Skip to content

fix: hold strong references to lifespan background tasks#6629

Open
sricursion wants to merge 1 commit into
keephq:mainfrom
sricursion:fix/6552-background-task-strong-refs
Open

fix: hold strong references to lifespan background tasks#6629
sricursion wants to merge 1 commit into
keephq:mainfrom
sricursion:fix/6552-background-task-strong-refs

Conversation

@sricursion

@sricursion sricursion commented Jul 12, 2026

Copy link
Copy Markdown

Linked Issue

Fixes #6552

Description

startup() and lifespan() create fire-and-forget tasks with asyncio.create_task(...) and discard the returned Task:

  1. the maintenance-window watcher (async_process_watcher)
  2. the check_pending_tasks debug logger

The event loop keeps only weak references to tasks, so either task can be garbage-collected and silently cancelled mid-execution — no traceback; the watcher just stops running and maintenance-window alert recovery silently breaks.

This change adds a module-level service_tasks set and a create_service_task() helper that holds a strong reference and discards the task on completion, per the pattern recommended in the asyncio docs. Both call sites now go through it.

A separate set is used instead of the request-scoped background_tasks set on purpose: that set's length is what check_pending_tasks reports as pending events (and routes/alerts.py adds event-processing tasks to it), so long-lived service tasks in the same set would permanently skew the count.

Test

Added tests/test_service_tasks.py: the task is strongly referenced while running and discarded on completion.

Also verified under forced GC (all local references dropped, gc.collect() ×3 — the task survives and runs to completion) and on a live server boot with WATCHER=true and KEEP_DEBUG_TASKS=true: the watcher completed multiple full cycles and the debug logger ran continuously for 2+ minutes with zero task was destroyed / exception was never retrieved log entries. Full pytest --non-integration suite passes with no regressions. ruff check passes.

@CLAassistant

CLAassistant commented Jul 12, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@dosubot dosubot Bot added the size:S This PR changes 10-29 lines, ignoring generated files. label Jul 12, 2026
asyncio.create_task() results were discarded in startup() (maintenance
window watcher) and lifespan() (pending-tasks debug logger). The event
loop only keeps weak references to tasks, so either task could be
garbage-collected and silently cancelled mid-execution.

Keep the tasks in a module-level set with a done-callback discard, per
the pattern recommended in the asyncio docs. A separate set is used
rather than the request-scoped background_tasks set so the debug
logger's pending-events count is not skewed by long-lived service
tasks.

Fixes keephq#6552
@sricursion
sricursion force-pushed the fix/6552-background-task-strong-refs branch from 55c041a to 3483497 Compare July 12, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: background tasks created in api.py lifespan can be garbage-collected mid-execution

2 participants