ENG-9348: Lifespan tasks execute in registration order#6334
ENG-9348: Lifespan tasks execute in registration order#6334
Conversation
Avoid indeterminism when lifespan tasks are registered. This allows internally registered tasks to deterministically execute prior to any user defined lifespan tasks. Added test case for originally reported issue.
Greptile SummaryChanges the Confidence Score: 5/5Safe to merge; implementation is correct and the single remaining finding is a minor style suggestion. No P0/P1 issues found. The set→dict migration correctly preserves insertion order and deduplication semantics. The deprecated property returns a copy (intentional design). The only open finding is a P2 style suggestion to mark _lifespan_tasks with init=False to prevent accidental use as a constructor argument. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant User as User / Internal Code
participant RM as register_lifespan_task()
participant Store as _lifespan_tasks (dict)
participant Runner as _run_lifespan_tasks()
participant Task as Lifespan Task
User->>RM: register_lifespan_task(task, **kwargs)
RM->>Store: _lifespan_tasks[task] = None (insertion-ordered)
Note over Store: Order preserved: internal tasks first, then user tasks
User->>Runner: app startup (Starlette lifespan)
Runner->>Store: iterate keys in insertion order
loop For each task (in order)
Store-->>Runner: task callable
Runner->>Task: create asyncio.Task or enter AsyncContextManager
end
Runner-->>User: yield (app is running)
User->>Runner: app shutdown
Runner->>Task: cancel all running tasks
Reviews (2): Last reviewed commit: "little cleaner test assertion, for grept..." | Re-trigger Greptile |
|
@greptile-apps re-review this PR, my dog |
There was a problem hiding this comment.
Pull request overview
Updates the Reflex app lifespan task system to run deterministically in registration order, ensuring framework-internal lifespan tasks execute before user-registered ones and documenting the new behavior.
Changes:
- Switches lifespan task storage from an unordered
setto an insertion-ordereddictto preserve registration order. - Adds
get_lifespan_tasks()API and deprecates the publiclifespan_tasksattribute. - Adds an integration test covering
app.modify_stateusage from within a lifespan task and updates docs to reflect ordering and inspection.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
reflex/app_mixins/lifespan.py |
Stores tasks in insertion order, adds get_lifespan_tasks(), and deprecates lifespan_tasks. |
tests/integration/test_lifespan.py |
Adds an integration test and a new lifespan task that pushes state updates via modify_state. |
docs/utility_methods/lifespan_tasks.md |
Documents registration-order execution and the new task inspection API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Return frozenset instead of set from the deprecated lifespan_tasks property so callers cannot mistakenly mutate the returned collection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the @deprecated decorator to a TYPE_CHECKING-only stub so that IDEs and type checkers still surface the deprecation warning, but runtime access only triggers console.deprecate (avoiding duplicate deprecation signals). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove blanket exception handling in the test's modify_state_task so any unexpected errors propagate immediately rather than being hidden. The outer CancelledError handler remains for clean shutdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Extract _get_task_name helper that uses task.get_name() for asyncio.Task objects and __name__ for callables. This fixes an AttributeError when registering a pre-created asyncio.Task, which was already accepted by the type signature but crashed at runtime. Add integration test that creates an asyncio.Task during lifespan startup and registers it, verifying it runs and gets cancelled on shutdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add comments clarifying that test_lifespan must be the last test in the file since it shuts down the session-scoped backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
unfortunate, but changing these to `nonlocal` fixes the typing, but ultimately breaks the compiled app because these _do_ end up being module globals.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async with contextlib.AsyncExitStack() as stack: | ||
| for task in self.lifespan_tasks: | ||
| run_msg = f"Started lifespan task: {task.__name__} as {{type}}" # pyright: ignore [reportAttributeAccessIssue] | ||
| for task in self._lifespan_tasks: | ||
| task_name = _get_task_name(task) | ||
| run_msg = f"Started lifespan task: {task_name} as {{type}}" | ||
| if isinstance(task, asyncio.Task): |
There was a problem hiding this comment.
Iterating directly over self._lifespan_tasks will raise RuntimeError: dictionary changed size during iteration if a lifespan task registers additional lifespan tasks while startup is in progress (e.g., an async contextmanager that calls register_lifespan_task). Even if it doesn’t crash, tasks added mid-iteration won’t be visited and therefore won’t be tracked in running_tasks for cancellation. Consider iterating over a snapshot and/or using an index-based loop that can safely pick up newly-registered tasks (preserving registration order) so dynamically-registered asyncio.Tasks are also cancelled on shutdown.
Registering a task from within a running lifespan task would mutate the dict during iteration. Add a _lifespan_tasks_started flag that causes register_lifespan_task to raise RuntimeError once _run_lifespan_tasks has entered. The previous test that registered an asyncio.Task from inside a lifespan context manager is now a negative test asserting the RuntimeError. The raw_asyncio_task_coro is registered directly as a coroutine function (the framework wraps it in asyncio.create_task). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Lifespan tasks now execute in registration order instead of arbitrary set order. This ensures internally registered tasks deterministically run before user-defined lifespan tasks.
lifespan_tasksbacking store fromsettodict(insertion-ordered) to preserve registration order.get_lifespan_tasks()method returning atupleof registered tasks.app.modify_stateusage inside a lifespan task.Deprecations
LifespanMixin.lifespan_tasks(the publicsetattribute): deprecated in 0.9.0, removal in 1.0. Useget_lifespan_tasks()instead, which returns an orderedtuple.Test plan
test_lifespancontinues to passtest_lifespan_modify_stateverifies a lifespan task can useapp.modify_stateto push state updates to connected clients