Repository navigation
fix(rpc): end event streams when the node shuts down - #745
MegaRedHand wants to merge 8 commits into
Conversation
Graceful shutdown waits for every in-flight response, and an SSE stream never finishes on its own: the router keeps its own EventBus clone, so the channel never closes. Any subscriber left connected to /eth/v1/events or /lean/v0/events therefore held SIGINT forever, and SIGKILL skips the state flush. A permanent subscriber such as contributoor would make every restart hang. serve() now hands each router its shutdown token as a ShutdownSignal extension, and sse_response ends the stream when it fires.
Graceful shutdown waits for every in-flight response, and an SSE stream never finishes on its own: the router keeps its own EventBus clone, so the channel never closes. Any subscriber left connected to /eth/v1/events or /lean/v0/events therefore held SIGINT forever, and SIGKILL skips the state flush. A permanent subscriber such as contributoor would make every restart hang. serve() now hands each router its shutdown token as a ShutdownSignal extension, and sse_response ends the stream when it fires. Cherry-picked from fix/sse-end-on-shutdown at 3bba47d.
🤖 Claude Code ReviewReview of PR 745: end SSE streams on shutdown The approach is correct and minimal. I found no blocking issues, and ran no tests or build.
Minor, non-blocking points:
The docs updates in Verdict: Approve. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Kimi Code ReviewI'll review this PR which adds graceful shutdown support for SSE event streams. Let me analyze the changes across all files. Overall AssessmentThis is a well-structured PR that fixes a real operational issue: SSE streams blocking graceful shutdown. The approach using
|
| Priority | Location | Issue |
|---|---|---|
| Medium | events.rs:97 |
take_until may drop in-flight events at shutdown |
| Medium | tests/http_servers.rs:197 |
api_port == metrics_port is fragile |
| Low | events.rs:37-38 |
Default on ShutdownSignal is a footgun; accessor missing |
| Low | tests/http_servers.rs:210-220 |
TCP stream not explicitly closed |
| Low | events.rs:128-131 |
Comment contradicts code path |
The core fix is correct and the tests validate the behavior. Address the port collision and consider the event-dropping semantics before merge.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewAutomated review by OpenAI Codex |
|
Went through the Kimi review with a security lens. One item is worth acting The handlers get the node-wide shutdown token.
The rest of Kimi's items are not security issues on inspection:
|
serve put the very token wait_for_shutdown cancels into request scope, so any handler in the crate, or a future bug in one, could call cancel() and take the whole node down. A child token is cancelled with its parent but cannot cancel it, so a handler can now only wait for shutdown.
The token sat in a pub(crate) field, so code holding the signal could reach it and cancel, and Default built a signal that never fires, which in production would bring back the very hang this branch removes. The field is now private, the signal is built only through ShutdownSignal::of, which takes the child token itself, it exposes only a cancelled() future, and Default exists for tests alone.
Both events handlers required Extension<ShutdownSignal>, which only crate::serve adds, so a router served any other way (plain axum::serve over build_beacon_api_router, as two test files already do) answered GET /eth/v1/events with a 500 at runtime rather than failing to build. The handlers now take it as an Option, and without one the stream ends only when the client leaves, as every stream did before. The test-only Default goes with it: the unit tests now run without the layer, which is the case this covers.
Ending the event streams on the shutdown signal covered a client that keeps reading, but axum's graceful shutdown waits with no limit for every response, and a response stalls for as long as its client stops reading: a validator client paused mid-stream, or a slow download of a whole state. docs/rpc.md said a client never holds up shutdown, which was not true for those. serve now stops waiting HTTP_DRAIN_TIMEOUT after shutdown starts; the listener is already closed then, and the stalled connection tasks end with the runtime.
The shutdown test used one port for both routers, so it only covered the merged listener. The default ports go through the other branch of start_http_servers, two serve calls under try_join!, and a change that layered the signal onto only one of them would have passed. The test now runs both shapes. Its bound also drops well under the new drain timeout, so it is the stream ending on the signal that lets the servers stop, not the servers giving up on the stream.
|
@pablodeymo both are in:
Two more from a second review pass:
Agreed on the rest of your notes; nothing changed for them. |
Motivation
Graceful shutdown waits for every in-flight response, and an SSE stream never finishes on its own: the router keeps its own
EventBusclone, so the broadcast channel never closes andRecvError::Closednever arrives. Any subscriber left connected to/eth/v1/eventsor/lean/v0/eventstherefore holds SIGINT forever (SIGKILL skips the state flush).This blocks running a permanent subscriber next to a node, such as ethPandaOps' contributoor, which keeps one stream per topic open and reconnects on its own.
Ending the streams covers a client that keeps reading. axum's graceful shutdown also waits with no limit for any response whose client stopped reading, such as a paused validator client or a slow download of a whole state, so that needs a bound of its own.
Changes
serve()hands each router aShutdownSignalextension holding a child of its shutdown token. A child is cancelled with its parent but cannot cancel it, so a handler can wait for shutdown and never trigger the node's. The token is private: the signal is built only throughShutdownSignal::ofand exposes onlycancelled().sse_responseends the stream withtake_untilwhen the signal fires. Both the lean and the Beacon API handlers extract it as anOption: a router served withoutcrate::serve(plainaxum::serveoverbuild_beacon_api_router) answers, and its streams end when the client leaves, instead of a 500 for a missing extension.serve()stops waiting on responses still in flightHTTP_DRAIN_TIMEOUT(5 s) after shutdown starts. The listener is already closed by then, and the stalled connection tasks end with the runtime.docs/rpc.md: streams end at shutdown, and the servers wait at mostHTTP_DRAIN_TIMEOUTfor stalled responses.Tests
events_stream_ends_when_the_server_shuts_down: the body completes once the server's token is cancelled while the bus stays open.a_connected_event_stream_does_not_hold_up_shutdownand..._on_separate_ports(tests/http_servers.rs): bind real sockets, keep/eth/v1/eventsopen, cancel, and expect the servers to stop within 2 s, well under the drain timeout, so the signal is what ends them. One merges the API and metrics routers onto one listener, the other runs the default shape, two servers. Verified to fail (time out) without the fix.a_response_that_never_finishes_does_not_hold_up_shutdown: a body that never ends and knows nothing of the signal; the server stops after the drain timeout. Verified to time out without it. Adds about 5 s to the suite.cargo test --profile release-fast -p ethlambda-rpc --lib: 336 passed.--test http_servers: 6 passed.cargo clippy --locked -p ethlambda-rpc --all-targets -- -D warnings: clean.