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 @@
-
+
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" },