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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.01-brightgreen" alt="Version: 26.09.01"></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="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.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"
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.01"
__version__ = "26.09.02"
23 changes: 22 additions & 1 deletion src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions tests/context/test_application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading