feat(server): add the re-arming upload body deadline reader (upload-inactivity-deadline plan: 1/4)
What
Adds server.DeadlineReader, a body wrapper that arms the socket read deadline
at min(now + inactivity, ceilingAt) on construction and again ahead of any
read that follows one carrying bytes, so an inactivity window bounds the gap a
read waits out where the caller's own ceiling bounds the transfer as a whole.
The write deadline is pinned at ceilingAt at construction and never moves.
No route installs it here. This step is the wrapper and its contract only, so nothing in it changes a request's behaviour and no configuration key is added. Step 2 installs it on the three OCI blob upload routes; steps 3 and 4 do the same for Maven and npm.
Why
container.upload_read_timeout and its Maven and npm siblings bound the whole
transfer, so a client that stops sending mid-body holds a connection until the
absolute ceiling elapses. Turning that into a bound on the gap between reads is
what #1043 (closed)
asks for, and what ADR-004's upload-inactivity paragraph describes.
The contract, and what is easy to get wrong
internal/server/body_size.md gains a section stating it. In short:
- Four arguments are refused, and
ceilingAtis the one worth explaining. A zero instant clears a deadline where every other value bounds one, so clamping to it would take the body offserver.timeouts.readas well as off its own window. The constructor panics rather than arming, because a caller reaching it has a bug. It panics on a nil body and a nil*http.ResponseControllertoo; the second is a bare nil dereference on the first arm if it is left to reach one, and the first is what makes the zero value's "requiresNewDeadlineReader" error true of only a zero value.inactivityis refused for a sharper reason than any of those three: a non-positive window arms atnowor before it, so the first read fails on an already-expired deadline and, with the window marker set, is booked as an inactivity cut, putting the discriminator's own lie in the single case it exists for.parseDurationFieldkeeps configuration from producing one, but a window derived against whatever is left of the ceiling produces one with nothing misconfigured, and an exported type's zero value is declarable whatever today's callers do. - The arm sits ahead of the read, not after it. A re-arm placed after the
inner read returns has to cover the caller's own work with the bytes it just
got as well as the wait for the next ones, and on the OCI and Maven paths
that work is a storage RPC in the same goroutine (
gcs.writer.ReadFrom, and the S3 equivalent). Since the half-window skip leaves the remaining budget anywhere in(0, inactivity], a flush outlasting it fails the next read against a client that was sending perfectly, and the window marker books that failure as an idle client: at a5mwindow a flush stall above 2m30s aborts a healthy upload and reports it as inactivity, which during a storage latency incident is a fleet-wide failure misattributed to clients. Arming first means every read opens with more than half a window that no caller work has spent, and the marker is read after the arm, so it names the deadline the read actually ran under. - Re-arming is amortized by two skips. The first skips while the deadline
in force is more than half a window out, so the idleness a body is cut at
falls between half the window and the whole of it. The second stops arming
once an arm has clamped to the ceiling:
nowonly grows, so every later arm resolves to that same instant. Without the second, an inactivity window equal to the ceiling arms once per read from half the ceiling onward, which on acontainer.blob_max_sizeblob that fills its ceiling is about 95 thousand arms: half of the 190 thousand reads such a blob takes at the storage driver's 256 KiBreadFromBufferSize. That is the cost the second skip exists to avoid; the first is what keeps the other half of the range off one arm per read. - A failed arm falls back to the ceiling. Otherwise the last window arm stays in force, cuts a client that is still sending, and books that cut as an idle one, which is the discriminator lying in the case it exists for.
- The write half is pinned at the ceiling once. Moving it with the read
half is what created an obligation prose had to carry: when the window fired,
the write deadline had expired at that same instant, so the route could not
flush its own answer and the client got a reset. A reset is
indistinguishable from the client hangup this wrapper exists to tell apart,
so a hand-back missed on one exit path failed silently in exactly the case
the feature was built for, and the obligation ran across three installing
steps. Both hand-back branches already put the write half on
ceilingAt, so pinning it changes no end state and removes the obligation rather than documenting it. The cost, recorded inbody_size.md: a write issued during the read, such as the100 ContinuefromexpectContinueReader.Read, is bounded byceilingAtrather than by the window. FinishReadbranches on which of four exits ended the read. The discriminator is whether a live background read is sitting on the socket, which is exactly whethernet/httpranonHitEOF, because that callback isstartBackgroundRead.onHitEOFis keyed onnet/http's ownsawEOFrather than on the error returned, so a short declared body reaches it as well as a plainio.EOF; both go back cleared, since arming there would add a bound where the server removed one and the background read would then cancel the request context. A read a deadline cut cleared nothing, so it is left exactly as it fired: a fresh bound is one the drain inbody.Closeblocks on, and the stalled client the window just cut would hold its connection and its serve goroutine for another whole window after being answered. A read the caller stopped short of the end cleared nothing either, and takes a fresh gap bound, becausedoEarlyCloseis set on every*bodyand drains up to 256 KiB under whatever the call leaves. That covers more than a declared body: the give-up branch needs an*io.LimitedReader, and a chunked body's source is achunkedReader, so an interrupteddocker pushis drained too, andConnection: closeskips the drain only for a declared body. The one exit costing nothing either way is a declared body with more than 256 KiB unsent, which is abandoned rather than drained.io.ErrUnexpectedEOFis the single value carrying two exits, a short declared body and a chunked truncation; the wrapper cannot tell them apart and clears for both, which is right for the pair because the truncation has no background read to strand and its drain returns at once onchunkedReader's sticky error.FinishReadgoes in a baredefer, and calling it earlier costs a connection.net/httpdrains the unread body frombody.CloseinsidefinishRequest, after the handler has returned, so the drain reads under whatever the last call left. A deferredFinishReadruns at that boundary and armsmin(now + inactivity, ceilingAt)there. Called at the end of the read it arms the same bound before an inline persist, and a persist outlasting the window leaves it already expired: the drain's first socket read fails on it,earlyCloseis set, and the connection is closed instead of kept. Measured over real TCP at a 50 ms window and a 250 ms persist, two requests on one client open one connection under the deferred call and two under the early one; pastmaxPostHandlerReadBytesboth open two, which is what identifies the drain as the only consumer of this deadline. This reverses the sidecar's earlier "restore at the end of the read, ahead of any persist", which was safe only while the write half moved.- Do not wrap a request that carries no body.
startBackgroundReadruns before the handler on a requestrequestBodyRemainsreports as bodyless, such as aContent-Length: 0OCI finalizePUT, so the background read is live before construction. The hand-back is not what bites:readTransfergives such a requestNoBody, whoseReadanswersio.EOFat once, so the read half goes back cleared, which is whatstartBackgroundReadhad already done. The arm that cannot be avoided is the construction one.cr.abortedstays false untilfinishRequestcallsabortPendingRead, so that arm's timeout is not the expected onebackgroundReadignores: it reacheshandleReadErrorLocked, which cancels the request context atnow + inactivityrather than atceilingAt, twelve times sooner under a5m/1hpair, and mid-commit. The OCI install is already shaped that way, becauseappendFinalChunkreturns before building a wrap on the chunkedPATCH's bodyless finalizePUT.
Tests
Thirty tests over the wrapper's contract, plus two benchmarks. Every
guard was mutation-tested at be7690e04: broken one property at a time, the
package suite re-run, and the reddening test recorded. Sixteen mutations, no
survivors:
| Mutation | Reddens |
|---|---|
drop the carriedBytes guard |
TestDeadlineReader_ReArmsOnlyOnAReadThatCarriedBytes |
| drop the exit guard on the re-arm | TestDeadlineReader_ReArmsOnlyOnAReadThatCarriedBytes |
| read the window marker before the arm rather than after | TestDeadlineReader_MarksTheCutTheArmAheadOfItTook |
| move the arm below the read | TestDeadlineReader_CallerTimeIsNotChargedToTheRead |
| pin the write half at the window instead of the ceiling | TestDeadlineReader_AmortizesReArmsInsideHalfTheWindow |
FinishRead: gap-bound the cleared exits |
TestDeadlineReader_FinishReadClearsTheReadHalfPastEOF, TestDeadlineReader_FinishReadClearsTheReadHalfAfterAShortBody |
FinishRead: re-arm after a fired deadline |
TestDeadlineReader_FinishReadLeavesAFiredDeadlineAlone |
FinishRead: clear instead of bounding the drain |
TestDeadlineReader_FinishReadBoundsTheDrainShortOfEOF, TestDeadlineReader_FinishReadBoundsTheDrainAfterAReadFailure |
| drop the fall back to the ceiling | TestDeadlineReader_ArmFailureFallsBackToTheCeiling |
armRead records its instant even when the call failed |
TestDeadlineReader_AFailedFallBackLeavesTheWindowInForce |
drop reArm's armErr guard |
TestDeadlineReader_ArmFailureStopsReArmingUnderALandedReadHalf |
drop reArm's clamp guard |
TestDeadlineReader_ClampedArmStopsReArming, TestDeadlineReader_ClampsMidStreamAndStopsReArming |
| drop the half-window skip | TestDeadlineReader_ReArmCountTracksElapsedNotReadCount |
recordExit: fold io.ErrUnexpectedEOF into the in-progress state |
TestDeadlineReader_FinishReadClearsTheReadHalfAfterAShortBody |
recordExit: treat a fired deadline as an ordinary exit |
TestDeadlineReader_FinishReadLeavesAFiredDeadlineAlone |
recordArmError overwrites the first failure |
TestDeadlineReader_ArmErrorKeepsTheFirstFailure |
The constructor's refusals were mutation-tested in an earlier round and their
tests are unchanged: TestNewDeadlineReader_RefusesANonPositiveInactivity,
TestNewDeadlineReader_RefusesAZeroCeiling, and
TestNewDeadlineReader_RefusesANilBodyAndController.
Two mutations that survived an earlier round are closed. armRead recording
its instant on a failed call now reddens
TestDeadlineReader_AFailedFallBackLeavesTheWindowInForce. Gating the
hand-back on armErr was the other; it is moot now that the hand-back moves
only the read half, and the three FinishRead rows above pin what it leaves on
each exit.
The exit a fired deadline takes had no test at all before this round, which is what let the hand-back give the drain a fresh window with the suite green.
TestDeadlineReader_FinishReadBelongsInADefer is measured the same way, over
real TCP rather than a recorder: making FinishRead arm nothing on the
stopped-short exit reddens it.
The arm's own placement survived an earlier round of this table, and the
harness was why rather than the assertions: scriptedBody and pacedBody
both advance the fake clock inside Read, so the arm lands at the same
instant whichever side of the read it sits on, and caller time between two
reads (the whole finding) could not be expressed. Advancing the clock from
the test body separates them, which is what
TestDeadlineReader_CallerTimeIsNotChargedToTheRead does. Note arms/op is
blind to it: both benchmarks report the same figures with the arm on either
side of the read, because placement is a timing property and not a count.
BenchmarkDeadlineReader_PacedBody holds 5 arms/op flat across 32, 128 and
1024 reads; BenchmarkDeadlineReader_ClampedBody holds 1 arm/op flat across
the same three. Both re-measured at be7690e04. The figures are unchanged by
moving the arm ahead of the read, and each arm is now one deadline call rather
than two, since the write half no longer moves with it.
TestDeadlineReader_ClampedArmStopsReArming is paced a minute a read rather
than a second: at a second the transfer never left the region the half-window
skip already covers, so the clamp guard the test is named for could be deleted
with it still green.
Size
2135 reviewable LOC, over the 500 ceiling
docs/dev/development-model.md sets, so per
guardrail 18, by file group. Churn is added plus deleted lines against the
merge base ce6d06d9d, measured at be7690e04:
| File | LOC | What |
|---|---|---|
internal/server/upload_deadline_test.go |
1480 | the suite |
internal/server/upload_deadline.go |
290 | the wrapper |
internal/server/body_size.md |
289 | the contract, as prose |
internal/server/upload_deadline_bench_test.go |
76 | the two benchmarks |
The production change is 290 lines; 1845 of the 2135 are tests, benchmarks and sidecar prose. Splitting further would separate the wrapper from the suite that pins it, which is the pairing a reviewer needs in one place. What carries the size is the suite and the sidecar, not the wrapper.
Corrections to the merged plan
Three facts in docs/plans/2026-09-03-upload-inactivity-deadline.md
are overturned here rather than transcribed, and guardrail 4 keeps a step MR
out of the plan file, so they are declared here and recorded in a separate
docs(plans) MR.
- The plan rules that a read carrying bytes beside
io.EOFshould re-arm. Its Approach says "n > 0besideio.EOFis a legal and common return, and it did carry bytes, so it should count", and both its Step 1 Tests entry and its Testing Strategy require that test. This MR does the opposite: that read is the onenet/httpclears the deadline inside, so there is no next gap to bound and an arm there fights the clear. The suite pins the reversal. - The plan records the write half re-arming alongside the read as settled.
Its
### The write deadline re-arms alongside the readsection ends "The wrapper therefore moves both", and## Open Questionslists it among the settled decisions. What that settled was moving the write half at all, against leaving it at the instant the handler armed; pinning it atceilingAtonce, at construction, is a third option the plan does not weigh. This MR takes it. The response then has the whole ceiling to flush in, which is strictly more than the original instant, so the failure #776 (closed) records is still closed, and the per-exit restore obligation that moving both halves creates goes away. The plan is the remaining site carrying the superseded ruling. - The plan's arm-count figure rests on the wrong buffer. It says the count
is "the driver's read count" and then measures it "at
io.Copy's 32 KiB default", giving "about 1.5 million reads". The drivers read into a 256 KiB scratch, and that scratch caps the bytes a read carries rather than setting how many reads there are, so the honest figure is a floor of about 190 thousand rather than an estimate either way. The plan is the remaining site carrying the superseded number.
Stack
Merge in this order. Each row targets the row above it.
| Order | MR | Branch | Target |
|---|---|---|---|
| 1 | this MR | dmeshcharakou/upload-inactivity-deadline-step-1 |
main |
| 2 | !2346 (merged) | dmeshcharakou/upload-inactivity-deadline-step-2 |
step 1 |
| 3 | !2351 (merged) (Maven) | dmeshcharakou/upload-inactivity-deadline-step-3 |
step 1 |
| 4 | not yet opened (npm) | — | step 1 |
Steps 2, 3 and 4 are siblings: none depends on another, and each is reviewable and mergeable on its own once this one lands.
Both open children stop compiling against this step until they are rebased
onto it. This MR renames RestoreCeiling to FinishRead and stops the
hand-back touching the write half at all; both children call the old name and
both describe the old behaviour. Measured at this head:
| MR | Call site to rename | Prose to drop |
|---|---|---|
| !2346 (merged) | internal/format/oci/upload.go:1629 |
internal/format/oci/upload_deadlines.md:49, :91 |
| !2351 (merged) | internal/format/maven/upload.go:360 |
internal/format/maven/upload_deadlines.md:59, :70 |
The call site is the rename alone. The prose is the substantive half: both
sidecars describe the call putting both halves back on ceilingAt, which the
pinned write half makes false, so each wants its write-half restore passage
dropped rather than renamed. Their own descriptions repeat the same rationale
and need it there too.
Both children target this branch, so the next merged-result pipeline either one runs is compiled against this head and fails on the rename. Until one runs, nothing on either MR shows the break. Step 4 is not open yet and inherits the corrected contract.
e2e scenarios
No scenario is added or affected here: no route installs the wrapper in this
step, so no request behaviour changes. Step 2 adds
e2e.docker.publish.stalled-upload-cut to
docs/testing/e2e/docker.md.
Related to #1043 (closed)