You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the std engine, a cancel of StartWithContext's (or StartWithListenerAndContext's) context does not keep to Config.ShutdownTimeout. A handler still running when the budget expires holds the drain, the OnShutdown hooks and the Start call until it returns, however long that is, and the shutdown still reports no error. A direct Server.Shutdown(ctx) on std keeps to its ctx.
Found while measuring, for #738's docs, what a shutdown deadline does to a request still in its handler (lane LIFECYCLE, with the #703 fix, PR #746).
Measured
ShutdownTimeout (or the direct Shutdown's ctx) 500 ms; one request held 2 s in its handler, which either waits for c.Context() or ignores it; linux/arm64 Docker.
Each cell is one run; times are from the moment the shutdown began (738/probe/table.py over the logs):
In every std cancel run the hook's ctx was already done when it ran, and StartWithContext returned nil. c.Context() was not cancelled in any of the 72 runs (all engines, both modes).
Why
The watcher runs Server.shutdown(shutCtx), whose first engine step is std.Engine.Shutdown(shutCtx), a sync.Once around http.Server.Shutdown.
But the context handed to Listen is derived from the caller's context, so the cancel reaches std's Listen first: its case <-ctx.Done(): return e.Shutdown(context.Background()) takes the Once with no deadline. The watcher's call then waits in once.Do for that unbounded drain. std's Shutdown comment names this race ("callers race for the once") and relies on the escalation it arms on the caller's ctx: context.AfterFunc(ctx, e.baseCancel) cancels every r.Context() at the deadline (Detached-stream lifecycle follow-ups after #496: H2/h2c SSE disconnect, epoll EPOLLRDHUP skip, std Shutdown, io_uring undrained-send reap #498).
The escalation does not reach a celeris handler: Context.Context() returns the stream's context, and on an H1 stream that is context.Background() (stream.(*Stream).Context, h1Mode). Only the SSE/WebSocket detach-close hook is wired to r.Context() (engine/std/bridge.go). So nothing tells the handler, the drain waits for it, and the Once body's error never reaches the watcher, whose Engine.Shutdown call did not run the body and returns nil.
A related doc gap: the engine.Engine interface says of Shutdown "if it expires, remaining connections are closed". No engine does that: in every case above the handler's connection stayed open and the client got the whole response at 2 s.
Fix direction (not tried)
Either keep std's Listen from starting an unbounded drain of its own when a Server.Shutdown with a budget is coming (the watcher always runs one on a cancel since #692), or hand the budget to whichever call wins the Once (for example, Listen waits for the first Shutdown caller's context). Giving handlers r.Context() on std would make the escalation reach a handler that watches c.Context(), but not one that ignores it. The fix belongs to std; epoll, io_uring and adaptive run the hooks at the deadline since #703.
Evidence: evidence/lanes-20260927/LIFECYCLE/738/probe/ in the maintainer's probatorium evidence root (run-probe.sh, deadline_probe_linux_test.go, the logs).
Summary
On the
stdengine, a cancel ofStartWithContext's (orStartWithListenerAndContext's) context does not keep toConfig.ShutdownTimeout. A handler still running when the budget expires holds the drain, theOnShutdownhooks and theStartcall until it returns, however long that is, and the shutdown still reports no error. A directServer.Shutdown(ctx)onstdkeeps to itsctx.Found while measuring, for #738's docs, what a shutdown deadline does to a request still in its handler (lane LIFECYCLE, with the #703 fix, PR #746).
Measured
ShutdownTimeout(or the directShutdown's ctx) 500 ms; one request held 2 s in its handler, which either waits forc.Context()or ignores it; linux/arm64 Docker.Each cell is one run; times are from the moment the shutdown began (
738/probe/table.pyover the logs):dccb839ShutdownTimeout500 ms)c.Context()StartWithContext2123 msdccb839f7685c3, unconstrained memlockc.Context()/ ignores itf7685c3, CI shapeShutdown(500 ms ctx)Shutdown500-501 ms,context.DeadlineExceededf7685c3(4 runs)StartWithContextat 2000-2001 msIn every std cancel run the hook's ctx was already done when it ran, and
StartWithContextreturned nil.c.Context()was not cancelled in any of the 72 runs (all engines, both modes).Why
Server.shutdown(shutCtx), whose first engine step isstd.Engine.Shutdown(shutCtx), async.Oncearoundhttp.Server.Shutdown.Listenis derived from the caller's context, so the cancel reaches std'sListenfirst: itscase <-ctx.Done(): return e.Shutdown(context.Background())takes theOncewith no deadline. The watcher's call then waits inonce.Dofor that unbounded drain. std'sShutdowncomment names this race ("callers race for the once") and relies on the escalation it arms on the caller's ctx:context.AfterFunc(ctx, e.baseCancel)cancels everyr.Context()at the deadline (Detached-stream lifecycle follow-ups after #496: H2/h2c SSE disconnect, epoll EPOLLRDHUP skip, std Shutdown, io_uring undrained-send reap #498).Context.Context()returns the stream's context, and on an H1 stream that iscontext.Background()(stream.(*Stream).Context,h1Mode). Only the SSE/WebSocket detach-close hook is wired tor.Context()(engine/std/bridge.go). So nothing tells the handler, the drain waits for it, and theOncebody's error never reaches the watcher, whoseEngine.Shutdowncall did not run the body and returns nil.Shutdowndoes not hit this:Listen's context is cancelled only afterEngine.Shutdown(the Server.Start / StartWithListener never return after Shutdown on io_uring and epoll: Listen blocks on a Background context and Engine.Shutdown is a no-op #595 ordering), so the caller'sctxwins theOnce.A related doc gap: the
engine.Engineinterface says ofShutdown"if it expires, remaining connections are closed". No engine does that: in every case above the handler's connection stayed open and the client got the whole response at 2 s.Fix direction (not tried)
Either keep std's
Listenfrom starting an unbounded drain of its own when aServer.Shutdownwith a budget is coming (the watcher always runs one on a cancel since #692), or hand the budget to whichever call wins theOnce(for example,Listenwaits for the firstShutdowncaller's context). Giving handlersr.Context()on std would make the escalation reach a handler that watchesc.Context(), but not one that ignores it. The fix belongs to std; epoll, io_uring and adaptive run the hooks at the deadline since #703.Evidence:
evidence/lanes-20260927/LIFECYCLE/738/probe/in the maintainer's probatorium evidence root (run-probe.sh,deadline_probe_linux_test.go, the logs).