Skip to content

fix(transport): acknowledge down-poll chunks so a slow push cannot lose bytes - #104

Merged
Daily-AC merged 7 commits into
mainfrom
fix/57-push-bad-record-mac
Sep 18, 2026
Merged

Daily-AC merged 7 commits into
mainfrom
fix/57-push-bad-record-mac

Conversation

@Daily-AC

@Daily-AC Daily-AC commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Fixes #57.

Root cause

The peer-to-peer TLS stream rides on the relay's HTTP long-poll transport. /h/down dequeued bytes from the session's side queue and then wrote them into the response body, so anything that stopped the reader from receiving that response whole destroyed those bytes for good. The stream got a hole in the middle, and the end-to-end TLS layer reported the hole as tls: bad record MAC.

internal/httpconn made the loss silent. In Read:

body, _ := io.ReadAll(resp.Body)

A body cut short returns the partial data and an error. The error was discarded and the partial body was handed straight to TLS.

Two things made that happen on a slow link and only after a few minutes:

  • Unbounded coalescing. sideQueue.drain appended every queued chunk into one response. A reader that fell behind while the writer kept going got handed megabytes in a single response body.
  • A whole-exchange client timeout. http.Client{Timeout: 60 * time.Second} bounded the entire request including the body download. On a slow link a coalesced multi-megabyte response exceeds that bound — at the ~60 KB/s measured in the issue, 8 MB needs about 140 s — so the timeout fired mid-body, which is exactly the truncation above. Smaller responses on the same link were fine, which is why 2 MB succeeded where 18.9 MB did not.

That matches the report: 18.9 MB failed at 2 m 50 s on the slow link, while 2 MB on the same link and 18.9 MB on a fast link both succeeded.

The fix

The down direction is acknowledged, at the transport layer:

  • A data-bearing 200 carries X-Wanctl-Down-Seq. The next poll reports the last fully received sequence in ack=.
  • The relay holds a delivered chunk in sideQueue.unacked until an ack covers it, and re-sends it byte for byte under its original sequence otherwise. Nothing is dropped on the strength of having written it to a socket.
  • Checking the ack, draining, numbering the chunk and storing it as unacked is one operation per direction, admitted one poll at a time. Two polls that overlap — a reader whose request was abandoned while parked on an empty queue, plus the retry it sent next — used to each take a chunk, and the second overwrote the first's. A poll abandoned after it drained still records its chunk, so the bytes wait for the next poll rather than vanishing with the response nobody received.
  • httpconn no longer swallows a truncated body. It leaves the ack where it is and polls again, so the relay re-sends; the same bounded retry covers a poll request that fails outright. It also discards a re-send of a sequence it already consumed, so a duplicate cannot be injected either.
  • Retrying is gated on a negotiated capability. The relay marks every authenticated /h/down answer with X-Wanctl-Down-Ack — the data-bearing 200, the empty 204, and the 400/404/410 refusals, though not the 401, which is answered before the relay knows who is asking. A reader that has not seen that header (or a sequence header) on this session fails the read loudly instead of retrying, because a relay from before this protocol dequeues before it answers and a retry there would resume after the lost bytes and skip them silently. Failing is what main does today, so this is never worse than before the PR; it only ever retries more.
  • Coalescing is capped at maxDrainBytes, and the cap now bounds the append instead of being tested before it: a chunk that would overshoot is split and its tail is served first on the next drain, preserving order.
  • A graceful close keeps the session drainable. Deleting it on /h/close used to throw away everything queued behind the first capped response — the far side read one cap's worth and the rest 404ed. The queues now stop accepting bytes and report EOF once dry, while the session stays reachable until both directions have been taken: closed to new bytes, no poll part way through taking any out, and none left queued, held as a split tail, or waiting to be acknowledged. The in-flight part matters — a chunk that has left the channel but has not yet been recorded is in no field at all, and reading only the fields made an occupied direction look finished. One direction reaching EOF says nothing about the other, and retiring on the first would 404 away a chunk the peer had not yet acknowledged. Retirement is attempted by every site that can complete the last of those conditions — each poll, each read on the in-process bridge, and the close itself, which is what shuts the second queue. A single site is not enough: whoever looks first may look while another reader still holds a direction, and a check that came too early is only harmless if someone checks again. When the far side never comes back for its half, none of them can succeed and the idle sweeper retires it instead: reapHTTP scans every 40 s and drops a session nobody has polled for 60 s, so an abandoned one is gone within roughly 60–100 s. Credential revocation still tears the session down immediately (410).
  • A closed session is readable, not writable. Keeping it registered left /h/up open on it, so an upload after the close could still queue bytes nobody would read. /h/up now refuses a closed session with 410, including an empty body, and push and close take the same lock, so once the close has returned no later push can succeed.
  • The client bounds the wait for response headers at 45 s and the whole request at 5 minutes, rather than the old 60 s bound on the entire exchange, so a body that is merely slow is no longer killed.

A poll that arrives without ack= is served the old fire-and-forget way, so a mixed-version fleet keeps working. That path is covered by a test.

Choosing the cap

maxDrainBytes is 2 MiB, sized against the link in the issue rather than picked round. At 60 KB/s a full response downloads in about 34 s — roughly a ninth of the client's 5-minute per-request bound, with room to spare for the 20 s the poll may have parked on the relay first. Larger buys nothing on that link and makes the re-send after a truncated body more expensive. Smaller costs throughput everywhere else, because serial polling cannot carry more than one cap per round trip.

Measured on the in-repo probe (32 MiB, simulated 100 ms per-poll RTT):

cap polls throughput
256 KiB 128 2.47 MiB/s
2 MiB 16 19.24 MiB/s

TestDownPollThroughputAtRTT keeps that measurable; it is skipped unless WANCTL_TRANSPORT_THROUGHPUT=1 is set.

transport=ws

No bug of this class. A WebSocket session is one TCP connection piped byte for byte by relay.pipe, so a carrier failure tears the session down with an error instead of leaving a gap. The hybrid ws/http path reads the same side queues in process through httpSessionConn, where the hand-off is a function return, not a response that can half arrive — but its HTTP leg shares the drain cap, so ending that leg now uses the same drainable close.

Verification

internal/relay/slowlink_test.go builds an in-process relay, controller and agent and runs a real TLS stream over the HTTP transport. It carries real net/http on both ends with only the TCP listen and dial replaced by net.Pipe, so it needs no listening socket.

On main, TestPushSurvivesTruncatedDownPoll (20 MB, two responses cut short) fails with the issue's exact error:

push failed after 4 data-bearing down polls (1 truncated, 0 dropped):
agent read: local error: tls: bad record MAC
(controller saw: controller write: up chunk: relay returned 404)

and TestPushSurvivesDroppedDownPoll (8 MB, two polls killed before the response) fails with a connection reset. Both pass on this branch, with the agent's SHA-256 matching the payload.

Each review finding has its own test, each verified to fail with only that fix reverted:

test what it pins
TestOverlappingDownPollsOnTheWireDoNotLoseAChunk a poll failed at the client but left running on the relay, overlapped by its retry, over real HTTP
TestOverlappingPollsCannotTakeDifferentChunks polls that acked nothing are all served the same chunk
TestAbandonedPollKeepsItsChunkForTheNextPoll a poll abandoned after draining leaves its chunk, under the same sequence
TestDownPollRetriesOnlyAgainstAnAcknowledgingRelay new client against an old relay: dropped and truncated responses, paired against the current relay. The older relay has to prove it advertises neither the capability nor a sequence header, so the case cannot pass by mis-wiring
TestGracefulCloseStaysDrainable 4 chunks over the cap, peer closes mid-response, reader still gets all of it then EOF
TestGracefulCloseWaitsForBothDirections one direction drained while the other holds an unacknowledged chunk and a split tail, for both close paths and for a poll already parked when the close landed
TestUploadsAfterGracefulCloseAreRejected 64 uploads after a close that left a backlog, where the session is deliberately kept, all refused, nothing queued, empty body included
TestUploadRacingGracefulCloseIsAllOrNothing an upload concurrent with the close: the status and the queue agree
TestRetirementWaitsForAPollHoldingAChunk the empty direction polled in a loop while the other works through two megabytes, for both close paths; the chunk stays re-sendable
TestGracefulCloseRetiresASessionAReaderWasParkedOn a reader already parked when the close arrives, for both close paths; the session is still retired promptly rather than left to the sweeper
TestRevokedCredentialTearsDownImmediately revocation is not a graceful close
TestDrainCapSplitsRatherThanOvershoots one over-cap chunk, two straddling it, irregular sizes; order and memory bound

Also covered: TestSideQueueResendsUntilAcked, TestDrainCoalescingIsBounded, and TestDownPollWithoutAckIsFireAndForget for the other direction of the version mix, an old client against the current relay.

The existing fault carrier injects one fault per data-bearing poll and never leaves two polls running at once, so the overlap needed a second carrier: it fails a poll at the client while letting that same request keep running on the relay, detached from the client's context, so the retry arrives with the abandoned poll still parked on the queue.

go build ./... && go vet ./... && go test ./...        # clean
go test -race ./internal/relay/ ./internal/httpconn/   # clean
GOOS=windows GOARCH=amd64 go build ./...               # clean
gofmt -l .                                             # empty

Not verified here

The real slow link. This reproduces the mechanism deterministically rather than by throttling, so the production 60 KB/s path to the Windows agent still wants one real 18.9 MB push after deploy.

🤖 Generated with Claude Code

张以琳 and others added 2 commits September 18, 2026 17:40
…se bytes

The HTTP transport carries an end-to-end TLS stream over finite request /
response pairs. /h/down dequeued bytes from the session's side queue and then
wrote them into the response, so anything that stopped the reader from
receiving that response whole destroyed those bytes: the stream got a hole in
the middle and TLS reported it as "tls: bad record MAC" several minutes into a
push. httpconn made this silent by discarding the error from io.ReadAll on the
response body and handing the partial body on as if it were complete.

Two things conspired on a slow link. The relay coalesced everything queued into
one response, unbounded, so a reader that fell behind was handed megabytes at
once; and http.Client.Timeout bounded the whole exchange, so it fired while
that response body was still downloading and cut it in half.

The down direction is now acknowledged. A data-bearing 200 carries
X-Wanctl-Down-Seq, the next poll reports the last fully received sequence in
ack=, and the relay holds a delivered chunk until it is acked, re-sending it
byte for byte otherwise. httpconn no longer swallows a truncated body: it does
not advance the ack and polls again, so the relay re-sends. Coalescing is
capped at the controller's write batch (256 KiB), which also bounds the memory
one unacked chunk holds, and the client bounds the wait for response headers
instead of the whole exchange.

A poll that arrives without ack= is served the old way, so a mixed-version
fleet keeps working.

transport=ws does not have this bug: a session there is one TCP connection
piped byte for byte, so a carrier failure tears the session down instead of
leaving a gap. The hybrid ws/http path reads the same queues in process, where
the hand-off is a function return rather than a response that can half arrive.

Fixes #57

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d session

Review of the acknowledged down protocol found four ways bytes could still go
missing. Each is closed here with a test that fails without the fix.

Overlapping polls on one direction each took a chunk. A reader whose request
was abandoned while it was parked on an empty queue leaves that poll running
inside drain; the retry it sends next enters drain too, and whichever finishes
second overwrites the first's unacked chunk and loses it. Checking the ack,
draining, numbering the chunk and storing it is now one operation per
direction, admitted one poll at a time. A poll that is abandoned after it has
drained still records its chunk, so the bytes wait for the next poll instead of
disappearing with the response nobody received.

Retrying a poll the carrier failed to deliver is safe only against a relay that
holds the chunk until it is acked. Against an older one, which dequeues before
it answers, the retry resumes at the chunk after the lost bytes and skips them
without a word — the opposite of what the retry was added for. The relay now
marks every /h/down answer with X-Wanctl-Down-Ack, and a reader that has not
seen that on this session fails the read the way main does rather than
retrying.

A peer closing gracefully used to delete the session, which with a drain cap in
place throws away everything still queued behind the first response: the far
side read one cap's worth and the rest 404ed. A graceful close now stops new
bytes and reports EOF once the queues run dry, but leaves the session reachable
until the reader has taken all of it. Credential revocation is unchanged and
still tears the session down at once.

The cap was tested before appending rather than bounding the append, so a
single chunk larger than it, or two chunks straddling it, came back whole. A
chunk that would overshoot is now split, its tail served first next time so the
stream keeps its order, and the comment states the memory each direction can
actually hold.

The cap itself moves from 256 KiB to 2 MiB, sized against the link in the
issue: 2 MiB at 60 KB/s downloads in about 34 s, a ninth of the client's
5-minute per-request bound, while serial polling can now carry 2 MiB per round
trip instead of 256 KiB. A measured 32 MiB at a simulated 100 ms RTT goes from
2.46 MiB/s to 19.24 MiB/s. TestDownPollThroughputAtRTT keeps that measurable.

Fixes #57

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Daily-AC
Daily-AC force-pushed the fix/57-push-bad-record-mac branch from 03bfb54 to dbc20fe Compare September 18, 2026 10:01
张以琳 and others added 3 commits September 18, 2026 18:10
…elay is one

Two gaps the author of the acknowledgement change pointed out.

The fault carrier injects one fault per data-bearing poll and never leaves two
polls running at once, so nothing in the suite reached the overlap through real
HTTP: the queue-level test covered the primitive, not the handler. The new
carrier fails a poll at the client while deliberately letting that same request
keep running on the relay, detached from the client's context, so the retry
arrives with the abandoned poll still parked on the queue. Feeding two chunks
one at a time then reproduces the reported behaviour exactly: without the
serialized turn the reader's first chunk comes back as the second one.

The pre-acknowledgement relay in the retry-gating test was taken on trust. It
now has to prove itself: the carrier records what the relay advertised, and
each case asserts that the older relay offered neither the capability nor a
sequence header while the current one offered both. That is the new client
against an old relay, which had no coverage at all before this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… empty

Two things the round-two review found in the drainable close.

Reaching the end of one direction says nothing about the other. A controller
that has read everything it was sent hits EOF on its queue, and the session was
retired on that alone — while the agent still held a delivered chunk it had not
acknowledged and the tail of a chunk split at the cap. Its next poll got a 404
for bytes the relay had promised to keep. Retirement now waits until neither
direction has a queued chunk, a split tail or an unacknowledged chunk. When the
far side never comes back for its half, nothing fires and the idle sweeper
retires the session instead, as it does for any abandoned one. Both ways a
session ends gracefully are affected and both are covered.

Keeping a closed session reachable also left /h/up open on it, and push chose
between a writable queue and a closed one by whichever the runtime picked, so
roughly half the uploads sent after the close returned 200 and queued bytes
onto a session nobody would read. That broke the premise the drain check rests
on. An upload to a closed session is now refused before it reaches the queue,
including an empty one, which never reached the queue to be refused by it; and
close and enqueue take the same lock, so once close has returned no later push
can succeed. A push already waiting for room on a full queue when the close
lands is genuinely concurrent with it and may still go either way, but its
answer and the queue always agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drain check read the fields a chunk ends up in, but a chunk spends a moment
in neither. take holds the direction's turn while it receives from the channel
and copies, and only afterwards takes ackMu to record what it has; in between,
the channel is empty and unacked is still nil. A poll on the other direction
that checked both sides during that window found the session finished and
retired it, and the chunk the first poll went on to record could never be
re-sent: its retry got a 404, with the response it was meant to replace already
lost.

A take now marks the direction from the moment it takes the turn until after it
has published its chunk, in the same critical section unacked is written in, and
the check treats a marked direction as non-empty. The check also requires the
direction to be closed, which is what stops its answer going stale: once that
holds nothing can be pushed, so only a take could move anything, and a take can
only find what the check just established is not there.

Both ways a session ends gracefully are affected and both are covered. The
regression provokes the window rather than injecting one, by polling the empty
direction in a loop while the other works through two megabytes; on the
unfixed code it reproduces within the first few attempts on every run. The same
interleaving driven deterministically, with the poll held between draining and
recording, was used to confirm the fix separately.

Fixes #57

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
张以琳 and others added 2 commits September 18, 2026 18:50
…o hold

CI caught a session that outlived both its peers. A reader parked on one
direction is still holding it while the close walks the two queues, so whoever
looks first looks too early: a poll that wakes on the first queue finds the
second still open, and a poll that wakes on the second finds the first still
held. Retirement was attempted from exactly one place, the poll that reaches
EOF, and a check that came too early was never retried — so nothing retired the
session and it sat in the registry until the idle sweeper a minute later. The
bridge test waits two seconds for that and failed.

The in-process bridge reader is the case that cannot come right on its own: it
holds a direction exactly as an HTTP poll does, but it never attempted
retirement at all, so whenever it was the last one holding, no one was left to
try. With one processor it reproduces on the first attempt.

Retirement is now attempted by every site that can complete the last of the
conditions it waits on: every poll on the way out rather than only the one that
sees EOF, every read on the in-process bridge, and the close itself, which is
what shuts the second queue. Whichever of them finishes last finds the session
finished and retires it.

A closed session with nothing queued is now retired as the close returns, so a
later request finds no session and is answered 404 rather than 410 — what the
code did before any of this, and either way the client treats it as the end.
The upload-after-close regression moves to the case that actually exercises the
check: a close with a backlog still to come out, where the session is kept on
purpose and /h/up can still reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Daily-AC
Daily-AC merged commit f2c6cd5 into main Sep 18, 2026
4 checks passed
Daily-AC added a commit that referenced this pull request Sep 18, 2026
* docs: portal changelog entry for v0.11.0

Covers the merged work since v0.10.0: the portal-served WebFetch skill and
catalog-rendered discovery instructions (#106), grants of up to 24 hours with
the matching exec timeout and job allowance (#107), elevated commands under
Android bypass mode plus the approval card and exec-elevated rules (#108),
cancelling a persistent-session command (#109), controller-only hosts on
update (#105), the macOS Screen Recording remedy (#102), and the 17 rewritten
tool descriptions (#103).

Adding this file is what moves CurrentVersion to v0.11.0, since the version is
derived from the changelog file names.

PR #104 is still open, so its transport fix is left as a marked TODO in the
file rather than announced as shipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* changelog: add the slow-link transport fix to v0.11.0

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: 张以琳 <zhangyilin@thunder.com.cn>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

push over a slow link fails with "tls: bad record MAC" after a few minutes

1 participant