Skip to content

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

Open
kinyoklion wants to merge 3 commits into
rlamb/relay-init-concurrency-limiterfrom
rlamb/relay-init-concurrency-initwrite
Open

feat: add a progress-aware write deadline for initialization deliveries#786
kinyoklion wants to merge 3 commits into
rlamb/relay-init-concurrency-limiterfrom
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 and small Unwrap additions with no production call sites yet; behavior change is deferred to a follow-up wiring PR.

Overview
Introduces internal/initwrite, a ResponseWriter wrapper that will bound how long slow or stalled clients can hold init-delivery budget slots. It arms per-chunk write deadlines from a 64 KiB/s throughput floor plus slack, with an optional maxHold cap; large writes are sliced so deadlines re-arm mid-payload. Wrap always arms (poll responses); WrapGated arms only between Begin and an End + flush teardown so SSE heartbeats and idle HTTP/2 streams are not left with a lingering deadline. Abort / WaitAndFinish tear down stalled sends and release slot waiters.

Adds Unwrap on the logging and metrics ResponseWriter wrappers so http.NewResponseController can reach the real connection when this layer is stacked under middleware—without it, write deadlines would silently no-op.

No runtime wiring yet; this PR is the primitive plus tests and middleware compatibility only.

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

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.
@kinyoklion
kinyoklion requested a review from a team as a code owner July 31, 2026 22:14
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.

@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.

// Wrap returns a Writer for a poll (request/response) delivery: the deadline is armed on every
// write. net/http resets the connection deadline when the handler returns.
func Wrap(w http.ResponseWriter, maxHold time.Duration) *Writer {
return newWriter(w, maxHold, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Poll wrap leaves write deadline

Medium Severity

Wrap arms write deadlines but never clears them, relying on net/http to reset the connection after the handler returns. That only happens when WriteTimeout is set; this server sets none, so on HTTP/1 keep-alive the deadline can linger and fail or block a later request. WrapGated already clears via flush/Finish; poll deliveries need the same cleanup.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1b95108. Configure 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.
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