Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.02-brightgreen" alt="Version: 26.09.02"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.03-brightgreen" alt="Version: 26.09.03"></a>
<a href="https://mypy-lang.org/"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#philosophy"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.09.02"
__version__ = "26.09.03"
37 changes: 37 additions & 0 deletions src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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),
Expand All @@ -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

Expand Down
41 changes: 41 additions & 0 deletions tests/context/test_application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading