From 81a85872887a5d6ca8484e425586881307abeec0 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 9 Sep 2026 21:08:04 -0700 Subject: [PATCH] fix(context): stop() releases the singletons it destroyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() called @pre_destroy on every resolved bean and then left each instance on its registration, so the container went on holding objects whose pools were closed, whose consumers were stopped and whose files were flushed. Two consequences, both silent: get_bean() after a stop returned a DESTROYED singleton rather than failing or rebuilding, and a later start() created a fresh set beside the stale one, so anything walking the registrations — health reporting, metrics, a bean inventory — saw every singleton twice, one live and one dead. stop() now clears the instance from every registration it destroyed, which is what makes it the inverse of start() rather than half of it. It releases only what the container BUILT: anything handed to it as a ready-made object — the container's own self-registration, the context's, anything an embedder registered as an instance — has no factory to rebuild it, so it is left alone and the next start() still works. start() records that distinction as it begins. Found with the same service that surfaced the double-start in 26.09.02: a start/stop/start cycle across two test modules left two EventPublisher instances in one container, one of them dead. Release 26.09.03. --- CHANGELOG.md | 23 +++++++++++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/context/application_context.py | 37 ++++++++++++++++++++ tests/context/test_application_context.py | 41 +++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 105 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8849b32..c56e4d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.09.03 (2026-09-10) + +### Fixed + +- **`ApplicationContext.stop()` releases the singletons it destroyed.** `stop()` called `@pre_destroy` + on every resolved bean and then left each instance on its registration, so the container went on + holding objects whose pools were closed, whose consumers were stopped and whose files were flushed. + Two consequences followed, both silent: `get_bean()` after a stop returned a **destroyed** singleton + rather than failing or rebuilding, and a later `start()` created a fresh set **beside** the stale one, + so anything walking the registrations — health reporting, metrics, a bean inventory — saw every + singleton twice, one live and one dead. + + `stop()` now clears the instance from every registration it destroyed, which is what makes it the + inverse of `start()` rather than half of it. It releases only what the container **built**: anything + handed to it as a ready-made object — the container's own self-registration, the context's, anything + an embedder registered as an instance — has no factory to rebuild it, so it is left in place and the + next `start()` still works. `start()` records that distinction as it begins. + + Found with the same service that surfaced the double-start in `26.09.02`: a start/stop/start cycle + across two test modules left two `EventPublisher` instances in one container, one of them dead. + +--- + ## v26.09.02 (2026-09-10) ### Fixed diff --git a/README.md b/README.md index 400655d2..5710fa7a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.09.02 + Version: 26.09.03 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index bfd6ec6d..eec1c5fe 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.2" +version = "26.9.3" 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 bc421451..7afe1ecc 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.02" +__version__ = "26.09.03" diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py index 952fe23d..9d8ef015 100644 --- a/src/pyfly/context/application_context.py +++ b/src/pyfly/context/application_context.py @@ -71,6 +71,8 @@ def __init__(self, config: Config) -> None: self._event_bus = ApplicationEventBus() self._post_processors: list[BeanPostProcessor] = [] self._started = False + #: Instances the container was HANDED rather than built; stop() must not release them. + self._preexisting_instances: set[int] = set() self._infrastructure_adapters: list[Any] = [] self._task_scheduler: Any | None = None self._background_tasks: list[asyncio.Task[Any]] = [] @@ -196,6 +198,16 @@ async def start(self) -> None: async def _do_start(self) -> None: """Internal startup logic.""" + # Whatever already carries an instance was HANDED to the container, not built by it — the + # container's self-registration, the context's own, anything an embedder registered as a + # ready-made object. stop() releases what this start creates and leaves these alone, because + # they cannot be rebuilt: they have no factory, and discarding them breaks the next start. + self._preexisting_instances = { + id(reg.instance) + for reg in self._container._registrations.values() + if getattr(reg, "instance", None) is not None + } + # 0. Register built-in @auto_configuration classes self._register_auto_configurations() @@ -349,9 +361,11 @@ async def stop(self) -> None: # by instance identity so an interface-typed @bean alias is not # destroyed twice (audit #113). seen_destroy: set[int] = set() + destroyed: list[Any] = [] for reg in reversed(list(self._container._registrations.values())): if reg.instance is not None and id(reg.instance) not in seen_destroy: seen_destroy.add(id(reg.instance)) + destroyed.append(reg.instance) try: await asyncio.wait_for( self._call_pre_destroy(reg.instance), @@ -363,6 +377,29 @@ async def stop(self) -> None: extra={"bean": type(reg.instance).__qualname__, "timeout_s": shutdown_timeout}, ) + # RELEASE what was just destroyed. @pre_destroy has closed these pools, stopped these + # consumers and flushed these files, so keeping them on their registrations leaves the + # container handing out objects that no longer work: get_bean() after a stop returned a + # destroyed singleton instead of failing or rebuilding. It also made a restart accumulate — + # start() creates a fresh set BESIDE the stale one, so anything walking the registrations + # (health reporting, metrics, a bean inventory) sees every singleton twice, one live and one + # dead. Clearing here is what makes stop() the inverse of start() rather than half of it. + released = 0 + for reg in self._container._registrations.values(): + if ( + reg.instance is not None + and id(reg.instance) in seen_destroy + and id(reg.instance) not in self._preexisting_instances + ): + reg.instance = None + released += 1 + + if released: + logger.debug( + "context_singletons_released", + extra={"registrations": released, "instances": len(seen_destroy)}, + ) + await self._event_bus.publish(ContextClosedEvent()) self._started = False diff --git a/tests/context/test_application_context.py b/tests/context/test_application_context.py index 82405506..06234f7e 100644 --- a/tests/context/test_application_context.py +++ b/tests/context/test_application_context.py @@ -747,3 +747,44 @@ async def test_a_stopped_context_can_be_started_again() -> None: assert len(_CountingConfiguration.instances) == 2, "a restarted context must rebuild its singletons" await context.stop() + + +@pytest.mark.asyncio +async def test_stop_releases_the_singletons_it_destroyed() -> None: + """A stopped context must not keep handing out beans it has already destroyed. + + stop() calls @pre_destroy on every resolved bean but used to leave the instance on its + registration, so the container went on holding objects whose pools were closed, whose consumers + were stopped and whose files were flushed. Two things follow, both bad: get_bean() after a stop + returns a destroyed object rather than failing or rebuilding, and a later start() adds a fresh set + BESIDE the stale one, so anything walking the registrations — health reporting, metrics, a bean + inventory — sees each singleton twice, one live and one dead. + """ + _CountingConfiguration.instances.clear() + + context = ApplicationContext(Config({})) + context.register_bean(_CountingConfiguration) + + await context.start() + first = context.get_bean(_Counted) + + await context.stop() + + held = [ + reg.instance + for reg in context._container._registrations.values() + if getattr(reg, "instance", None) is not None and isinstance(reg.instance, _Counted) + ] + assert held == [], f"a stopped context is still holding {len(held)} destroyed singleton(s)" + + await context.start() + + live = { + id(reg.instance) + for reg in context._container._registrations.values() + if getattr(reg, "instance", None) is not None and isinstance(reg.instance, _Counted) + } + assert len(live) == 1, "a restarted context holds both the new singleton and the destroyed one" + assert id(first) not in live, "the destroyed singleton is still registered after the restart" + + await context.stop() diff --git a/uv.lock b/uv.lock index 2df823d0..e2dcc515 100644 --- a/uv.lock +++ b/uv.lock @@ -2285,7 +2285,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.9.1" +version = "26.9.2" source = { editable = "." } dependencies = [ { name = "pydantic" },