diff --git a/CHANGELOG.md b/CHANGELOG.md index e8849b3..c56e4d7 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 400655d..5710fa7 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 bfd6ec6..eec1c5f 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 bc42145..7afe1ec 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 952fe23..9d8ef01 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 8240550..06234f7 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 2df823d..e2dcc51 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" },