fix(mcp_server): hold a strong reference to close_runners_sync's background close task - #302
Conversation
…ground close task close_runners_sync's in-running-loop branch fires loop.create_task(_close_detached_runners(runners)) and discards the result. asyncio only holds a weak reference to a scheduled Task once no other strong reference exists, so the task closing the cached runner's browser session is eligible for GC before it finishes - silently dropping the browser cleanup this shutdown path exists to guarantee. This is the exact fire-and-forget pitfall PR #300 fixed for navigator_client.py's _schedule_close. Apply the same fix here: retain the task in a module-level _pending_close_tasks set and drop it via a completion callback. Co-authored-by: Claude <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe runner shutdown path now retains asynchronous close tasks until they finish. A regression test verifies task retention during blocked cleanup and removal after completion. ChangesRunner shutdown lifecycle
Merge Risk: ⚪ Minimal · up to This localized shutdown fix keeps browser-session cleanup running to completion without changing the public API or normal control flow; no actionable merge-blocking risk remains after normal checks and review. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…orget tasks (#303) navigator_client.py's _schedule_close (#300) and mcp_server.py's close_runners_sync (#302) each independently fixed the identical unretained-fire-and-forget-task GC hazard by hand-rolling the same "add to a module-level set, discard via a done-callback" pattern (their own comments already cross-reference each other for the exact same hazard). overlay.py's _on_navigation guarded the same hazard with an instance-level set and an extra logging callback. Extracted retain_background_task(pending, task) into utils.py, alongside this repo's other small shared helpers (resolve_optional_method, elapsed_ms, now_ms). All three call sites now delegate to it instead of duplicating the add+discard logic; overlay.py still chains its own extra completion callback afterward. Pure extraction — same set object, same task, same discard-on-completion timing — so the existing regression tests asserting on `module._pending_close_tasks` pass unchanged. Added direct unit tests for the new helper. Verified: full suite 326 passed / 21 pre-existing environment-only Chromium-sandbox failures (unchanged baseline, was 324/21 before this PR's 2 new tests); ruff check/format --check clean on touched files. Co-authored-by: Claude <noreply@anthropic.com>
What
close_runners_sync()'s in-running-loop branch firesloop.create_task(_close_detached_runners(runners))and discards thereturned
Task, with no other strong reference kept anywhere.Why
asyncio only holds a weak reference to a scheduled
Task; with no otherstrong reference, the task can be garbage-collected before it finishes —
silently dropping the browser-session cleanup this shutdown path exists to
guarantee. This is the exact fire-and-forget hazard PR #300 fixed for
navigator_client.py's_schedule_close(same underlying asyncio pitfall,same docstring warning). This code path is exercised whenever
close_runners_sync()runs while a loop is already active — covered by theexisting
test_close_runners_sync_resets_state_immediately_inside_running_looptest, which only asserts on state reset, not on the task surviving to
completion.
Fix
Apply the identical pattern PR #300 established: retain the task in a
module-level
_pending_close_tasksset and drop it via a completioncallback (
task.add_done_callback(_pending_close_tasks.discard)).Why safe
mcp_server.py), plus aregression test in
tests/test_mcp_server.pymirroring the one added for_schedule_closein PR fix(navigator_client): hold a strong reference to scheduled close() background tasks #300 (asserts the task lands in_pending_close_tasks, then completes and clears out of it once itsawaited
close()finishes).only the task's GC-eligibility window changes (now waits for completion
instead of being collectible immediately).
uv run ruff check src/ tests/anduv run pytest tests/pass with thesame known baseline (324 passed = 323 pre-existing + 1 new test, same 21
pre-existing Chromium-sandbox environment failures, unchanged from
unmodified
main).uv run ruff format --checkflags the same 7pre-existing unrelated files on
mainbefore this change; no newlytouched file is among them.
Generated by Claude Code
Note
Low Risk
Shutdown-only lifecycle fix with no API or behavior change beyond ensuring async close tasks complete; low risk aside from edge cases during MCP server exit.
Overview
Fixes a shutdown race where
close_runners_sync()could schedule_close_detached_runnerswithloop.create_task(...)and drop theTask, allowing asyncio to garbage-collect it before Playwright/browser cleanup finishes.The change adds a module-level
_pending_close_tasksset (same approach asnavigator_client._schedule_close) and registers each close task withadd_done_callbackso the reference is held until completion, then removed.A new async regression test uses a slow
close()to assert the task stays in_pending_close_tasksuntil it finishes and that runner cleanup actually runs.Reviewed by Cursor Bugbot for commit 5fb362c. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
Bug Fixes
Tests