fix(transport): acknowledge down-poll chunks so a slow push cannot lose bytes - #104
Merged
Merged
Conversation
…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
force-pushed
the
fix/57-push-bad-record-mac
branch
from
September 18, 2026 10:01
03bfb54 to
dbc20fe
Compare
…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>
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #57.
Root cause
The peer-to-peer TLS stream rides on the relay's HTTP long-poll transport.
/h/downdequeued 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 astls: bad record MAC.internal/httpconnmade the loss silent. InRead: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:
sideQueue.drainappended every queued chunk into one response. A reader that fell behind while the writer kept going got handed megabytes in a single response body.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:
200carriesX-Wanctl-Down-Seq. The next poll reports the last fully received sequence inack=.sideQueue.unackeduntil 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.httpconnno 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./h/downanswer withX-Wanctl-Down-Ack— the data-bearing200, the empty204, and the400/404/410refusals, though not the401, 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 whatmaindoes today, so this is never worse than before the PR; it only ever retries more.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./h/closeused 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:reapHTTPscans 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)./h/upopen on it, so an upload after the close could still queue bytes nobody would read./h/upnow refuses a closed session with410, including an empty body, andpushandclosetake the same lock, so once the close has returned no later push can succeed.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
maxDrainBytesis 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):
TestDownPollThroughputAtRTTkeeps that measurable; it is skipped unlessWANCTL_TRANSPORT_THROUGHPUT=1is 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 throughhttpSessionConn, 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.gobuilds an in-process relay, controller and agent and runs a real TLS stream over the HTTP transport. It carries realnet/httpon both ends with only the TCP listen and dial replaced bynet.Pipe, so it needs no listening socket.On
main,TestPushSurvivesTruncatedDownPoll(20 MB, two responses cut short) fails with the issue's exact error: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:
TestOverlappingDownPollsOnTheWireDoNotLoseAChunkTestOverlappingPollsCannotTakeDifferentChunksTestAbandonedPollKeepsItsChunkForTheNextPollTestDownPollRetriesOnlyAgainstAnAcknowledgingRelayTestGracefulCloseStaysDrainableTestGracefulCloseWaitsForBothDirectionsTestUploadsAfterGracefulCloseAreRejectedTestUploadRacingGracefulCloseIsAllOrNothingTestRetirementWaitsForAPollHoldingAChunkTestGracefulCloseRetiresASessionAReaderWasParkedOnTestRevokedCredentialTearsDownImmediatelyTestDrainCapSplitsRatherThanOvershootsAlso covered:
TestSideQueueResendsUntilAcked,TestDrainCoalescingIsBounded, andTestDownPollWithoutAckIsFireAndForgetfor 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.
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