Skip to content

fix: shut the dashboard down quietly on Ctrl+C - #10

Merged
cboulay merged 1 commit into
devfrom
fix/graceful-ctrl-c
Aug 14, 2026
Merged

cboulay merged 1 commit into
devfrom
fix/graceful-ctrl-c

Conversation

@cboulay

@cboulay cboulay commented Aug 14, 2026

Copy link
Copy Markdown
Member

Stacked on #8 (base is feat/publisher-focus-on-unit-click), so the diff here is just the four files below.

CI does not run on this PR: both workflows trigger only on pull requests targeting main or dev. Retarget once the stack below it merges. Verified locally — see Verification.

The reported symptom

Ctrl+C on ezmsg dashboard ends in a traceback:

  File ".../asyncio/runners.py", line 123, in run
    raise KeyboardInterrupt()
KeyboardInterrupt

There turned out to be two separate causes, and the second is the one that actually made it unpleasant.

1. uvicorn re-raises the signal by design

Server.capture_signals() handles SIGINT, shuts the application down gracefully, restores the original handlers, and then calls signal.raise_signal() on what it captured — deliberately, so callers see conventional interrupt semantics. The restored handler is asyncio.Runner's, which cancels the main task and converts it into KeyboardInterrupt.

So the traceback is printed after a clean shutdown. For a foreground server that is pure noise, so serve_dashboard now swallows KeyboardInterrupt/CancelledError from server.run() and logs Dashboard stopped.. Anything else still propagates — there's a test for that, since it would be easy to swallow a real startup failure here.

2. The event stream held shutdown open for ten seconds

With a browser attached, the first Ctrl+C took 10 seconds. The /ws/events handler only woke when the graph service yielded an envelope, so on a quiet graph it sat in queue.get() until the next heartbeat — heartbeat_seconds is 10 — and uvicorn waited for that connection before finishing shutdown.

Ten seconds is long enough that Ctrl+C looks hung, so it gets pressed again — and the second press sets uvicorn's force_exit, tearing the loop down while the lifespan task is mid-receive(). That produced the ugliest output of all, a cancelled-task dump on top of the KeyboardInterrupt.

The handler now runs the envelope pump and a disconnect watcher concurrently and stops as soon as either finishes. The same change means a closed browser tab releases its graph subscriptions immediately instead of up to a heartbeat later.

Measured with a live websocket connected:

shutdown time exit code traceback
before 10.05s 130 (killed by SIGINT) yes
after 0.16s 0 no

Verification

Reproduced first, against a real graph server, with a real websocket client, sending SIGINT to the process group the way a terminal does. All three cases — no websocket, websocket attached, and websocket plus a second interrupt — produced the reported traceback before and are clean after, with Application shutdown complete still logged before Dashboard stopped. in each.

Tests: 46 backend passed (35 frontend and 28 e2e unchanged and still green).

  • test_serve_dashboard_exits_quietly_on_interrupt — parametrized over KeyboardInterrupt and CancelledError, using a stand-in server that reproduces uvicorn's raise-after-shutdown behavior.
  • test_serve_dashboard_still_propagates_real_errors — a RuntimeError from run() must not be swallowed.
  • test_events_websocket_returns_when_the_client_disconnects — drives the ASGI app directly rather than through TestClient, because TestClient cancels the application task on teardown, which hides whether the handler noticed anything itself. Confirmed it fails (TimeoutError) without the app change and passes with it — I wrote a TestClient version first and threw it away when it passed against unfixed code.

Note

The graceful path now exits 0 rather than 130. That's a deliberate choice for an interactive server stopped by its operator; if you'd rather keep the conventional interrupt status for scripting, raise SystemExit(130) in place of the pass is the whole change.

🤖 Generated with Claude Code

Ctrl+C ended in a KeyboardInterrupt traceback, and with a browser attached it
took ten seconds to get there. Two causes:

uvicorn shuts the application down gracefully on SIGINT and then re-raises the
signal so that callers see conventional interrupt semantics. For a foreground
server that only amounts to a traceback printed after a clean shutdown, so
serve_dashboard now swallows it and logs "Dashboard stopped." instead. Real
failures from `server.run()` still propagate.

The delay was the event stream. The `/ws/events` handler only woke when the
graph service yielded, so a quiet stream held the connection -- and with it the
server shutdown -- until the next heartbeat, ten seconds away. Long enough that
Ctrl+C looks hung and gets pressed again, and the second press tears the loop
down mid-flight, which is where the messiest output came from. The handler now
watches for the disconnect concurrently and stops as soon as one arrives.

Measured with a live websocket: 10.05s to shut down, before; 0.16s, after.
A second Ctrl+C no longer has time to land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from feat/publisher-focus-on-unit-click to dev August 14, 2026 23:10
@cboulay
cboulay merged commit 9015cdd into dev Aug 14, 2026
@cboulay
cboulay deleted the fix/graceful-ctrl-c branch August 14, 2026 23:10
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