Skip to content

feat: add a progress-aware write deadline for initialization deliveries - #786

Open
kinyoklion wants to merge 8 commits into
feat/concurrency-init-limitsfrom
rlamb/relay-init-concurrency-initwrite
Open

feat: add a progress-aware write deadline for initialization deliveries#786
kinyoklion wants to merge 8 commits into
feat/concurrency-init-limitsfrom
rlamb/relay-init-concurrency-initwrite

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Jul 31, 2026

Copy link
Copy Markdown
Member

Adds the write-deadline primitive that the init-concurrency wiring will use to reclaim a slot from a client that cannot keep up, without disturbing a healthy client mid-delivery.

What changes

  • New internal/initwrite package. A ResponseWriter wrapper that, for each initialization delivery, arms a write deadline sized to the payload: a throughput floor (so a stalled or too-slow client is cut promptly) combined with an absolute cap (so a client stuck right at the floor on a very large payload is still bounded). It writes strings directly to avoid copying the payload once per connection, and implements Unwrap so http.NewResponseController can reach the underlying connection to set the deadline.
  • Unwrap on the logging and metrics ResponseWriter wrappers. These wrap the connection for their own purposes and otherwise hide it, which would stop a ResponseController from reaching the real connection. Each now returns its inner writer.

No behavior change

Nothing wraps a connection with initwrite yet, so this is inert at runtime. It builds, vets, and passes -race on ./internal/initwrite/, ./internal/logging/, and ./internal/middleware/.


Second of the PRs splitting #782. Builds on #785 (the config + limiter refinement); the wiring PR will follow and depend on both.


Note

Low Risk
New isolated package with extensive tests; middleware changes are small Unwrap additions with no runtime wiring yet.

Overview
Introduces internal/initwrite, a ResponseWriter wrapper for initialization poll and SSE replay traffic. It arms per-chunk write deadlines from a 64 KiB/s throughput floor plus slack, optionally clamped by maxHold, and supports Wrap (always armed) vs WrapGated (Begin/End/WaitAndFinish so SSE heartbeats stay deadline-free after the basis flush). The handler owns SetWriteDeadline; producers coordinate only via channels.

loggingHTTPResponseWriter and statusRecorder now implement Unwrap() so http.NewResponseController can set deadlines through the middleware stack when this is wired in.

Nothing uses initwrite in handlers yet, so relay behavior is unchanged until a follow-up PR.

Reviewed by Cursor Bugbot for commit dc68f44. Bugbot is set up for automated code reviews on this repo. Configure here.

@kinyoklion
kinyoklion requested a review from a team as a code owner July 31, 2026 22:14

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1b95108. Configure here.

Comment thread internal/initwrite/initwrite.go
Base automatically changed from rlamb/relay-init-concurrency-limiter to feat/concurrency-init-limits August 5, 2026 16:34
Add internal/initwrite: a ResponseWriter wrapper that arms a write deadline
sized to the payload it is about to send. The deadline combines a throughput
floor with an absolute cap, so a client that stalls or reads slower than the
floor is cut promptly while a healthy client sending a large payload is not.
It writes strings without copying the payload per connection and exposes the
underlying connection via Unwrap so http.NewResponseController can reach it.

To let that (and any other) ResponseController operation reach the real
connection, implement Unwrap on the logging and metrics ResponseWriter
wrappers, which otherwise hide it.

No runtime effect on its own: nothing wraps a connection with initwrite yet.
Make the write-deadline writer robust on every delivery-end path, not just
the normal end-of-batch flush:

- Done is now never nil. Outside a delivery it is an already-closed channel,
  so a producer that waits on it to release its budget slot is released at
  once instead of pinning the slot for the life of the connection. This also
  removes the need for a caller to capture the channel before closing its
  batch.
- Add Finish: an idempotent backstop that clears the deadline and closes Done
  on any exit path. Without it, a delivery that ended without a final flush
  (an error or a cancelled context) left an armed deadline behind, which on
  HTTP/2 is a self-firing timer that resets an otherwise idle stream.
- Begin closes any still-open Done before starting a new delivery, so a
  second Begin cannot orphan a waiter.
- arm records a deadline as current only when SetWriteDeadline succeeds, so a
  transient failure does not leave the writer believing it armed a deadline
  it never set and then stop retrying.

Rewrite the tests around an injected clock so the per-chunk deadline math is
asserted exactly (floor, slack, cap, per-chunk re-arming) and the full gated
lifecycle is covered (no deadline before Begin, armed during, cleared once at
end, Done fail-safe, Finish backstop, string/byte parity). Coverage of the
package rises from ~39% to ~94%.

Also make the package doc self-contained: it no longer references a config
file or default value that lives in the wiring change rather than here.
The previous Finish cleared the write deadline on any exit path. Called
mid-send -- which is where a cancelled delivery lands, since the completion
signal never fires on cancel -- clearing stripped the only bound on the
remaining bytes, so a stalled client could hold the connection and its budget
slot indefinitely. Clearing is correct only for a delivery that actually
finished.

Replace it with two operations that match the two ways a gated delivery ends:

- A clean finish still clears the deadline at the end-of-delivery flush, so
  the now-idle persistent stream is left alone.
- Abort ends a delivery abnormally by moving the deadline to now, forcing any
  in-flight or subsequent write to fail at once rather than running unbounded.
  It is safe on any exit path, including mid-send.
- WaitAndFinish holds until the delivery is flushed (slot held across the real
  send) or the context is cancelled, aborting in the latter case. This is the
  one correct ordering, so the producer no longer has to assemble it.

Also make teardown race-safe: a generation counter bumped in Begin lets a
flush tear down only the delivery it observed, never a newer one that started
meanwhile, and the deadline clear now runs under the lock like arm does.
Remove Flush's pre-flush re-arm, which could stamp the delivery's start before
its first payload byte and shrink a healthy client's budget. Guard the chunk
loop against a (0, nil) writer.

Rework the tests around the injected clock to assert the exact per-chunk
budget with literal values, arm-before-write ordering, the error path, the
gated string path (the production SSE path), post-delivery inertness across
heartbeats, Abort vs clean-finish semantics, WaitAndFinish on both branches,
and that a stale flush cannot clear a newer delivery's deadline.
A gated stream's producer goroutine can outlive the HTTP handler: the SSE
server keeps draining it after the client leaves. A write deadline, though, is
a capability scoped to the handler's lifetime -- once the handler returns the
connection may be recycled or, on HTTP/2, gone -- so setting it from the
producer after the handler returned dereferences freed state inside net/http
(a process crash on HTTP/2) or poisons the next request on a recycled
keep-alive connection.

The previous Abort force-fired the deadline, and WaitAndFinish called it on
context cancellation -- which is exactly when the handler has returned. Remove
that: the producer now never touches the connection. Only the handler
goroutine sets or clears the deadline, via the per-write arm and the
end-of-delivery flush, both within its own lifetime. On cancellation
WaitAndFinish just releases the waiter so the slot frees; the per-write
deadline already armed still bounds anything in flight, and connection
teardown clears it.

Also clear the deadline before closing Done on the clean path, so a producer
released by Done is guaranteed to observe the cleared deadline.

Tests: prove the cancel path performs no SetWriteDeadline (counter and a
panicking connection), and add an end-to-end HTTP/2 test where the producer
outlives the handler and must not crash. Restore the multi-chunk string parity
test and mirror the byte path's arm-order and error-path coverage onto the
string path; pin the same-value re-arm ratchet; guard against a double flush;
give the delivered-wait test a timeout so a regression fails instead of hangs.
Removing the force-fire left the producer-error-with-a-healthy-client case
without a terminator, and the package doc narrowed the contract to "two ways"
a delivery ends. If the producer errors after arming a deadline and skips End,
the end-of-batch flush never tears down: the deadline is never cleared and the
handler eventually cuts even a healthy stream at maxHold, while the slot stays
pinned.

State the real contract: a gated delivery ends in three ways, and End must be
called on every exit after Begin -- a clean finish and an abandoned delivery
alike -- before the batch channel is closed, so the handler's flush clears the
deadline on the goroutine that owns the connection. Releasing the slot alone is
not enough, because only the handler can clear the deadline.

Also:
- Flush now performs the teardown even when the underlying writer cannot flush,
  so a non-flushing chain cannot strand the deadline or pin the slot.
- Note on WaitAndFinish that a context cancelled while the handler is still live
  returns early without ending the delivery; use End for that.

Tests: pin that a flush without End does not tear down and that End+flush tears
down even a partial delivery; that a cancel after a clean delivery does not
double-close; the (0, nil) short-write guard; teardown on a non-Flusher chain;
and make the HTTP/2 post-handler-return test deterministic so a reintroduced
connection touch crashes reliably instead of flaking green.
…ve request

A poll (Wrap) delivery arms a per-write deadline and relies on net/http to
reset the connection's write deadline when the handler returns, since this
server sets no WriteTimeout. Automated review flagged this as unsafe, on the
theory that net/http only resets the deadline when WriteTimeout is set. That is
not so on the Go versions this builds with: the reset is unconditional. Add a
regression test that arms a short deadline in one request and reuses the
keep-alive connection for a second one after it would have expired -- the
second request succeeds -- so a future change to that behavior would fail CI
rather than silently break a later request.
Round of test and doc hardening; one structural change: Flush's teardown now
runs from a defer registered before the flush, so a flush that is unsupported,
fails, or panics can no longer strand the deadline or pin the slot, and the
(active, ending) sample is explicitly taken before flushing so a delivery that
begins mid-flush belongs to the next flush.

Tests, each verified to fail against the defect it pins:
- The delivered-then-cancelled double-close check now loops (a single run took
  the panicking branch only ~half the time), and two waiters parked on one
  abandoned delivery reach the close deterministically.
- The flush-side double-close guard: client leaves between End and the
  end-of-batch flush, which then must not re-close done (a panic here would
  land on the handler goroutine) yet must still clear the deadline.
- Sample-before-flush ordering: a delivery begun during the flush must not be
  torn down pre-basis.
- Re-Begin resets: ending (else the first flush after re-Begin kills the new
  delivery), the cap anchor, and the deadline ratchet.
- The absolute cap is anchored to the delivery start, not per-write (a
  per-write anchor is no backstop at all).
- minExtension suppression values, arm-after-end inertness, the string-path
  (0, nil) guard, and timeouts so a removed guard fails instead of hanging.
- A concurrent lifecycle stress run so -race pins the lock discipline.

Docs, each correcting a measured behavior: a missed End kills an HTTP/2 stream
within seconds of its last write (not at the cap), disabling the cap makes
that kill sooner rather than never, an error exit that wrote nothing still
needs End, and defers must register the batch-channel close before End so End
runs first. Softened "exactly once" to idempotent; the flush dispatches to the
first Flusher in the chain.
The keep-alive regression test asserted only that a second request succeeded;
if the premise it pins ever broke, the transport would silently retry the
idempotent GET on a fresh connection and the test would stay green. It now
also asserts the second request REUSED the connection.

Correct the cap-disabling doc for HTTP/1.1, where no deadline self-fires: a
missed End with the cap disabled leaves the budget slot pinned permanently
rather than killing the stream, and the HTTP/1.1 kill (with the cap on) lands
at the first write after the cap expires. Note that a heartbeat interval
shorter than the slack postpones the HTTP/2 self-fire, which can hide the bug
under fast test heartbeats.

Pin the properties of the deferred-teardown change itself, each test verified
to fail with the defect reintroduced:
- a panicking flush still clears the deadline and releases the waiter during
  unwinding, and the panic propagates;
- the clearing SetWriteDeadline runs strictly after the final flush, which
  must still execute under the armed deadline;
- a stale waiter cannot release a newer delivery's slot (WaitAndFinish's
  channel capture is split out so the test can drive the cancellation branch
  with a stale capture);
- Flush's state sample happens under the lock, pinned by a race test that
  actually splits the producer and handler across goroutines.

Also release the waiter from a defer inside the teardown, so even a panicking
deadline clear cannot strand it.
@kinyoklion
kinyoklion force-pushed the rlamb/relay-init-concurrency-initwrite branch from 77697c7 to dc68f44 Compare August 5, 2026 16:37
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