Skip to content

fix: clean Ctrl-C shutdown — daemon teardown + quiet SSE cancellation#166

Merged
ArthurBernard merged 1 commit into
developfrom
fix/ctrlc-clean-shutdown
Jul 5, 2026
Merged

fix: clean Ctrl-C shutdown — daemon teardown + quiet SSE cancellation#166
ArthurBernard merged 1 commit into
developfrom
fix/ctrlc-clean-shutdown

Conversation

@ArthurBernard

Copy link
Copy Markdown
Owner

Summary

Two related shutdown defects, both against uvicorn 0.49's signal/streaming behavior:

Defect A — start --serve loses its teardown. uvicorn 0.49's Server.serve() unconditionally wraps itself in capture_signals(), which restores the pre-serve() signal disposition and re-raises the captured SIGINT/SIGTERM with that (default) disposition right after serve() returns — killing the process before _run_daemon's own finally (scheduler shutdown + supervisor.shutdown()) can run. Fixed by overriding the built Server instance's capture_signals with a no-op context manager (there is no public install_signal_handlers config flag on this uvicorn version — confirmed it raises TypeError at construction) and installing the daemon's own SIGINT/SIGTERM handlers on the running loop around await server.serve(): first signal sets should_exit, second sets force_exit. The headless path (no --serve) is untouched.

Defect B — traceback spam on every shutdown with a connected dashboard. With a browser/curl holding /api/events, the 3s graceful-shutdown timeout force-cancels the connection and uvicorn logs a multi-screen CancelledError/anyio.WouldBlock traceback at ERROR level. Both SSE generators now catch asyncio.CancelledError around their streaming loop and end cleanly (the merged generator also cancels any outstanding per-iteration getter task). This alone isn't sufficient, though: the traceback actually originates in Starlette's own StreamingResponse.__call__ (an anyio task group racing "stream the body" vs "listen for disconnect" — the code path uvicorn 0.49 is stuck on since it declares ASGI spec_version: "2.3", below the 2.4 Starlette checks for its newer implementation), confirmed by reproducing the identical traceback against a bare FastAPI app with no custom middleware at all. So a small logging.Filter on uvicorn.error also drops the "Exception in ASGI application" record specifically when the underlying exception is a plain CancelledError — any real exception is still logged normally.

Verified (pty driver, genuine keyboard Ctrl-C, SSE client attached)

  1. trading-bot dashboard + SSE + one ^C → exit 0 in ~4.2s, dashboard stopped printed, only the expected "Cancel 1 running task(s)..." line, no CancelledError traceback.
  2. trading-bot start --serve --interval 3600 + SSE + one ^C → exit 0 in ~4.2s, daemon stopped (all strategies shut down) printed, no traceback.
  3. trading-bot start --interval 3600 (headless) + SIGINT → unchanged, daemon stopped in ~1s.

Test plan

  • python -m pytest — 1170 passed (added: single/merged SSE cancellation tests, _run_daemon signal-ownership test, logging-filter unit tests)
  • ruff check trading_bot/ clean
  • ruff format --check trading_bot/ clean
  • mypy trading_bot/ clean
  • pty-driver verification of all three scenarios above

🤖 Generated with Claude Code

start --serve died before its supervisor teardown ran because uvicorn
0.49's Server.serve() unconditionally re-raises the captured SIGINT/
SIGTERM (with the default disposition) right after serve() returns.
_run_daemon now overrides the server's capture_signals with a no-op
and owns the signal itself (there is no install_signal_handlers config
flag on this uvicorn version), so its own finally (scheduler shutdown +
supervisor.shutdown()) always completes.

Separately, a connected dashboard/SSE client made every shutdown log a
multi-screen CancelledError/anyio.WouldBlock traceback at ERROR level:
uvicorn force-cancels the SSE connection past the graceful-shutdown
timeout, and Starlette's StreamingResponse (uvicorn 0.49 is stuck on
its older, task-group-based implementation) re-raises that unconverted.
Both /api/events generators now end cleanly on cancellation (the merged
one also cancels any outstanding per-iteration getter task), and a
logging filter drops uvicorn's own "Exception in ASGI application"
record for that specific, expected cancellation shape.

Verified with a pty driver sending a genuine keyboard Ctrl-C against a
connected SSE client: dashboard and start --serve both exit 0 in under
5s with their "stopped" banner and no ERROR-level traceback; headless
start + SIGINT is unchanged.
@ArthurBernard ArthurBernard force-pushed the fix/ctrlc-clean-shutdown branch from 4d0cca5 to 0fb8f4e Compare July 5, 2026 09:17
@ArthurBernard ArthurBernard merged commit 037ad11 into develop Jul 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant