diff --git a/CHANGELOG.md b/CHANGELOG.md index eacb06c1..e8849b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.09.02 (2026-09-10) + +### Fixed + +- **`ApplicationContext.start()` is idempotent.** The context already tracked `_started` — set at the + end of `start()`, cleared at the end of `stop()` — but nothing ever read it, so a second `start()` + re-ran the whole pipeline rather than returning. Auto-configurations registered again, + `@configuration` classes were processed again, and a second fully-initialised set of singletons was + created and **started** beside the first: a second Kafka consumer joining the same group and stealing + partitions from the first, a second scheduler firing every `@scheduled` task twice, a second + connection pool. Nothing owned the duplicates, so `stop()` disposed one set and leaked the other. + + A double start is easy to reach — an ASGI server that runs the lifespan twice, a reload, a test + harness sharing one module-level application across files — and it failed silently, which is the + worst property a lifecycle bug can have. Every adapter in the codebase already guards itself this way + (`KafkaEventBus.start()` opens with `if self._started: return`); the context now follows its own + convention. `stop()` still clears the flag, so a stopped context restarts and rebuilds normally. + +--- + ## v26.09.01 (2026-09-09) Found by building a real service on `26.07.01`. Two defects, both of the same shape: a capability the diff --git a/README.md b/README.md index be188bce..400655d2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.01 + Version: 26.09.02 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 5dc9e7b5..bfd6ec6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.9.1" +version = "26.9.2" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 94b20eb2..bc421451 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.09.01" +__version__ = "26.09.02" diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py index 402271a8..952fe23d 100644 --- a/src/pyfly/context/application_context.py +++ b/src/pyfly/context/application_context.py @@ -161,7 +161,28 @@ def bean_count(self) -> int: # ------------------------------------------------------------------ async def start(self) -> None: - """Start the context: resolve @configuration beans, call lifecycle hooks, publish events.""" + """Start the context: resolve @configuration beans, call lifecycle hooks, publish events. + + Idempotent. A context that is already started returns immediately, because re-running the + pipeline does not refresh the context — it BUILDS A SECOND ONE beside it. Auto-configurations + register again, ``@configuration`` classes are processed again, and a second fully-initialised + set of singletons is created and started: a second Kafka consumer joins the same group and + steals partitions from the first, a second scheduler fires every ``@scheduled`` task twice, a + second connection pool opens. Nothing owns the duplicates, so :meth:`stop` disposes one set and + leaks the other. + + A double start is easy to reach — an ASGI server that runs the lifespan twice, a reload, a test + harness sharing one module-level application across files — and it fails silently, which is the + worst property a lifecycle bug can have. Every adapter in this codebase already guards itself + the same way (``KafkaEventBus.start()`` opens with ``if self._started: return``); the context + now follows its own convention. + + :meth:`stop` clears the flag, so a stopped context can be started again and rebuilds normally. + """ + if self._started: + logger.debug("context_start_ignored", extra={"reason": "already started"}) + return + try: await self._do_start() except BeanCreationException: diff --git a/tests/context/test_application_context.py b/tests/context/test_application_context.py index 15c98ec3..82405506 100644 --- a/tests/context/test_application_context.py +++ b/tests/context/test_application_context.py @@ -673,3 +673,77 @@ class NeverService: await ctx.start() with pytest.raises(NoSuchBeanError): ctx.get_bean(NeverService) + + +# --------------------------------------------------------------------------- +# start() is idempotent +# +# The context already tracked `_started` — it was set True at the end of start() +# and False at the end of stop() — but nothing ever READ it, so a second start() +# re-ran the entire pipeline: auto-configurations re-registered, @configuration +# classes re-processed, and a second, fully-initialised set of singletons created +# beside the first. +# +# That is not a test-only concern. The second set is STARTED: a second Kafka +# consumer joins the same group and steals partitions from the first, a second +# scheduler fires every @scheduled task twice, a second connection pool opens. +# Nothing owns the duplicates, so stop() disposes one set and leaks the other. +# A double start is easy to reach — an ASGI server that runs the lifespan twice, +# a reload, a test harness that shares one module-level app across files. +# +# PyFly's own adapters already guard this way (`KafkaEventBus.start()` opens with +# `if self._started: return`); the context now follows its own convention. +# --------------------------------------------------------------------------- + + +@configuration +class _CountingConfiguration: + instances: list[object] = [] + + @bean + def counted(self) -> "_Counted": + made = _Counted() + _CountingConfiguration.instances.append(made) + return made + + +class _Counted: + pass + + +@pytest.mark.asyncio +async def test_starting_twice_does_not_build_a_second_set_of_singletons() -> None: + _CountingConfiguration.instances.clear() + + context = ApplicationContext(Config({})) + context.register_bean(_CountingConfiguration) + + await context.start() + first = context.get_bean(_Counted) + assert len(_CountingConfiguration.instances) == 1 + + await context.start() # the second start must do nothing + + assert len(_CountingConfiguration.instances) == 1, ( + "start() ran the bean pipeline again and built a second singleton" + ) + assert context.get_bean(_Counted) is first, "the container handed back a different instance" + + await context.stop() + + +@pytest.mark.asyncio +async def test_a_stopped_context_can_be_started_again() -> None: + """Idempotence must not turn into a one-shot: stop() clears the flag.""" + _CountingConfiguration.instances.clear() + + context = ApplicationContext(Config({})) + context.register_bean(_CountingConfiguration) + + await context.start() + await context.stop() + await context.start() + + assert len(_CountingConfiguration.instances) == 2, "a restarted context must rebuild its singletons" + + await context.stop()