Repository navigation
Fix inbox byte-cap wipe; add request/reply IDs and dedupe - #43
Merged
Merged
Conversation
With the default config (InboxMaxBytes == 0, meaning 256 MiB) the write path gated on effectiveInboxMaxBytes(), but evictInboxOverflowByBytes compared against the raw config value 0 and removed every file. The first time a node's inbox passed 256 MiB, every stored message was deleted, including replies a `send-message --wait` was about to read. Any peer allowed to send could trigger it with ~4 max-size frames. The periodic pass had the same bug in reverse: with the default config it never applied the byte cap at all. - Both eviction paths use the effective cap. - A byte-cap eviction removes the oldest files until the inbox plus the incoming message is at or below 90% of the cap, so one pass makes room for many messages. A message larger than the cap (or than the cap minus writes already in flight) is rejected without evicting anything. - The cap is checked against a running byte total (seeded by one scan, re-seeded by the periodic pass) instead of listing and stat'ing the whole inbox on every incoming message. Other processes delete inbox files, so the directory is re-read before anything is evicted. - A full inbox now evicts oldest-first as InboxMaxBytes was documented to, instead of rejecting the new message. The two tests that pinned the reject-when-full quirk now assert the cap holds and new messages land. Regression test TestInboxByteCap_DefaultConfigEvictsOldestNotEverything reproduces the sweep finding (257 MiB of old files + 5 recent replies + one message, default config): on origin/main 0/5 recent replies survive; now all survive and ~27 old files are evicted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replies from a service land in the requester's inbox with nothing tying them to the request, so `send-message --wait` matches on sender and mtime and routinely returns another query's answer (4 of 5 back-to-back list-agents queries in the sweep). list-agents also delivers each reply twice, and the late copy is often the wrong answer. Wire (fully backward compatible): - Frame gains optional MessageID and ReplyTo. A frame with neither is written byte-for-byte as before. A frame with either travels as the new TypeTagged (10): [2-byte header_len][JSON header][4-byte inner_type][inner payload]. WriteFrame wraps, ReadFrame unwraps. IDs are 1-128 chars of [A-Za-z0-9._:-]; unknown header keys are ignored. - Receivers that predate TypeTagged answer "ERR UNKNOWN(10) ..." and store nothing. The new Client.Send waits for the ACK and, on that answer, re-sends the frame untagged on the same connection (SendResult.Tagged=false), so a new sender still reaches old peers. Receiver: - message_id / reply_to are recorded in inbox JSON and in message.received / file.received events, only when present. Governed and trace frames carry them through; they are correlation metadata only and are not covered by the governed signature. - An identical re-delivery (same sender, MessageID, ReplyTo, type, filename, bytes) of a stored frame that carried a MessageID is acknowledged with the original ACK plus " (duplicate)" and not stored again (DedupeWindow, default 10 min, bounded cache). Concurrent identical deliveries wait for the first; a failed first attempt is forgotten so its retry is processed normally. - Frames without a MessageID keep the old store-everything behaviour unless the operator sets DedupeContentWindow (off by default). README documents the fields, the wire layout and what a service must do (echo the request's message_id as ReplyTo). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
reserveInbox rescans the inbox before evicting and then trims to `target - size - pending`. pending still counts writers that were admitted and whose file is already on disk (fully or partly) but whose deferred inboxWriteDone has not run yet. Their bytes were counted twice, once in the scan and once in pending, and their files were eviction candidates. So a concurrent eviction could delete a message that had just been written and was about to be ACKed, even when both messages fit under the cap. With two ~20 MiB messages saved concurrently into a 64 MiB inbox holding 30 MiB, the first was lost in 3-5 of 60 trials (review finding DX-REV-2). The budget now records the destination path of every open reservation. Rescans leave those files out of both the eviction candidates and onDisk, so their bytes are counted once, in pending. inboxWriteDone releases the path. prepareInboxMessage picks the file name before reserving, so the reservation can name it. The periodic eviction pass sees the same filtered scan and trims the settled files so that they fit next to the pending bytes. Tests: a deterministic interleaving through saveInboxMessage (a test-only hook holds writer A between its write and its settle), no double counting on a rescan during a write, and the periodic pass skipping an in-flight file. All three fail without the fix. The reviewer's concurrent repro now loses 0/180 (was 3/60 and 5/60). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… frames under MaxFrameSize DX-REV-1: Client.Send re-sent a tagged frame untagged only when the ACK started with "ERR UNKNOWN(10) ". Only receivers at dataexchange v0.2.2 or later answer that way. v0.1.0 through v0.2.1, and the fac933e pseudo-version, have no default case in handleConn, so a type-10 frame is not stored and is still ACKed "ACK UNKNOWN(10) <n> bytes". Send reported that as delivered with Tagged == true. Every stable daemon (web4 v1.12.0 through v1.13.9) is in that group, so a request sent with a MessageID to one of them was silently lost. Send now treats both prefixes as "receiver predates TypeTagged, stored nothing" and re-sends untagged. A current receiver unwraps TypeTagged before acknowledging and names the inner type, so it can never produce either prefix. Verified against real receivers built from v0.2.1 (git archive) and v0.2.2: each now stores the tagged message exactly once, with Tagged == false. DX-REV-3: WriteFrame wrapped a frame in TypeTagged without checking the result against MaxFrameSize. A payload within the header size (up to 293 bytes) of the cap was valid untagged but too large tagged, so the receiver dropped the connection and Send got a transport error with no fallback. WriteFrame now returns ErrTaggedFrameTooLarge before writing anything. Send handles it by sending the frame untagged (Tagged == false). The new MaxTaggedOverhead constant (293) documents the worst-case overhead. README, CHANGELOG and doc comments now describe both old-receiver answers and the size rule. Tests: the old-receiver emulator covers the v0.2.1-and-older generation. There is a table test of the ACKs that trigger the fallback, and a check that a current receiver keeps tags for every type. Other tests cover WriteFrame at and one byte past the cap, the exact-fit write, and Send to a length-checking receiver. An end-to-end test runs a real Service in a child process with PILOT_DATAEXCHANGE_MAX_FRAME=65536 (the reviewer's repro). Each regression test fails without its fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
TeoSlayer
enabled auto-merge (squash)
September 23, 2026 23:57
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 three dataexchange problems from the Pilot reliability sweep: the default inbox byte cap deletes the whole inbox, replies can't be matched to their requests, and duplicate deliveries are stored twice. The branch has 4 commits over
c1f3d84and merges cleanly withmainat00f5945.Problem (sweep evidence)
1. With the default config, the inbox byte cap deletes the entire inbox (sweep item 10, HYG-01)
service.go:803-809checks the cap witheffectiveInboxMaxBytes(). With the defaultInboxMaxBytes == 0that is 256 MiB, so the check fires and callsevictInboxOverflowByBytes.evictInboxOverflowByBytes(service.go:943-982, lines:968and:974) compares against the raws.cfg.InboxMaxBytes, which is 0. It removes every file.:899-901) had the same bug in reverse: with the default config it never applied the byte cap at all.cmd/daemon/main.go:375-377buildsServiceConfigwith onlyIncludeBase64set, so every default install takes this path.1cd6f58(Harden dataexchange against resource-exhaustion DoS #25). It is in v0.2.1 (shipped in web4 v1.13.9), v0.2.2 andmain.inbox eviction (bytes) evicted=262 total_bytes_after=0 max_bytes=0, and 0 of 5 recent replies survived.DefaultMaxFrameSizeis 64 MiB, so any peer allowed to send can trigger the wipe with about 4 large messages.inboxTotalBytes, a fullReadDirplus a stat of every file (service.go:804).2.
send-message --waitreturns another query's reply, with exit 0 (sweep item 2, ONB-01 / HYG-02 / NET-02)cmd/pilotctl/main.go:4725sets the cutoff one second before the send.waitForInboxReply(main.go:6616-6657) returns the first file in directory order from the right sender. Nothing in the frame ties a reply to its request.query.jsandsearch.jsuse the same path.3. list-agents answers every request twice (sweep item 11, ONB-05)
inbox message savedtwice (service.go:877).What changed
7e88ecdfix(inbox): stop the default byte cap from deleting the whole inboxTestInboxByteCap_DefaultConfigEvictsOldestNotEverything: onmain, 0 of 5 recent replies survive. With the fix, all 5 survive and about 27 old files are evicted.e746029feat: optional request/reply IDs on frames and receiver-side dedupeFrame.MessageIDandFrame.ReplyTo. A frame with either one travels as the newTypeTagged(10):[2-byte header_len][JSON header][4-byte inner_type][inner payload].[A-Za-z0-9._:-]. Unknown header keys are ignored.WriteFramewraps the frame andReadFrameunwraps it. Helpers:NewMessageIDandValidMessageID.message_id/reply_toin the inbox JSON and inmessage.received/file.receivedevents, only when they are present. Governed and trace frames carry them through. They are correlation metadata only and are not covered by the governed signature.Client.Sendwaits for the ACK. If the receiver predatesTypeTagged, Send re-sends the frame untagged on the same connection and returnsSendResult.Tagged == false.MessageID, an identical re-delivery gets the original ACK plus(duplicate)and is not stored again. The window isServiceConfig.DedupeWindow(default 10 min) and the cache is bounded. Concurrent identical deliveries wait for the first one. If the first attempt fails, it is forgotten so a retry is processed normally.DedupeContentWindow(off by default) extends dedupe to frames without aMessageID.message_idasReplyTo.0b8e551fix(inbox): never evict a message another connection is still writing (review finding DX-REV-2)e457424fix(tagged): fall back for every pre-TypeTaggedreceiver; keep tagged frames underMaxFrameSizeACK UNKNOWN(10) <n> bytesand store nothing. Before this commit, Send reported that as delivered withTagged == true. Send now treats bothERR UNKNOWN(10)(v0.2.2 and later) andACK UNKNOWN(10)as "receiver predates TypeTagged" and re-sends untagged. A current receiver unwraps the frame before it ACKs and names the inner type, so it never produces either prefix.WriteFramenow returnsErrTaggedFrameTooLargebefore writing anything, andSendhandles it by sending the frame untagged. The worst-case header size is exported asMaxTaggedOverhead(293).Review outcome
An independent review found 3 problems in the first two commits. All 3 are fixed on this branch:
e4574240b8e551e457424Compatibility
Wire: untagged frames are byte-for-byte unchanged. A frame is tagged only when the sender sets
MessageIDorReplyTo. A tagged frame is refused only when it would exceedMaxFrameSize.New sender to an old receiver:
Client.Sendfalls back to an untagged frame (Tagged == false). A rawWriteFrameof a tagged frame gets no fallback.Old sender to a new receiver: unchanged.
Go API: additive only. New exported names:
Frame.MessageID,Frame.ReplyTo,TypeTaggedClient.SendandSendResult{Ack, Tagged, Duplicate}NewMessageID,ValidMessageIDServiceConfig.DedupeWindow,ServiceConfig.DedupeContentWindowErrTaggedFrameTooLarge,MaxTaggedOverheadThe new unexported helpers
reserveInbox(dir, path, size, max)andinboxWriteDone(path, size, written)are internal.Behavior:
InboxMaxByteswas documented to do. Before, the new message was rejected. The two tests that pinned the old behavior now check that the cap holds and that new messages land.MessageIDare still all stored unless the operator setsDedupeContentWindow.Cross-version run over TCP
The sender was built from this branch. It ran the same send-and-ACK path as
Client.Send, once withMessageID=req-123and once without an ID. Receivers were realServiceinstances (harness inscratch/dx-rev-xver). Rerun on 2026-09-24:git archive)ACK TEXT 12 bytes,Tagged=falsemessage_idACK TEXT 11 bytes, stored onceACK TEXT 12 bytes,Tagged=falsemessage_idACK TEXT 11 bytes, stored onceACK TEXT 12 bytes,Tagged=falsemessage_idACK TEXT 11 bytes, stored onceERR TEXT save failed: unsigned legacy frame rejected by governed receiver,Tagged=falseACK TEXT 12 bytes,Tagged=true"message_id":"req-123"ACK TEXT 11 bytes, stored onceTagged=truee457424, v0.2.1 answered the send with an ID asACK UNKNOWN(10) ...and stored nothing, but it was reported as delivered withTagged=true.Tests
All commands ran with
GOWORK=offon HEADe457424:gofmt -l .: cleango vet ./...andgo vet -tags no_dataexchange ./...: cleango test -race -covermode=atomic -coverprofile ./...(the CI command): ok, 77.0% coveragego test -tags no_dataexchange ./...: okgo test -race -count=5 ./: okFuzzTaggedFrameRoundTripandFuzzTaggedFrameRead: passstaticcheck ./...: no new findings compared with the base. The same 3 findings exist before and after: ST1019 inexamples/main.goand two U1000 in older test files.0b8e551on its own, exported withgit archive: vet and race tests passThe merge result with
main00f5945(common v0.5.14) also passesgo vet, the CI race and coverage command (77.0%),go test -tags no_dataexchange ./...andgofmt.Each new test below was red-checked: its fix was disabled in a scratch copy and the test failed.
zz_inbox_budget_test.go:TestInboxByteCap_InflightWriteNotEvicted: a deterministic interleaving through theafterInboxWritetest hookTestInboxByteCap_RescanDoesNotDoubleCountInflightTestEvictInboxOverflow_SkipsInflightWritezz_correlation_test.go:genDropUnknown(v0.2.1 and older),genRejectUnknownandgenRejectUnknownGoverned.TestSend_FallsBackForOldReceiverchecks that each generation stores the message exactly once.TestSend_UntaggedFrameNeverRetriedruns once per generation.TestReceiverPredatesTagged(a table of ACKs) andTestSend_CurrentReceiverKeepsTagsForEveryType.TestSend_TaggedFrameSizeEndToEndSmallCapre-runs itself in a child process withPILOT_DATAEXCHANGE_MAX_FRAME=65536against a realService. It checks that the at-cap frame is stored untagged, that the largest taggable frame (cap − 293) is stored tagged, and that a rawWriteFramefails without writing.zz_tagged_test.go:TestMaxTaggedOverhead(= 293)TestWriteFrame_TaggedFrameOverMaxFrameSize: at the cap and one byte over, it returns an error with 0 bytes written. An exact fit writes 8 +MaxFrameSizebytes.TestSend_TaggedFrameTooLargeSentUntagged: a 64 MiB payload to a receiver that checks the length and discards the payloadDeploy notes
--waitrequest-ID matching (sweep batch 3b). It must depend on a dataexchange release that includese457424. Otherwise, requests sent with aMessageIDto any stable peer (web4 v1.12.0 through v1.13.9, dataexchange v0.2.1 or older) are dropped while reported as delivered.Client.Sendand treatSendResult.Tagged == falseas "match the reply on the sender alone". That covers old receivers and frames within 293 bytes of the max frame size. A rawWriteFrameof a tagged frame gets no fallback.🤖 Generated with Claude Code