Skip to content

fix(static,loop,http3,chronos): the 2026-09-26 stress-loop fixes (truncated sendFile, loop-stall deadline credit, QUIC idle reap, chronos pump spin) - #386

Merged
cryo2010 merged 13 commits into
mainfrom
fix/stress-all-round1
Oct 2, 2026
Merged

cryo2010 merged 13 commits into
mainfrom
fix/stress-all-round1

Conversation

@cryo2010

@cryo2010 cryo2010 commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

The fixes written during the 2026-09-26 /vortex-stress loop, rebased off main and
brought up to date with everything that has landed since (#349, #350, #383, #384,
#385). Five server fixes, seven harness/doc commits, four new test suites.

Last night's soak re-hit the first of these: streamdownload / h2 / sync failed at
~8 s with StreamReset error_code:2 on the download stream. That is main's #345
reconciliation catching a truncated sendFile body, and the fix(static) commit below
is the fix at the source. So this is not only old work: the top item is a live failure
on main today.

Server fixes

  • fix(static): never report a short file read as the end of a streamed body.
    A streamed sendFile declares the Content-Length stat reported, then pulls the
    file one 256 KiB hop at a time, and the trampoline treated any shortfall (got == 0)
    as end of file: it emitted last = true and the loop answered with res.finish().
    read(2) may legally return short on a regular file, and Nim's readBuffer raises
    on a short read whose stream has its error flag set, discarding the bytes it already
    copied, so a partially-satisfied hop reported 0. Either way "the file ended here" and
    "this read did not finish" were indistinguishable, and the second was delivered to the
    peer as a complete response at a length contradicting the head already on the wire.
    readAt now preads in a loop retrying EINTR, so a short result means EOF and
    nothing else and a failure raises; a hop that cannot deliver what the declared length
    still owes aborts the response (RST_STREAM on h2/h3, connection close on h1); a
    mangled continuation aborts instead of silently restarting the body at offset 0; and a
    failed initial read answers 500, since nothing has been sent and the length is still
    ours to retract.
  • fix(loop): stop charging a starved loop thread's stall to its peers.
    A thread that did not run for N seconds fired every deadline that fell inside the gap,
    at peers that did nothing wrong, and the phase a connection happened to be in decided
    what the peer saw: a connection still in its TLS handshake reset with no alert, an h2
    connection closed with no GOAWAY, an in-flight response truncated. Measured on the
    soak host (h2, 14 loop threads, load ~20): ticks missed by 2 to 33 seconds against a
    10 s headerTimeout. The armed deadlines are now pushed out by the gap (less its last
    second), so they measure a peer gone quiet while we could serve it, not the host's
    scheduler. wsCreditStall does the same for the h2/h3 WebSocket keep-alive stamps.
  • fix(chronos): stop the pump spinning on futures only the loop can complete.
    Plus docs(chronos), recording the measured cost of the spin next to the early exit.
  • fix(http3): stop letting a quiet QUIC connection be reaped as idle.
    max_idle_timeout is now advertised from keepAliveTimeout (QUIC takes
    min(local, peer), so this also caps the client's timer) and the shim arms ngtcp2's
    keep-alive at a third of it, so one protocol no longer reaps a connection the other
    would have kept.
  • fix(http3): stop rewinding the clock ngtcp2 stamps its timers with.
    ngtcp2 aborts the process if the clock ever goes behind a stamp it has already seen.

Harness

Chaos sidecar baseline gate widened to 180 s with the sidecar log preserved on a canary
failure; the sidecar's window timed from its baseline rather than its launch; a
load-proportional WebSocket opening-handshake budget; STRESS_HEADER_TIMEOUT so the
soak server's header timeout fits an oversubscribed host; streaming workers stop at the
deadline mid-transfer; and /download chunks served from a precomputed pattern.

Tests

test_loop_stall, test_h3_idle_keepalive and test_h3_stall_clock are new;
test_http2_download gains the truncated-sendFile case ("a streamed file that cannot
fill its Content-Length aborts, not END_STREAM") and test_adapter the chronos pump
cases. Full suite green locally: 95 of 95.

Rebase notes

Four conflicts, all additive and resolved by keeping both sides: tick runs
checkBodyPause (#349) and creditStall; ngSetup takes both the TLS/SNI parameters
(#383) and maxIdleTimeout; VqConfig gained max_idle_timeout_sec after the SNI
fields in both the C header and its Nim mirror. One semantic adaptation: creditStall
iterated conns as a seq, which #384 replaced with the growth-stable ConnTable, so it
now walks conns.slots.

Follow-ups stay open: #346 (revisit the load tolerances added here) and #347 (extend
test_h3_idle_keepalive to pin the keep-alive PING, not just the transport parameter).

…plete

The chronos adapter counts every awaited handler future in `pendingOps` and,
while that tally is non-zero, the pump spins eight `poll()` passes per loop
iteration and reports a 5 ms cap on the loop's selector wait. But a handler
parked in `ws.messages` or `await req.read()` is waiting on a core callback
that runs on the loop thread -- chronos has no fd, timer or callback of its own
for it and can never complete it by polling. So a server holding long-lived
WebSocket or streaming connections could never let the tally reach zero: every
one of its loop threads spun 1600 chronos polls a second forever, and none of
them could ever sleep on its selector. Pure burn, buying no latency, and paid
out of the headroom a loop thread needs to accept and upgrade new connections.

Track those parked futures separately (the shared AwaitableReader is the single
place a handler parks on a core wakeup) and exit the spin -- dropping the cap --
as soon as every outstanding future is parked that way. Correctness does not
rest on the tally: the pump still polls at least once per loop iteration while
anything is outstanding, so a wakeup delivered by the loop always has its
resumed continuation polled in the same iteration. The tally only decides
whether the loop may sleep, and it is conservative -- any future suspended on
something chronos itself drives keeps the old spin and cap. asyncdispatch needs
no tally: a bare parked Future registers nothing, so its hasPendingOperations
already reads false, and the no-op hooks keep the two backends symmetric.

Measured on the stress soak's ws cell (h1, 96 sockets, chaos sidecar, 14 loop
threads): chronos ran at 150-374% CPU against the sync build's 25-37% for the
same 33k messages/s. On an oversubscribed host that missing headroom is what
let individual SO_REUSEPORT loop threads stall for seconds, so connections
hashed to them were not upgraded before the client's handshake deadline.

The new test pins the ordering the fix must not break: a socket left idle long
enough for the loop to be inside its full selector wait must still round-trip a
message promptly, including the reply's own runtime-driven await after the
resume -- the suspension an unconditional early exit strands (verified: it
fails the test).
… budget

The ws workload opened every worker's socket at t=0, so a cell slammed
CLIENTS*CONC (96) simultaneous handshakes at the server from one asyncio loop,
under `websockets`' 10 s default open_timeout. These soaks are run deliberately
oversubscribed -- eight cells in parallel, host load 20-50 -- and the server
upgrades each connection on whichever SO_REUSEPORT loop thread the kernel hashed
it to, so one descheduled thread is enough to blow that deadline.

Measured on a loaded host: TCP connect stayed under 0.15 s throughout, while the
upgrade for the connections hashed to one starved thread waited 1-5 s and then
all completed in the same instant -- the thread being scheduled again, not a
wedged server. In the hour-long round that cold-start scheduling luck failed
five ws cells inside their first minute, each with a server that went on to echo
tens of thousands of messages a second; the sync cells, needing an order of
magnitude less CPU, passed every time.

Scale the handshake budget with the burst size and floor it far above the worst
case observed, and spread the initial handshakes over a few seconds (capped so a
short smoke run is unaffected) rather than opening all of them at once. The soak
then measures steady state, which is what it exists to measure. A genuinely
wedged upgrade still fails the cell, on a timescale that means something.
…ubscribed host

vortex's headerTimeout defaults to 10 s, measured from accept and explicitly
counting the TLS handshake and any protocol upgrade -- the right slowloris guard
for a deployed server. These soaks, though, are run deliberately oversubscribed,
and there it fires on the host's scheduler rather than on a slow client.

Measured on a loaded host (ws/h2/async, 8 extra busy containers): one WebSocket
upgrade was still unserviced 13.9 s after its TCP connect and was then reset by
this deadline, while every established connection on the same server kept
echoing ~30k messages a second and RSS stayed flat. The cell died 12 s into a
120 s run over a reset that says nothing about vortex.

This is the other half of the client-side handshake budget: raising the client's
open_timeout alone just hands the same starved handshake to the server's 10 s
deadline instead, so the cell still fails at the same moment for a different
reason. Raise it here (env-overridable via STRESS_HEADER_TIMEOUT) and keep it
finite, so a genuinely wedged handshake still fails the cell.

With this, ws/h2/async passes a 120 s cell under 8 extra busy containers:
3,037,677 messages at 27k/s, RSS flat at 69 MB, fds 164 -> 61.
…early exit

The preceding commit's message quoted the soak's whole event-loop-vs-sync CPU
gap (150-374% against 25-37%), which the spin is only one part of. Put the
spin's own A/B where it can be checked: one loop thread, four idle WebSockets,
0.0527 s of CPU per 5 s idle before the early exit and 0.0004 s after.
The async builds' /download handler built every 64 KiB chunk with a per-byte
loop, so streaming the workload's 1 GiB response ran 16 Ki of those loops -- a
million bounds-checked byte stores each -- on the loop thread, and the chaos
sidecar hammers the same route with slow-read and aborted connections on top of
the canary's. The sync build never paid any of it: it serves /download with
sendFile from a file generated once at startup, which is part of why the sync
cells needed a fraction of the event-loop builds' CPU for the same work.

The generator has period 256 and the chunk size is a whole number of periods, so
a pattern of dlChunk + 256 bytes contains every chunk any caller can ask for as
one contiguous slice at `offset mod 256`. Precompute it at compile time and make
genChunk a memcpy.

The byte stream is unchanged, which matters: the client verifies the whole
download against a SHA-1 of its own gen_chunk (byte i = i mod 256), and the
pre-generated sendFile source for the sync build comes from this same proc.
Verified identical for offsets on and off a period boundary, for chunk sizes up
to dlChunk, and for the compile-time `binaryBody = genChunk(0, 8192)` const.
Every per-connection deadline is an absolute monotonic stamp, and sweepTimeouts
only runs when the loop's coarse clock changes. So a loop thread that does not run
for N seconds fires, in a single pass on its next tick, every deadline that fell
inside those N seconds -- at peers that did nothing wrong. Which timeout fires
depends only on the phase a connection happened to be in, and each one is
invisible to the client: a connection still in its TLS handshake is closed with no
alert (closeConn skips tlsShutdown while handshaking, and queued handshake bytes
turn the close into an RST), an h2 connection is closed with no GOAWAY, and an
in-flight request or response is simply truncated. From the client that is an
unexplained connect failure, a "server disconnected", or a read/write error
against a server that is healthy, logging nothing, and still serving every other
connection on the same listener.

Measured on the stress soak's h2 cells (14 loop threads, deliberately
oversubscribed host, load ~20): in one 60 s window the loop threads missed ticks
by 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 15, 16, 18, 19, 20, 23, 25, 27, 28 and 33
seconds. The deadlines those ticks enforce are headerTimeout 10 s (which covers
every TLS handshake), wsPongTimeout 10 s, bodyTimeout 30 s and keepAliveTimeout
60 s, so gaps of that size reset handshakes, close WebSockets whose pong was
already in flight, and truncate uploads -- which is the whole set of failures the
hour-long round reported on the asyncdispatch and chronos runtimes. They are the
runtimes that show it because their loop threads cost 6-10x the sync build's CPU
for the same work, so they have that much less scheduling headroom; the sync cells
passed the same workloads.

So credit the stall: when a tick gap shows the loop missed a whole second of
sweeps it owed, push the armed deadlines out by the time it was away. That
includes the h2/h3 WebSocket keepalive stamps, which are elapsed-since-nowSec
comparisons of their own and so have the same flaw. The timeouts then measure what
they exist to measure -- a peer that has gone quiet while we were able to serve it
-- instead of the host's scheduler. They stay finite and still absolute: a peer
that really is silent is reaped one gap later, and a slowloris gains only the time
the server could not serve anyone at all.

The test pins both halves, and the first case fails without the fix: a blameless
idle connection must survive a stall longer than its keep-alive budget (the stall
comes from inside the server, a blocking dispatch on the only loop thread, which
holds it exactly as a deschedule would), and a peer that really does go quiet must
still be reaped.
…body

A streamed sendFile response declares the Content-Length stat reported, then
pulls the file one 256 KiB hop at a time. Each hop read through a single
buffered readBuffer, and the trampoline treated any shortfall -- `got == 0` --
as end of file: it emitted the chunk with `last = true`, and the loop answered
that with res.finish(). So a read that did not finish was delivered to the peer
as a complete response, at a length contradicting the head already on the wire.

Two ways to get there, neither exotic. read(2) may legally return fewer bytes
than asked for on a regular file. And Nim's readBuffer RAISES when a short read
left the stream's error flag set, discarding the bytes it had already copied, so
a partially-satisfied hop reports 0 rather than its partial count. Either way
the caller could not tell "the file ended here" from "this read did not finish",
and the two need opposite answers.

That is class D of the hour-long stress soak: streamdownload/h2/sync ended a
1 GiB download with END_STREAM after 20709376 bytes -- exactly 79 whole 256 KiB
hops -- under a head that said content-length: 1073741824, and the client's
HTTP/2 stack raised InvalidBodyLengthError. The server logged nothing, kept its
fds and RSS flat and went on serving, because from its side the response had
completed. HTTP/1 catches the same truncation one layer down (#248 forces the
connection closed on a Content-Length mismatch, so the peer at least sees an
incomplete read); HTTP/2 and /3 have no such net, so there it is a silent
protocol violation, which is why only the h2 cell failed.

So fill the buffer, and make a shortfall mean one thing. readAt preads in a loop
retrying EINTR, so a short result is end of file and nothing else and a failure
raises. A hop that cannot deliver the bytes its Content-Length still owes reports
the new fileChunkFailed sentinel, and the loop ABORTS the response -- HTTP/1
closes the connection, HTTP/2 and /3 reset the stream -- so the peer sees a
cut-short transfer instead of a well-formed lie. Two adjacent cases join it: a
mangled continuation (unparseable offset/remaining) aborts instead of silently
restarting the body from offset 0, and an initial read that cannot fill its first
hop answers 500, since nothing has been sent yet and the length is still ours to
retract. pread also drops the per-hop lseek.

The test pins it and fails without the fix: a file truncated under a live
download must end in RST_STREAM, never in an END_STREAM short of the length the
response declared.
…ts launch

The chaos sidecar's fd-leak assertion could measure the canary instead of the
server. `run()` set `deadline = start + SECONDS` before its pre-baseline prelude
-- warm_baseline is up to 10 rounds of 64 CONCURRENT aborted 1 GiB /download
reads plus a retrying baseline sample, 48 s measured on a loaded host -- while
run.sh gates the verified canary on the "chaos: baseline" line that prelude ends
with. So the two clocks disagreed by exactly the prelude, and the sidecar
stopped, drained and took its FINAL fd sample that much before the canary
released its CLIENTS*CONC (96 by default) sockets. Those live sockets then read
as leaked descriptors.

That is what failed sse/h2/chronos in the hour-long round: "fd leak (baseline
61, final 157)", and 157 is 61 + 96 to the descriptor. Nothing leaked. The
server's fd count was flat at 163-165 for the whole hour and the canary's
closing /stats read was 61; and for the stress server -d:ltChronos and
-d:ltChronosAwait compile to identical binaries (the flag only feeds the
asyncMode boolean), so the cell that passed 61 -> 61 ran the same program. The
only thing that differed was the prelude: derived per cell from the first chaos
report, it was 48 s on the failing cell against 2, 17, 25 and 29 s on the four
that passed -- and the race fires exactly when the prelude plus the canary's own
launch lag exceeds DRAIN, 15 s on h2. Those passes were margin, not health.

So rebase the window on the baseline, which is the instant the canary starts,
and re-sample while the count is still above the threshold (SETTLE, 60 s) so the
canary's tail drains before the verdict. Waiting cannot hide a real leak: a
leaked descriptor is never reclaimed, so the loop only ever converts a false
failure into a pass. Rebasing also fixes the under-delivery the same line caused
-- the sidecar induced SECONDS minus prelude of chaos, not SECONDS -- and aligns
its t= stamps with the canary's, which is what makes the two logs comparable at
all. The baseline line now carries the prelude and the leak line its own elapsed
stamp: the two numbers whose absence made this invisible. run.sh's post-canary
wait for the sidecar grows to cover DRAIN + SETTLE so a genuine leak is still
reported as a leak (exit 1) rather than as a sidecar watchdog (exit 3); that cap
is only ever approached when the count truly never comes down, since the settle
loop exits on its first sample once the canary is gone.

Verified by forcing the condition rather than waiting for it. With a 60 s
stand-in prelude the unfixed sidecar fails an otherwise clean 300 s
sse/h2/chronos cell with "fd leak (baseline 62, final 157, slack 8)" -- the
round's signature, reproduced on demand with no server change -- and passes
59 -> 61 with the fix in place. The default cell passes 61 -> 61.
An h3 connection sends packets only when the application has bytes to move, and
RFC 9000 10.1 restarts an endpoint's idle timer on a packet it *receives* -- a
peer that is only waiting for a response never refreshes its own. So any gap in
application data makes the connection go completely silent, and whichever side
notices first tears it down and reports "Idle timeout" against a server that is
healthy and still serving every other connection on the same listener. Three
things made that gap reachable, and the hour-long stress round hit it twice:
ws/h3/async died at t=3020s with the server at 78% CPU and the chaos sidecar
still succeeding on its own connections 11s later, and sse/h3/chronos-await died
at t=65s -- both as aioquic's `connection closed (code 1 "Idle timeout")`, on a
host at load 35-50, with the server logging nothing.

First, the window. The shim advertised a hardcoded 30 s max_idle_timeout, which
is half the h1/h2 idle budget (keepAliveTimeout, 60 s by default) and narrower
than the h3 drain grace. QUIC gives each endpoint min(local, peer), so that
number capped the *client's* timer too: aioquic would have allowed 60 s and we
talked it down to 30. Derive it from keepAliveTimeout instead, so the two
protocols give a peer the same budget and h3 stops reaping connections an h1/h2
one on the same listener would have kept. On this host that alone doubles the
gap an h3 connection survives, and the measured loop-thread stalls (2 to 33 s in
a single 60 s window, per the loop's own creditStall) fit inside 60 s where a
good third of them did not fit inside 30.

Second, the silence. ngtcp2's keep-alive defaults to disabled (UINT64_MAX), so
nothing ever filled a gap. Arm it at a third of the window: the PING is
ack-eliciting, so it restarts the timer at *both* ends, and a third leaves room
for two lost PINGs. That covers every gap where the loop thread is running and
the application simply has nothing to say -- a slow handler, a stream between
chunks, the shutdown drain.

Third, the server's own timers. ngtcp2 arms the idle and loss-detection timers
as absolute stamps on the clock we hand it, so a loop thread descheduled past
the idle window comes back, calls handle_expiry once, and ngtcp2 reaps every
connection whose window fell inside the gap -- the exact failure creditStall
fixed for the h1/h2 deadline wheel, but the h3 timers live inside ngtcp2 where
those stamps cannot be rewritten. They can be measured against a clock that does
not run while we were away, so creditStall now withholds the stall from the
clock the shim reads. Withholding it from every ngtcp2 entry point keeps one
consistent clock domain: every relative duration ngtcp2 derives -- RTT samples,
PTO, the keep-alive interval -- is unchanged, and only the interval in which no
packet could be sent or processed is excluded. The clock stays strictly
monotonic (the loop credits less than the time that elapsed) and every timer
stays finite, so a peer that is still silent once the loop recovers is reaped
one gap later.

The test pins that keepAliveTimeout reaches the transport parameters and that a
narrow window neither breaks a normal exchange nor truncates a response that
takes more than twice the window to produce. It deliberately does not claim more:
curl's own QUIC stack sends keep-alive PINGs, and those refresh our timer just as
ours refresh the peer's, so it still passes with the arming removed. Only a
client that does not PING can observe the server's half -- aioquic, which is what
the stress soak drives and what reported the failure -- so that half is validated
by the h3 stress cells.
`drive` checks the soak deadline only between calls to `once()`, and the two
streaming workloads are the only ones whose `once()` is a whole 1 GiB transfer --
every other workload (requests, ws, sse, methods) checks the deadline per
iteration inside its own loop. main()'s `wait_for(..., SECONDS + 60)` safety net
was sized for those: 60 s comfortably covers one request. It does not cover one
streaming transfer. Over h3 on a loaded host a 1 GiB transfer takes 125 s
(upload) or 163 s (download), so a worker that starts one just before the
deadline cannot finish inside the grace, and the harness reports "workers did not
stop within deadline+60s (stall)" for a soak that was perfectly healthy.

That is what the hour-long round did on streamupload/h3/sync and
streamdownload/h3/sync: 86 GB and 69 GB transferred with no error, flat fds
(60 -> 60, chaos sidecar passed), the final interval running at the cell's best
rate (27 and 23 MB/s, not the 0 MB/s that fmt_xfer documents as a stall), and the
FAIL line's count one *higher* than the last report's -- a transfer completed
inside the grace window, so nothing was stuck. h1/h2 passed the same workload
only because 1 GiB moves there in 4-15 s. With residual work ~uniform over a
transfer, P(a cell fails) = 1 - (60/T)^3: 89% for streamupload h3, 95% for
streamdownload h3. streamdownload/h3/async is the same overrun that got a lucky
draw -- it finished at t=3654s, 54 s past the deadline and 6 s inside the grace.

So stop at the deadline instead of finishing the transfer in flight: check it per
chunk on download, and bound the upload by the time left. An abandoned transfer
is neither verified nor counted, and leaving the per-transfer session resets the
stream. Widening the grace was the alternative and is worse: it would keep the
false failures away only by making a genuinely stuck stream take that much longer
to report, and it would leave the actual defect -- no deadline check inside a
transfer -- in place. This keeps the detector sharp: a wedged stream delivers no
chunk, so it never reaches the check and main()'s wait_for still catches it.

Cancelling an upload mid-body is now a routine path rather than something that
only happened as the process exited, so give h3's `upload` the try/finally
cleanup `stream` already had -- otherwise each abandoned transfer leaks a
_queues entry and leaves late events for that stream id delivered to a queue
nobody reads.
358eb22 gave the h3 shim a stall credit: whenever the loop thread went a gap
without ticking, creditStall withheld that gap from the clock every ngtcp2 entry
point is handed, so the timers armed before the gap would not all fire the moment
the thread came back. It reasoned the clock stayed monotonic because the loop
credits less than the time that elapsed. It does not, and ngtcp2 checks:

  stress_server: ngtcp2_conn.c:88: conn_update_timestamp:
    Assertion `conn->log.last_ts <= ts' failed.

run() drives h3 *before* it ticks, so the order inside one iteration is always:
h3Drive stamps ngtcp2 with the full elapsed time (the ngPump/ngReceive at the end
of the long iteration, which ngtcp2 keeps as conn->log.last_ts), and only *then*
does tick measure the gap and credit it. The next entry point -- tick ->
sweepWsIdle -> h3Drive -> ngPump, or the next iteration's run -> h3Drive ->
ngReceive, which are exactly the two tracebacks the stress round produced -- is
handed a stamp a whole gap behind the one ngtcp2 already has, and the assertion
aborts the process. The h3 streaming cells died that way 5 to 10 minutes into the
hour, exit 133, and took their peers with them: the client reported an
"Idle timeout" against a server that had already gone.

There is nowhere to put that credit where it works. Clamping the clock to the
highest stamp already handed over would keep it monotonic, but the credit could
then never undo a jump ngtcp2 has already been shown, and it would instead freeze
the QUIC clock for the length of the gap *afterwards* -- stalling loss detection
and PTO on a host that is by definition already overloaded. So the credit goes,
and the clock the shim hands ngtcp2 is the plain monotonic clock again.

The intent of 358eb22 survives in its other two parts, neither of which needs to
lie about the time: the advertised idle window is keepAliveTimeout (60 s by
default) instead of a hardcoded 30 s, and ngtcp2's keep-alive PING is armed at a
third of it. An ordinary loop stall (2 to 33 s in the measured rounds) fits
inside that window, and a live-but-quiet connection keeps both ends' timers fed.

The new suite pins both halves: that the clock never hands out a stamp below one
it already gave and that a stall is not withheld from it, and that a live h3
connection survives a loop thread held past the loop's stall-credit threshold.
The second half is the reproduction -- on the pre-fix tree it does not fail a
check, it SIGABRTs with the identical traceback within seconds of starting.
The four user-visible fixes in this round: the truncated streamed sendFile, the
loop-stall deadline credit, the chronos pump spin on loop-parked futures, and
the HTTP/3 idle window plus keep-alive PING (including why ngtcp2's own timers
are deliberately left out of the stall credit).
@cryo2010
cryo2010 merged commit fb7bc4c into main Oct 2, 2026
35 checks passed
@cryo2010
cryo2010 deleted the fix/stress-all-round1 branch October 2, 2026 19:58
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