fix(sse,h2): SSE API hardening (#266-#270) and h2 teardown close-out (#230-#232) - #350
Merged
Merged
Conversation
`s.send("")` wrote a single `data:` field. A client appends an LF to its
data buffer per `data:` field and strips one trailing LF before dispatch,
so the buffer was empty at the dispatch step, where the WHATWG EventSource
algorithm resets the buffers and returns without firing any listener. A
payload-free notification (`s.send("", event = "ping")`, a reload poke, a
typed signal whose meaning is the event name) therefore did nothing on the
client, with nothing on the wire or in the server to show why.
Emit two empty `data:` fields for an empty `data`, the workaround the wire
format leaves: the client reconstructs "\n", which is non-empty after the
trailing-LF strip, so the event dispatches and the listener runs with
`event.data == "\n"`. Documented on `send`, in the README and the changelog,
since the payload is "\n" rather than "".
Fixes #266
…ay so
`send` split `data` with `splitLines`, so which byte sequences end a
`data:` field was a property of a text helper rather than of the protocol.
It happens to match the SSE grammar (CRLF, LF, CR), but nothing recorded
that, and the consequence was undocumented: the format has no escape for a
literal CR, so a CR inside `data` is a field boundary and the client
rebuilds it as an LF. `send("a\r\nb")` arrives as "a\nb", which silently
corrupts a payload a caller expected to be byte exact.
Spell the split out inline with a comment naming the three terminators, so
the wire contract is readable at the point it is produced, and document the
lossiness on `send` and in the README with the fix for it: encode the
payload (base64, or JSON, which escapes a CR). No behaviour change; the new
test pins the wire bytes for CRLF, bare CR, LF and a trailing break.
Fixes #267
`req.isAlive` and `res.bufferedAmount`, and the `SseStream.alive` / `SseStream.bufferedAmount` that delegate to them, read state the loop thread owns: the connection table, the h2/h3 stream maps and the response write buffers. Every mutating call on those handles (write, sendHead, finish, onDrain) already early-returns when it is not on the loop thread, but these two read it unsynchronised, so a producer polling `s.alive` from a worker raced the loop over a table the loop can resize and a generation counter it can bump, and could act on a torn answer. Add the same guard, with the same conservative answers the WebSocket handles already give: off-thread `isAlive` is false and `bufferedAmount` is 0. A `blocking:` worker is unaffected, it returns true from the `snap` check above the guard, since its connection is pinned for the body. The private `currentThreadId` helper moves up in the file so `isAlive` can see it. Fixes #268
`withSse` hardcoded `res.sse()`, so the block form could not set the reconnect delay or add response headers: wanting either meant dropping to the handle constructor and hand-writing the close/abort pairing the block form exists to provide, and the two `res.sse` arguments were invisible from the call site. `res.withSse(s, retry = 3000): ...` and `res.withSse(s, headers = [...], retry = 3000): ...` now work, and the existing `res.withSse(s): ...` is unchanged. It becomes a macro over varargs, because Nim binds a trailing block to the last parameter positionally: with `headers`/`retry` as defaulted template parameters the bare form hands the block to `headers` and fails to compile. The macro resolves the named arguments and expands to a private template, so `sse`, `close` and `abort` still bind at definition scope, and an unknown or positional argument is a compile error that names the accepted ones. Fixes #269
`s.send` and `s.comment` returned false from off the loop thread, the same value they return for a full write backlog. A producer's contract is "false means pause and wait for onDrain", so a worker pushing events saw a silent no-op it read as backpressure, then waited for a drain callback that an off-thread `onDrain` never registered either: events vanished with nothing logged and no way to tell the case apart from a slow client. Assert the loop thread in both, following `res.headers` / `res.trailers` (plain assert, so it is compiled out under `--assertions:off` / `-d:danger` and the call degrades to the no-op it was). The buffered `res.send` still marshals from a `blocking:` worker, and `req.isAlive` still answers there through the pin, so nothing legitimate is caught; the streaming primitives have always been loop-thread only. `res.write`, `res.onDrain` and the SSE wrappers keep their silent returns, since a hot core primitive should not become a Defect, but now document that false covers a dead connection and an off-thread call as well as backpressure. Fixes #270
Regression coverage for #231, fixed in #242. A streaming-upload route debits the connection receive window on receipt and defers the WINDOW_UPDATE to consumption, so a manualAck sink that never acks leaves every byte owed. The new suite uploads 48 KiB on two streams, cancels both with RST_STREAM, and asserts the server credits the connection window back and still accepts a further upload that only fits because of that credit. Without the reclaim the connection window drains and the next upload is a FLOW_CONTROL_ERROR, which is the deadlock the issue describes.
Regression coverage for #232, fixed in #242. Three cases the pre-#242 teardown paths stranded: a peer RST_STREAM must deliver onBody(last=true) to a streaming request sink, a peer RST_STREAM must fire a response producer parked on its onDrain, and a client disconnect must do both for every stream still open. Each one leaked a suspended handler coroutine (and whatever it pinned) on an ordinary client cancel.
This was referenced Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the five SSE issues from the API review, one commit per issue, and closes out the three HTTP/2 teardown issues that PR #242 fixed but never closed (its body did not use per-issue closing keywords), with the regression coverage that was missing.
Fixes #266
Fixes #267
Fixes #268
Fixes #269
Fixes #270
Fixes #230
Fixes #231
Fixes #232
SSE (src/vortex/request.nim)
s.send("")now dispatches on the client. An emptydatais emitted as two emptydata:fields: the WHATWG EventSource algorithm appends an LF per field and strips one trailing LF before its "empty buffer, do not dispatch" check, so one field never fires a listener and two leave"\n". Documented onsend, in the README and the changelog.sendsplits on the three terminators the SSE grammar defines (CRLF, LF, CR) explicitly instead ofsplitLines; behaviour is byte-identical and the inherent CR lossiness is now documented with the base64/JSON recommendation. Wire bytes for CRLF, bare CR, LF and a trailing break are pinned by tests.req.isAlive,res.bufferedAmountand theSseStreamalive/bufferedAmountthat delegate to them take the same loop-thread guard every mutating call takes (false / 0 off-thread). Ablocking:worker still seesisAlive == truethrough the pin.res.withSseforwardsheadersandretrytores.sse:res.withSse(s, retry = 3000): .... It is now a macro overvarargs[untyped]because Nim binds a trailing block to the last parameter positionally, so a template with defaulted params handed the block toheaders. The bareres.withSse(s): ...form is unchanged; positional or unknown args are compile errors naming the accepted ones.s.send/s.commentassert on off-thread use (the repo's existingres.headersconvention, elided under--assertions:off/-d:danger).res.write,res.onDrainand the SSE wrappers document that false also means an unsupported off-thread call, not only backpressure.Response.writeitself keeps its silent no-op: turning a hot core primitive into a Defect is a bigger blast radius than the issue needs.HTTP/2 teardown (#230, #231, #232)
All three were fixed in #242 (commit 24d7fd7). This PR re-audited every path each issue lists against current main (all
streams.delsites, all 28streamErrorcallers, theconnDeferredincrement/decrement pairing, everyh2Stream(...)/template stholder across table-mutating calls) and found no residual gap, so there is no production change. It addstests/test_http2_teardown.nim:onBody(last=true)to a streaming sink, RST_STREAM fires a producer parked on its drain callback, and a client disconnect fires both (fix(h2): RST_STREAM/disconnect strands async handlers: onBodyCb(last=true) and onRespDrain never fired #232). Each fails if the callback delivery is removed fromteardownStream/h2NotifyClosed.finish(), which a frame-level client cannot force (a scheduler pass always emits before resuming a parked producer). The compressed h2finish()path is covered bytest_streaming_compressionunderNIM_COMPRESS=1.Follow-ups found during the audit (not changed here)
onBody(last=true)is delivered twice on a normal streaming-upload completion when the handler responds from inside the callback:h2DeliverBodynever clearsst.rs.onBodyCb, soh2Respond -> teardownStreamfires it again. Harmless for the shipped adapters (markEofis idempotent), visible to a user callback that counts completions. One-line fix: nil the callback before invoking it withlast.h2WsTeardownAlliteratesh2.streams.mpairsacross the useronClose, which can tear down another stream and invalidate the iterator (same class as fix(h2): use-after-move via ptr H2Stream held across streams-table mutation in finish() #230). Fix mirrorsh2NotifyClosed: snapshot, nil, then fire.h2WsFinalizeis the only stream removal that bypassesteardownStream. Benign today, a standing drift risk.tests/h2client.nimsendRawusesstd/net.send, which re-sends from offset 0 after a partial write and spins at 100% CPU once the peer is gone; the new suite carries a localsendAllonposix.sendthat should move into the helper.Validation
NIM_COMPRESS=1 bash tests/run.sh(the CI test job's command, orc, gzip+brotli+zstd on): all 81 suites, 632 checks, 0 failures, including the 8 new SSE tests and the 4 new h2 teardown tests.nimble testchronos(strict-effect chronos adapter build): pass, so the new assert under{.raises: [].}compiles there.teardownStream/h2NotifyClosed: all four fail, fix(h2): deferred connection flow-control credit leaked on every stream-teardown path (upload deadlock) #231 withGOAWAY(FLOW_CONTROL_ERROR)and a stalled third upload, exactly as the issue predicts.Stress smoke
Two complete passes of
VORTEX_PROTO=all VORTEX_SERVER=all VORTEX_SECONDS=10 VORTEX_REPORT_SECONDS=2(5 workloads x h1/h2/h3 x sync/async/async-await/chronos/chronos-await, 75 cells, canary plus chaos sidecar, 64 MiB streams), both 75/75 with every checksum verified and no failure signature in any server log:nimble stress, run while eight unrelatedyes > /dev/nullcontainers held the host at load average ~23. 65 minutes.VORTEX_RUN_IDs after the burners were removed (load average ~3 before start). 14 minutes. Five cells share the host at any moment, so this run has its own contention.Throughput per workload and protocol, mean over the five server runtimes:
A cleaner reference from the eight cells of a quiet sequential run that was stopped to make room for the parallel one: requests h1 averaged 3409 req/s (vs 2902 loaded, 2791 parallel) and requests h2 averaged 2564 req/s (vs 2218 loaded, 2002 parallel). So the parallel run's contention costs roughly what the burners did, about 10 to 20 percent on the CPU-bound cells and most on h3, where QUIC crypto and the aioquic client compete hardest. None of the deltas is a regression signal: both runs are the same commit, and the ordering across protocols and runtimes is stable. Peak server RSS was 325 MB (loaded) and 315 MB (parallel), both on streamupload h1 sync.
Per-cell results (75 rows): ok count, throughput and RSS for both runs