From bea89f195323234c62f2188d271e954a4d4f2b0e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 20 Aug 2026 14:59:53 -0400 Subject: [PATCH] Close the oversized connection with FIN, not RST, so the reason survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third and final layer of this one, and the actual mechanism. The daemon capped the line, wrote "request too large", and then destroyed the socket. Destroying a socket that still has inbound data pending makes the OS send an RST — and an RST discards the PEER's unread receive buffer, carrying off the refusal we had just written. On Linux the reply usually beat the reset; on macOS and Windows it did not, so the caller saw an unexplained hangup rather than a reason. `end()` sends FIN instead, which leaves the peer's buffer intact. Reads keep draining (the `refused` flag makes them cheap, and nothing accumulates), with an unref'd five-second timer as the backstop against a peer that never closes. Three commits for one test, and the layers were genuinely stacked: the test treated its own EPIPE as fatal, then wrote a megabyte without backpressure, and underneath both the daemon was resetting the connection instead of closing it. Only the last is a defect in shipped behaviour — a caller that sends too much has always been refused, but on two of three platforms it was never told why. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN --- js/packages/cli/src/agent.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index c4731f7a..647a9710 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -907,17 +907,22 @@ export class AgentDaemon { if (refused) return; // already answered; stop buffering this peer if (buffer.length + chunk.length > MAX_LINE_BYTES) { refused = true; - // `end(payload, cb)` and THEN destroy, not `write` followed by an - // immediate `destroy`. destroy() tears the socket down at once and - // discards whatever is still buffered: on Linux the write usually - // reaches the kernel first and the peer sees the refusal, on macOS it - // does not and the peer sees an unexplained hangup. The callback - // fires once the bytes are flushed, so the reason is delivered and - // the connection still closes rather than being held open by a peer - // that keeps sending. - socket.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n", () => { - socket.destroy(); - }); + // end(), and specifically NOT destroy(). Destroying a socket that + // still has inbound data pending makes the OS send an RST, and an RST + // discards the PEER's unread receive buffer -- carrying off the very + // refusal we just wrote. On Linux the reply usually beat the reset; + // on macOS and Windows it did not, and the caller saw an unexplained + // hangup instead of "request too large". end() sends FIN, which + // leaves the peer's buffer intact. + // + // Reads continue (the `refused` flag makes them cheap), so the kernel + // buffer keeps draining and nothing grows. The timer is the backstop + // against a peer that never closes; unref'd so it cannot by itself + // keep the process alive. + socket.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n"); + const abandon = setTimeout(() => socket.destroy(), 5_000); + abandon.unref(); + socket.once("close", () => clearTimeout(abandon)); return; } buffer += chunk.toString("utf8");