Skip to content

node send drops the head of a multi-chunk message and reports it delivered #277

Description

@scgopi

node send drops the head of a multi-chunk message, keeps the tail, and reports delivered. The receiving loop gets a message that begins mid-sentence and reads as complete, so neither side has any signal that anything was lost.

This is the same silent-loss class that d3f7c9a ("Type large messages into a session in PTY-queue-sized pieces", 0.1.29) fixed for the oversized single write. That fix capped one write at maxSendChunkBytes = 2048 and split above it. The split path itself now loses its first chunk.

Measured

Four node send calls from one loop to another (TripleAgentSpawnIssueTriage), local project, both Claude Code backends. Sizes are what arrived in the receiver's session transcript.

Sent Chunks Arrived Lost Result
1944 B 1 1944 B 0 ✅ intact
1985 B 1 1985 B 0 ✅ intact
~2600 B 2 646 B ~75% ❌ head clipped
~2400 B 2 1304 B ~46% ❌ head clipped
~2600 B 2 61 B ~98% ❌ head clipped

Every send returned delivered. Every single-chunk message arrived whole. Every multi-chunk message arrived headless.

Examples of what the receiver actually saw, each the start of a message as delivered:

der does not go fix the truncation and think they fixed this.
R does not regress it, and file a follow-up issue for it so it is not lost. TWO, finding 6
daemon mid-PTY-spawn on a loaded codespace. The chain is: ensure exits 1, startRemote, run

Why this is worse than dropping the message

A clipped message looks like an arrived message. The receiver has no way to know a head is missing — it reads as a message that happens to start abruptly. In the run where this was found, two code-review findings reached the receiving loop as bare line numbers with no defect description, and a third arrived as nothing but its closing clause. The receiving loop acted on what it had and reported back as though it had the whole thing.

It also defeats diagnosis. The [graphcode] <Sender>: prefix lives in the first chunk, so a clipped message loses its own attribution. Searching a transcript for [graphcode] — the obvious way to audit delivery — returns only the messages that arrived intact, and the damaged ones are invisible to exactly the query you would use to find them. This bug hides its own evidence.

Where

  • ZmxSessionLauncher.swift:265 send(_:to:projectPath:) — the local path: for (index, chunk) in messageChunks(text).enumerated(), one PTYProcessSession per chunk with interChunkDelay (150 ms) between, then a separate submit keystroke.
  • ZmxSessionLauncher.swift:55 messageChunks(_:limit:) — the splitter. Cuts on character boundaries; the splitting logic itself looks correct on inspection.
  • ZmxSessionLauncher.swift:46 maxSendChunkBytes = 2048.
  • ZmxSessionLauncher.swift:1446 remoteSendInvocation — the remote path chains the same chunks into one ssh round-trip with && sleep 0.15 && between writes. Not tested here; a remote loop may or may not share the fault.

The chunk loop returns false if any zmx send fails, so a chunk that errored would surface. These reported success — consistent with the write being accepted and the text never reaching the composer, which is precisely the failure mode maxSendChunkBytes's own doc comment describes for a single oversized write.

Repro

  1. Two loops in a local project.
  2. From one: graphcode node send <project> <target-id> <a message over 2048 bytes>.
  3. It prints delivered.
  4. Read the target's session transcript. The message is present, beginning mid-sentence, with the leading ~2048 bytes and the [graphcode] <Sender>: prefix gone.

Under 2048 bytes the same send arrives intact, which is the control.

Suggested direction

Not prescribing a fix, but three things seem worth separating:

  1. Find why the first chunk specifically is lost. The later chunks land, so the transport works; something about the first write into a composer that is about to receive more, or the state of the session when the first write arrives, is different.
  2. Make a partial delivery detectable. Right now delivered means "every zmx send exited 0", which has been shown twice to be compatible with the text never arriving. A receipt the receiver can verify — a byte count, or the sender's own prefix checked for on the far side — would turn a silent corruption into a reportable failure.
  3. Consider whether the prefix should ride the last chunk rather than the first, so that attribution survives a head loss and the damaged messages are findable.

Impact

Loop-to-loop coordination is unreliable in a way nothing reports, and the damage is invisible to the audit you would naturally run. Any orchestration that sends detailed instructions between loops — review findings, merge criteria, handoffs — can silently deliver a fraction of them. Found while orchestrating #272 and #273, where it corrupted the review-findings handoff twice before it was noticed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions