Skip to content

fix(std): a cancel of StartWithContext's context keeps Config.ShutdownTimeout (celeris#753) - #803

Merged
FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-753-std-cancel-shutdown-timeout
Sep 29, 2026
Merged

FumingPower3925 merged 2 commits into
mainfrom
fix/celeris-753-std-cancel-shutdown-timeout

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Defect

On std, a cancel of StartWithContext's (or StartWithListenerAndContext's) context ignored Config.ShutdownTimeout. The context handed to Listen is derived from the caller's, so the cancel reaches std.Engine.Listen first, and its ctx.Done branch started the drain itself with context.Background(). The drain is a sync.Once around http.Server.Shutdown, so the watcher's Server.Shutdown, which carries the budget, waited in once.Do for that unbounded drain, and so did the OnShutdown hooks and the StartWithContext call, until the last handler returned. The watcher's Engine.Shutdown did not run the Once body and returned nil. The #498 escalation armed on the budget (baseCancel at the deadline) cancels r.Context(), which a celeris handler does not see on HTTP/1.1, so nothing ended the wait. A direct Server.Shutdown was not affected: Engine.Shutdown runs before Listen's context is cancelled, so its ctx won the Once.

Fix

engine/std: the drain runs under an engine-owned context (drainCtx), whichever call starts it, and every Shutdown(ctx) cancels that context when its ctx expires, together with the #498 escalation (one context.AfterFunc). So a drain that Listen started ends at the budget of the Shutdown that follows it. Every caller now gets the drain's result: a caller whose own ctx ran out gets that ctx's error (context.DeadlineExceeded, as a direct Shutdown did), and Listen still returns nil when a Shutdown cut its drain short (that Shutdown reports it). A drain that ends cleanly disarms the caller's AfterFunc as before, so a hijacked WebSocket's request context is left alone.

server.go: the Server.Shutdown godoc explained the cancel ordering by the old race ("Cancelling first would let Listen win Engine.Shutdown's sync.Once and strip the deadline"); it now says the drain keeps every Shutdown's deadline whichever call starts it.

engine/engine.go: the Engine.Shutdown doc said that when ctx expires "remaining connections are closed". No engine does that; it now says what they do (the issue's related doc gap).

No other engine changes: epoll, io_uring and adaptive already run the hooks at the deadline (#703).

Head 325d83a. The evidence below ran on 3d601ec; 325d83a changes only the Server.Shutdown godoc in server.go.

Failing first

Script: evidence/lanes-20260927/WRITE/753/run-controls.sh <ref> 5 (one container, CI shape: linux/arm64, 4 CPUs, 8 MiB memlock). Each variant of engine/std/engine.go is copied over the PR tree with cp; both tests run -count=5 -v; only --- PASS/FAIL/SKIP lines are counted.

variant of engine/std/engine.go TestListenCancelDrainKeepsTheShutdownBudget TestStartWithContextCancelKeepsShutdownTimeoutOnStd (2 subtests each run)
this PR 5/5 PASS 5/5 PASS (10/10 subtests)
main (negative control) 5/5 FAIL: "Shutdown with a 300ms budget had not returned 3.3s after it began" 5/5 FAIL (10/10): "no OnShutdown hook 3.3s after the cancel with ShutdownTimeout 300ms"
m1: the budget cancels only the escalation, not the drain 5/5 FAIL 5/5 FAIL (10/10)
m2: the drain ignores drainCtx 5/5 FAIL 5/5 FAIL (10/10)

Log: 753/logs/controls-3d601ec.log. The engine test forces the order (Listen's context is cancelled first, and Shutdown is called only once the listener refuses connections, which is the first thing http.Server.Shutdown does), and both tests hold the handler until every assertion has run, so a drain that waits for it fails every time, not late. m1 and m2 are the second control: each breaks one of the two halves of the fix, and each is caught.

The issue's measurement, re-run with lane LIFECYCLE's #738 deadline probe (unchanged copy; 753/probe/run-probe.sh <ref> m8): 500 ms deadline, a request held 2 s in its handler, times from the moment the shutdown began.

tree std, shutdown by handler hooks ran at StartWithContext returned client
main dfd044f cancel waits on c.Context() / ignores it 2121 / 2161 ms 2121 / 2161 ms 200 "done" at 2001 / 2009 ms
this PR cancel waits on c.Context() / ignores it 501 / 501 ms 501 / 501 ms 200 "done" at 2006 / 2009 ms
this PR direct Shutdown(500 ms) waits / ignores 504 / 505 ms 504 / 505 ms (Shutdown: DeadlineExceeded) 200 "done" at 2009 / 2003 ms

Logs: 753/probe/probe-dfd044f-m8.log, 753/probe/probe-3d601ec-m8.log (24/24 probe cases ran; the epoll, io_uring and adaptive rows are unchanged). The request itself is left to finish, as http.Server.Shutdown leaves it; c.Context() is still not cancelled at the deadline on std's HTTP/1.1 path.

Deadlock check

No new lock. drain is the same sync.Once; the only new wait is once.Do in a caller that did not start the drain, which is where every caller already waited. The one new edge, a Shutdown caller's AfterFunc cancelling drainCtx, runs on its own goroutine and takes nothing. The root test is the hang check: its handler is held past every bound, so a wait that depended on the handler returning fails the case at 3.3 s instead of passing late.

Suites

scripts/suites.sh 753 <shape> './engine/std/ ./engine/ .' base:dfd044f head:3d601ec: go test -race -count=1 -v, base and head in one container per shape, linux/arm64. Counts are --- PASS/FAIL/SKIP lines (top-level | subtests).

shape base dfd044f head 3d601ec
CI (8 MiB memlock, one io_uring worker) 414/0/1 | 177/0/3 416/0/1 | 179/0/3
unlimited memlock 414/0/1 | 180/0/0 416/0/1 | 182/0/0

The head adds 2 top-level tests and 2 subtests. The skips are the same in both arms: TestRouteAdaptive_SettleReopenCost (every shape) and three TestAdaptiveSettledRouteRetime592/iouring/* subtests (one-worker ring only). Logs: 753/suites/m8-…log, 753/suites/unl-…log.

linux/amd64 (emulated, no -race, the shutdown tests of ./engine/std and the root package): both new tests PASS; the only FAIL is TestShutdownHooksRunAfterTheDrain's io_uring cases, "io_uring not available on this system" under emulation (753/suites/amd64-engine_std-head3d601ec.log). go vet and golangci-lint (the repo config) clean for GOOS=linux GOARCH=amd64 and arm64.

CI: 17/17 checks passed on 3d601ec; the run on 325d83a is below.

Docs

goceleris/docs: goceleris/docs#80 (graceful-shutdown page: the std cancel caveat and the FAQ row are no longer true).

Review follow-ups

The round-1 review found no blocking defect here; its minor points are filed as #821 (overlapping Shutdown calls share the shortest budget, and a caller whose own ctx is live gets context.Canceled; the engine.Engine.Shutdown sentence "No engine closes a connection whose handler is still running when ctx expires", false for an HTTP/2 stream on the shared worker pool, which #808 rewrites; a test-margin nit). This head, 325d83a, is unchanged in round 2, and so are the numbers above.

Fixes #753

@FumingPower3925 FumingPower3925 added this to the v1.6.0 milestone Sep 28, 2026
@FumingPower3925 FumingPower3925 added bug Something isn't working area/engine Engine interface or implementation labels Sep 28, 2026
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration

Configuration used: Repository: goceleris/celeris/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 63c06a2b-36dd-4c99-a32a-339e75d07d8c

📥 Commits

Reviewing files that changed from the base of the PR and between 325d83a and 8b339f3.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: goceleris/celeris/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: e558a42f-27ca-4863-8cea-57718f4fb554

📥 Commits

Reviewing files that changed from the base of the PR and between 67fdb78 and 325d83a.

📒 Files selected for processing (5)
  • engine/engine.go
  • engine/std/engine.go
  • engine/std/listen_cancel_budget_test.go
  • server.go
  • std_cancel_shutdown_timeout_test.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The standard engine now uses one shared drain context for listener cancellation and shutdown calls. Shutdown deadlines bound the drain, and an expired caller context is returned as an error. Documentation and regression tests describe and check this behavior.

Changes

Bounded shutdown

Layer / File(s) Summary
Shutdown contract
engine/engine.go, server.go
The documentation describes deadline errors, active-handler behavior, and the drain deadline behavior when Listen or Shutdown starts the drain.
Shared drain and regression coverage
engine/std/engine.go, engine/std/listen_cancel_budget_test.go, std_cancel_shutdown_timeout_test.go
The standard engine runs a once-only drain with a shared context. The tests cover a deadline-bearing Shutdown after listener cancellation and StartWithContext cancellation with a configured shutdown timeout.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 325d8

The shutdown change is mergeable after normal checks; no concrete outstanding issue was identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 325d8

The fix makes shutdown respect its timeout when listener cancellation starts the drain. Overlapping shutdown calls can also cause a shorter deadline to end a longer caller’s drain and advance cleanup while requests are still active. No new network or authorization entrypoint was identified.

Retained concerns

  • Low · reliability · inferred: With overlapping Shutdown calls, expiration of a shorter caller’s context ends the shared drain for a longer caller. That can advance the longer call’s return and server cleanup hooks while an active handler remains unfinished.
Security review details

Security Blast Radius

  • inferred — Expiration of a Shutdown caller’s context can affect the drain and request base context for all active handlers on that standard-engine instance. It does not, on the examined path, create a new remotely callable shutdown operation.

Trust Boundaries and Controls

  • observed — The HTTP handler remains behind the existing server bridge. The added cancellation function acts on engine-owned drain state; caller-provided shutdown contexts are the inputs that can trigger it.

Resilience and Maintainability Implications

  • observed — The timeout bounds drain completion, not every handler’s lifetime: the held HTTP/1.1 request in the regression test completes after Shutdown and Listen have returned.

Hardening Proposals

  • proposed — Define whether the shortest overlapping Shutdown deadline is intended to govern all callers, and verify hook ordering and live-handler behavior under mixed concurrent budgets.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the required Conventional Commit format, describes the shutdown-timeout fix, and ends with the issue reference (celeris#753).
Description check ✅ Passed The description directly explains the cancellation defect, the implementation, documentation updates, tests, and measured results for the changed files.
Linked Issues check ✅ Passed Issue [#753] requires std cancellation through StartWithContext and StartWithListenerAndContext to honor Config.ShutdownTimeout, including when Listen starts the drain. engine/std/engine.go …
Out of Scope Changes check ✅ Passed The changed implementation, tests, and documentation all support issue [#753]. The tests reproduce both the public StartWithContext path and the Listen-first drain race. No unrelated behavior is e…

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
engine/std/engine.go 92.30% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…ams on the shared worker pool, and std for its h2c streams (celeris#759)

A stream on an async route (Route.Async, or a route AsyncHandlers has made
async) runs its handler on the shared HTTP/2 worker pool, off the event
loop, and its response comes back through the connection's write queue,
which only the loop drains. At shutdown epoll and io_uring cancelled such
streams (CloseH2) and closed their connections under the handlers: the
client got unexpected EOF, and the OnShutdown hooks ran before the
handlers had finished. On std, net/http hands an h2c connection over
(hijack) and stops tracking it, so http.Server.Shutdown waited for no h2c
stream: the hooks ran, and a direct Shutdown returned, while the handler
was still running.

epoll, io_uring (and so adaptive): the Processor counts its pool handlers
(poolRunning: incremented before Submit, decremented after executeHandler
has returned). Once its context is cancelled, a loop or worker sends every
HTTP/2 connection GOAWAY(NO_ERROR, last client stream), so its client opens
no new stream, and keeps turning, reading and writing as usual, until no
HTTP/2 connection has a pool handler running or a response in its write
queue, then shuts down as before (on io_uring the 250 ms send drain starts
after). The wait ends at the deadline of the budget the last
Engine.Shutdown handed over, never before 250 ms: io_uring's
Engine.Shutdown now records its ctx, as epoll's does since celeris#760.

std: Bridge counts the HTTP/2 requests in their handler, and the drain,
after http.Server.Shutdown, waits for that count to reach zero, bounded by
the drain's context (celeris#753).

Tests: TestShutdownHooksRunAfterTheDrain gains the h2c cases on std and the
h2c-async-route cases on every engine; TestShutdownSendsH2GoAwayThenFinishesStreams
reads the frames of a raw h2c connection: GOAWAY first, then the held
stream's response, then the close; TestShutdownH2PoolWaitIsBounded: a pool
handler that does not return holds the shutdown for its budget, no longer.
BenchmarkPoolDispatch and BenchmarkBridgeServeHTTP measure the two new
counters.

Stacked on #803 (celeris#753: std's drainCtx) and #807 (celeris#760:
epoll's drain budget); merge after them.

Fixes #759
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…ams on the shared worker pool, and std for its h2c streams (celeris#759)

A stream on an async route (Route.Async, or a route AsyncHandlers has made
async) runs its handler on the shared HTTP/2 worker pool, off the event
loop, and its response comes back through the connection's write queue,
which only the loop drains. At shutdown epoll and io_uring cancelled such
streams (CloseH2) and closed their connections under the handlers: the
client got unexpected EOF, and the OnShutdown hooks ran before the
handlers had finished. On std, net/http hands an h2c connection over
(hijack) and stops tracking it, so http.Server.Shutdown waited for no h2c
stream: the hooks ran, and a direct Shutdown returned, while the handler
was still running.

epoll, io_uring (and so adaptive): the Processor counts its pool handlers
(poolRunning: incremented before Submit, decremented after executeHandler
has returned). Once its context is cancelled, a loop or worker sends every
HTTP/2 connection GOAWAY(NO_ERROR, last client stream), so its client opens
no new stream, and keeps turning, reading and writing as usual, until no
HTTP/2 connection has a pool handler running or a response in its write
queue, then shuts down as before (on io_uring the 250 ms send drain starts
after). The wait ends at the deadline of the budget the last
Engine.Shutdown handed over, never before 250 ms: io_uring's
Engine.Shutdown now records its ctx, as epoll's does since celeris#760.

std: Bridge counts the HTTP/2 requests in their handler, and the drain,
after http.Server.Shutdown, waits for that count to reach zero, bounded by
the drain's context (celeris#753).

Tests: TestShutdownHooksRunAfterTheDrain gains the h2c cases on std and the
h2c-async-route cases on every engine; TestShutdownSendsH2GoAwayThenFinishesStreams
reads the frames of a raw h2c connection: GOAWAY first, then the held
stream's response, then the close; TestShutdownH2PoolWaitIsBounded: a pool
handler that does not return holds the shutdown for its budget, no longer.
BenchmarkPoolDispatch and BenchmarkBridgeServeHTTP measure the two new
counters.

Stacked on #803 (celeris#753: std's drainCtx) and #807 (celeris#760:
epoll's drain budget); merge after them.

Fixes #759
…nTimeout (celeris#753)

On std, StartWithContext derives Listen's context from the caller's, so a
cancel reaches Listen before the watcher's Server.Shutdown, which carries
Config.ShutdownTimeout, reaches Engine.Shutdown. Listen's cancel branch
started the drain itself with context.Background(), and the drain is a
sync.Once: the Shutdown with the budget waited in once.Do for that
unbounded drain, and so did the OnShutdown hooks and the StartWithContext
call, until the last handler returned. It then reported no error.

The drain now runs under an engine-owned context, whichever call starts
it, and every Shutdown(ctx) cancels that context when its ctx expires
(with the #498 escalation, as before). A drain Listen started therefore
ends at the budget of the Shutdown that follows, every Shutdown caller
gets the drain's result, and a caller whose own ctx ran out gets that
ctx's error (DeadlineExceeded), as a direct Shutdown did.

The engine.Engine interface doc said an expired Shutdown ctx closes the
remaining connections; no engine does, and it now says what they do.

Tests: TestListenCancelDrainKeepsTheShutdownBudget (engine/std) forces
the order (Listen's cancel first, Shutdown once the listener is closed)
and TestStartWithContextCancelKeepsShutdownTimeoutOnStd (root) checks the
hook and the Start call against the budget with the handler held.

Fixes #753
@FumingPower3925
FumingPower3925 force-pushed the fix/celeris-753-std-cancel-shutdown-timeout branch from 3d601ec to 325d83a Compare September 28, 2026 07:11
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…ams on the shared worker pool, and std for its h2c streams (celeris#759)

A stream on an async route (Route.Async, or a route AsyncHandlers has made
async) runs its handler on the shared HTTP/2 worker pool, off the event
loop, and its response comes back through the connection's write queue,
which only the loop drains. At shutdown epoll and io_uring cancelled such
streams (CloseH2) and closed their connections under the handlers: the
client got unexpected EOF, and the OnShutdown hooks ran before the
handlers had finished. On std, net/http hands an h2c connection over
(hijack) and stops tracking it, so http.Server.Shutdown waited for no h2c
stream: the hooks ran, and a direct Shutdown returned, while the handler
was still running.

epoll, io_uring (and so adaptive): the Processor counts its pool handlers
(poolRunning: incremented before Submit, decremented after executeHandler
has returned). Once its context is cancelled, a loop or worker sends every
HTTP/2 connection GOAWAY(NO_ERROR, last client stream), so its client opens
no new stream, and keeps turning, reading and writing as usual, until no
HTTP/2 connection has a pool handler running or a response in its write
queue, then shuts down as before (on io_uring the 250 ms send drain starts
after). The wait ends at the deadline of the budget the last
Engine.Shutdown handed over, never before 250 ms: io_uring's
Engine.Shutdown now records its ctx, as epoll's does since celeris#760.

std: Bridge counts the HTTP/2 requests in their handler, and the drain,
after http.Server.Shutdown, waits for that count to reach zero, bounded by
the drain's context (celeris#753).

Tests: TestShutdownHooksRunAfterTheDrain gains the h2c cases on std and the
h2c-async-route cases on every engine; TestShutdownSendsH2GoAwayThenFinishesStreams
reads the frames of a raw h2c connection: GOAWAY first, then the held
stream's response, then the close; TestShutdownH2PoolWaitIsBounded: a pool
handler that does not return holds the shutdown for its budget, no longer.
BenchmarkPoolDispatch and BenchmarkBridgeServeHTTP measure the two new
counters.

Stacked on #803 (celeris#753: std's drainCtx) and #807 (celeris#760:
epoll's drain budget); merge after them.

Fixes #759
FumingPower3925 added a commit that referenced this pull request Sep 28, 2026
…ams on the shared worker pool, and std for its h2c streams (celeris#759)

A stream on an async route (Route.Async, or a route AsyncHandlers has made
async) runs its handler on the shared HTTP/2 worker pool, off the event
loop, and its response comes back through the connection's write queue,
which only the loop drains. At shutdown epoll and io_uring cancelled such
streams (CloseH2) and closed their connections under the handlers: the
client got unexpected EOF, and the OnShutdown hooks ran before the
handlers had finished. On std, net/http hands an h2c connection over
(hijack) and stops tracking it, so http.Server.Shutdown waited for no h2c
stream: the hooks ran, and a direct Shutdown returned, while the handler
was still running.

epoll, io_uring (and so adaptive): the Processor counts its pool handlers
(poolRunning: incremented before Submit, decremented after executeHandler
has returned). Once its context is cancelled, a loop or worker sends every
HTTP/2 connection GOAWAY(NO_ERROR, last client stream), so its client opens
no new stream, and keeps turning, reading and writing as usual, until no
HTTP/2 connection has a pool handler running or a response in its write
queue, then shuts down as before (on io_uring the 250 ms send drain starts
after). The wait ends at the deadline of the budget the last
Engine.Shutdown handed over, never before 250 ms: io_uring's
Engine.Shutdown now records its ctx, as epoll's does since celeris#760.

std: Bridge counts the HTTP/2 requests in their handler, and the drain,
after http.Server.Shutdown, waits for that count to reach zero, bounded by
the drain's context (celeris#753).

Tests: TestShutdownHooksRunAfterTheDrain gains the h2c cases on std and the
h2c-async-route cases on every engine; TestShutdownSendsH2GoAwayThenFinishesStreams
reads the frames of a raw h2c connection: GOAWAY first, then the held
stream's response, then the close; TestShutdownH2PoolWaitIsBounded: a pool
handler that does not return holds the shutdown for its budget, no longer.
BenchmarkPoolDispatch and BenchmarkBridgeServeHTTP measure the two new
counters.

Stacked on #803 (celeris#753: std's drainCtx) and #807 (celeris#760:
epoll's drain budget); merge after them.

Fixes #759
@FumingPower3925

Copy link
Copy Markdown
Contributor Author

Round 2: the review found no blocking defect in this PR; its minor points are filed as #821. The head (325d83a) and its numbers are unchanged; the description points at #821.

@FumingPower3925
FumingPower3925 marked this pull request as ready for review September 29, 2026 09:25
@codspeed

codspeed Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 54 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing fix/celeris-753-std-cancel-shutdown-timeout (8b339f3) with main (1fdfcd4)2

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (5a085e4) during the generation of this report, so 1fdfcd4 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@FumingPower3925
FumingPower3925 merged commit 5cafb08 into main Sep 29, 2026
21 checks passed
@FumingPower3925
FumingPower3925 deleted the fix/celeris-753-std-cancel-shutdown-timeout branch September 29, 2026 10:07
FumingPower3925 added a commit to goceleris/docs that referenced this pull request Sep 29, 2026
…nTimeout (celeris#753) (#80)

graceful-shutdown page: on std, a cancel of StartWithContext's context now keeps Config.ShutdownTimeout (celeris v1.6.0, goceleris/celeris#803, fixes celeris#753): the hooks run, and StartWithContext returns, at the deadline while a running handler keeps running.
The three places that said a std cancel waits for the handler (the StartWithContext paragraph, the early-exit pitfall, the FAQ's std bullet) now describe v1.6.0 and keep the old behaviour as a pre-v1.6.0 note; the measured line gains the new 501 ms figure.
Follow-ups: goceleris/celeris#824.
FumingPower3925 added a commit that referenced this pull request Sep 29, 2026
…ams on the shared worker pool, and std for its h2c streams (celeris#759)

A stream on an async route (Route.Async, or a route AsyncHandlers has made
async) runs its handler on the shared HTTP/2 worker pool, off the event
loop, and its response comes back through the connection's write queue,
which only the loop drains. At shutdown epoll and io_uring cancelled such
streams (CloseH2) and closed their connections under the handlers: the
client got unexpected EOF, and the OnShutdown hooks ran before the
handlers had finished. On std, net/http hands an h2c connection over
(hijack) and stops tracking it, so http.Server.Shutdown waited for no h2c
stream: the hooks ran, and a direct Shutdown returned, while the handler
was still running.

epoll, io_uring (and so adaptive): the Processor counts its pool handlers
(poolRunning: incremented before Submit, decremented after executeHandler
has returned). Once its context is cancelled, a loop or worker sends every
HTTP/2 connection GOAWAY(NO_ERROR, last client stream), so its client opens
no new stream, and keeps turning, reading and writing as usual, until no
HTTP/2 connection has a pool handler running or a response in its write
queue, then shuts down as before (on io_uring the 250 ms send drain starts
after). The wait ends at the deadline of the budget the last
Engine.Shutdown handed over, never before 250 ms: io_uring's
Engine.Shutdown now records its ctx, as epoll's does since celeris#760.

std: Bridge counts the HTTP/2 requests in their handler, and the drain,
after http.Server.Shutdown, waits for that count to reach zero, bounded by
the drain's context (celeris#753).

Tests: TestShutdownHooksRunAfterTheDrain gains the h2c cases on std and the
h2c-async-route cases on every engine; TestShutdownSendsH2GoAwayThenFinishesStreams
reads the frames of a raw h2c connection: GOAWAY first, then the held
stream's response, then the close; TestShutdownH2PoolWaitIsBounded: a pool
handler that does not return holds the shutdown for its budget, no longer.
BenchmarkPoolDispatch and BenchmarkBridgeServeHTTP measure the two new
counters.

Stacked on #803 (celeris#753: std's drainCtx) and #807 (celeris#760:
epoll's drain budget); merge after them.

Fixes #759
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/engine Engine interface or implementation bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

std: a cancel of StartWithContext's context ignores Config.ShutdownTimeout; the drain, the OnShutdown hooks and Start wait for every handler

1 participant