From 2cf51df4f030bd868f248dc8f4d051c350ad4f86 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:21:08 +0000 Subject: [PATCH 1/6] fix(claude): bound unfinished picker uploads and drain refusals Preserve bounded runtime ownership and failure behavior, with focused regression coverage and the owning architecture contract. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/intercept/picker-listener.ts | 92 ++++++++++++++++++- structure/clients/claude-desktop.md | 17 ++-- .../claude-picker-upload.test.ts | 70 +++++++++++++- 3 files changed, 164 insertions(+), 15 deletions(-) diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index 23e8f5e1c8d..cebc896d60d 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -34,6 +34,10 @@ export interface PickerListenerOptions { maxEncodedBytes?: number; /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */ maxActiveUpstreams?: number; + /** Test seam: maximum time for a client upload to finish. */ + uploadDeadlineMs?: number; + /** Test seam: maximum time without upload progress. */ + uploadIdleTimeoutMs?: number; log?: (line: string) => void; } export interface PickerListenerHandle { port: number; close(): Promise } @@ -51,6 +55,9 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256; export const PICKER_MAX_CONCURRENT_STREAMS = 100; /** A connection that has not sent a complete ClientHello by then is dropped. */ const CLIENT_HELLO_TIMEOUT_MS = 10_000; +const PICKER_UPLOAD_DEADLINE_MS = 60_000; +const PICKER_UPLOAD_IDLE_TIMEOUT_MS = 15_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; const HOP_HEADERS = new Set([ "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", @@ -152,6 +159,13 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" }; const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES; const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS; + const uploadDeadlineMs = options.uploadDeadlineMs ?? PICKER_UPLOAD_DEADLINE_MS; + const uploadIdleTimeoutMs = options.uploadIdleTimeoutMs ?? PICKER_UPLOAD_IDLE_TIMEOUT_MS; + for (const [name, value] of [["uploadDeadlineMs", uploadDeadlineMs], ["uploadIdleTimeoutMs", uploadIdleTimeoutMs]] as const) { + if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new RangeError(`${name} must be a positive finite timer duration`); + } + } let activeUpstreams = 0; const upgrades = new Set(); const spliced = new Set(); @@ -209,10 +223,56 @@ export async function startPickerListener(options: PickerListenerOptions): Promi const refuse = (status: 400 | 503) => { // Do not leave a refused, still-uploading stream behind after delivering its empty reply. if (uploading && !req.complete) { - req.once("error", () => res.destroy()); - responseWritable.once("finish", () => closeInput(true)); + if (h2) { + responseWritable.once("finish", () => closeInput(true)); + } else { + let settled = false; + let replyFinished = false; + let inputFinished = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + const cleanup = () => { + if (settled) return; + settled = true; + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + req.off("data", onProgress); + req.off("end", onBodyEnd); + req.off("close", onBodyEnd); + req.off("error", onBodyEnd); + responseWritable.off("finish", onReplyFinished); + }; + const closeOnTimeout = () => { + cleanup(); + closeInput(true); + }; + const onProgress = () => { + if (settled || !deadlineTimer) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(closeOnTimeout, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; + const onBodyEnd = () => { + inputFinished = true; + if (replyFinished) { cleanup(); closeInput(true); } + }; + const onReplyFinished = () => { + if (settled) return; + replyFinished = true; + if (inputFinished || req.complete) { cleanup(); closeInput(true); return; } + deadlineTimer = setTimeout(closeOnTimeout, uploadDeadlineMs); + deadlineTimer.unref?.(); + onProgress(); + }; + req.on("data", onProgress); + req.once("end", onBodyEnd); + req.once("close", onBodyEnd); + req.once("error", onBodyEnd); + responseWritable.once("finish", onReplyFinished); + req.resume(); + } } - res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) }); + res.writeHead(status, { "Content-Length": "0" }); res.end(); options.log?.(`picker request refused ${status}`); }; @@ -329,9 +389,25 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose); if (uploading) { let uploadFinished = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let idleTimer: NodeJS.Timeout | undefined; + let expireUpload: () => void = () => {}; + const clearUploadTimers = () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + deadlineTimer = undefined; + idleTimer = undefined; + }; + const onUploadProgress = () => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(expireUpload, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; const finishUpload = () => { if (uploadFinished) return; uploadFinished = true; + clearUploadTimers(); + req.off("data", onUploadProgress); req.off("end", finishUpload); req.off("close", finishUpload); upReq.off("close", onUpstreamClose); @@ -349,16 +425,26 @@ export async function startPickerListener(options: PickerListenerOptions): Promi upReq.destroy(); } }; + expireUpload = () => { + if (uploadFinished) return; + clientGone = true; + stopUnfinishedUpload(); + release(); + }; const onResponseFinish = () => stopUnfinishedUpload(true); const onUpstreamClose = () => { if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload(); }; req.once("end", finishUpload); req.once("close", finishUpload); + req.on("data", onUploadProgress); upReq.once("close", onUpstreamClose); responseWritable.once("finish", onResponseFinish); res.once("close", stopUnfinishedUpload); if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload); + deadlineTimer = setTimeout(expireUpload, uploadDeadlineMs); + deadlineTimer.unref?.(); + onUploadProgress(); } req.pipe(upReq); }; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 75c69a9dcb3..e6ca65c2fa2 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -218,20 +218,21 @@ natively, counting name + value + 32 bytes per field and rejecting an oversized The relay retains its 256-request aggregate ceiling across both HTTP versions and all sessions. Upload framing, not the method, determines whether input cleanup is needed: HTTP/2 headers without END_STREAM, or HTTP/1.1 transfer encoding or a positive Content-Length. -Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the +Rejected unfinished uploads preserve the empty reply before cleanup; HTTP/2 uses the underlying stream's finish event rather than the compatibility response's finish event, which -waits for both stream halves to close. HTTP/1.1 refusals advertise `Connection: close` only while input is unfinished. +waits for both stream halves to close. Early replies stop relaying unfinished input only after the downstream writable finishes or the response closes; upstream request closure alone does not prove a buffered reply was delivered. After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113 §8.1), including a complete generated 502. Without a complete downstream response, cleanup uses `CANCEL` for a premature downstream close or an upstream close without a reply. -HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close` only while input is unfinished; after the writable finishes, -the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket -gracefully so queued response bytes flush. Errors and premature downstream closes still destroy -the request. Draining adds no upload deadline or byte cap and does not wait for request-body -completion before ending the response side. Body completion removes the input-cleanup listeners, -leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket. +An early HTTP/1.1 refusal preserves its reply, then unpipes upstream and drains unfinished input +until body completion or the absolute or idle deadline, then closes gracefully. Errors and premature downstream +closes still destroy the request. The 60-second absolute and 15-second idle-progress deadlines +close client input and upstream work and release the aggregate slot. Body +completion removes the input-cleanup listeners and both timers, leaving long-lived responses and +SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on +generated 502s and can reuse the same client socket. `tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation, failure and shutdown cleanup, and healthy sibling streams. diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 18c4ea0eec5..b411a046662 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -70,13 +70,15 @@ function h2Result(stream: ClientHttp2Stream) { } function h1Result(req: ClientRequest) { const result = Promise.withResolvers<{ status: number; text: string }>(); + let responseStarted = false; req.once("response", res => { + responseStarted = true; const chunks: Buffer[] = []; res.on("data", chunk => chunks.push(Buffer.from(chunk))); res.once("end", () => result.resolve({ status: res.statusCode!, text: Buffer.concat(chunks).toString() })); res.once("error", result.reject); }); - req.once("error", result.reject); + req.once("error", error => { if (!responseStarted) result.reject(error); }); void result.promise.catch(() => {}); return result.promise; } @@ -89,7 +91,7 @@ function observeClose(stream: ClientRequest | ClientHttp2Stream | ClientHttp2Ses function observeSocketClose(req: ClientRequest) { const closed = Promise.withResolvers(); req.once("socket", socket => { socket.once("close", () => closed.resolve()); }); - req.on("error", () => req.destroy()); + req.on("error", () => {}); return closed.promise; } function normalH2(session: ClientHttp2Session, path = "/ok") { @@ -109,7 +111,7 @@ for (const protocol of ["h1", "h2"] as const) { if (req.url === "/held") { req.once("close", () => left.resolve()); held.resolve(); } else { relayed++; res.end("ordinary"); } - }, { maxActiveUpstreams: 1 }); + }, { maxActiveUpstreams: 1, uploadIdleTimeoutMs: 250 }); try { const session = f.h2(); let blocker: ClientHttp2Stream | undefined; @@ -131,7 +133,7 @@ for (const protocol of ["h1", "h2"] as const) { } if (input === "buffered") upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); expect(await wait(result)).toEqual({ status, text: "" }); - if (protocol === "h1") expect(connection).toBe("close"); + if (protocol === "h1") expect(connection).not.toBe("close"); await wait(closed); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(relayed).toBe(0); @@ -251,6 +253,66 @@ for (const protocol of ["h1", "h2"] as const) { }); } +bounded("unfinished uploads share the aggregate budget across HTTP/2 sessions and recover on idle timeout", async wait => { + const arrived = Promise.withResolvers(); + let requests = 0; + const f = await fixture((req, res) => { + requests++; + if (req.url === "/stall") { + req.on("data", () => {}); + req.once("end", () => res.end("uploaded")); + arrived.resolve(); + } else res.end("ordinary"); + }, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 }); + try { + const firstSession = f.h2(); + const secondSession = f.h2(); + const upload = firstSession.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); + const uploadClosed = observeClose(upload); + upload.write("partial"); + await wait(arrived.promise); + const blocked = await wait(normalH2(secondSession, "/blocked")); + expect(blocked.status).toBe(503); + await wait(uploadClosed); + expect((await wait(normalH2(secondSession, "/ok"))).status).toBe(200); + expect(requests).toBe(2); + } finally { await wait(f.close()); } +}); + +bounded("absolute upload deadline expires despite continued progress", async wait => { + const arrived = Promise.withResolvers(); + const f = await fixture((req, res) => { + req.on("data", () => {}); + arrived.resolve(); + req.once("end", () => res.end("uploaded")); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + let progress: NodeJS.Timeout | undefined; + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/trickle", ":authority": "claude.ai" }); + const closed = observeClose(upload); + progress = setInterval(() => upload.write("x"), 80); + upload.write("start"); + await wait(arrived.promise); + await wait(closed); + expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); + } finally { if (progress) clearInterval(progress); await wait(f.close()); } +}); + +bounded("completed uploads are not timed out while a long response is pending", async wait => { + const f = await fixture((req, res) => { + req.resume(); + req.once("end", () => setTimeout(() => res.end("slow response"), 120)); + }, { uploadDeadlineMs: 50, uploadIdleTimeoutMs: 25 }); + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/complete", ":authority": "claude.ai" }); + const result = h2Result(upload); + upload.end("complete"); + expect(await wait(result)).toEqual({ status: 200, text: "slow response" }); + } finally { await wait(f.close()); } +}); + for (const protocol of ["h1", "h2"] as const) { bounded(`${protocol} backpressure preserves every byte of a large early reply`, async wait => { // Exceed the h2 receive window and the h1 socket buffers, including a non-text tail. From 6832dfdad52d329977c04db739772a8472a0dbb1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:35:19 +0000 Subject: [PATCH 2/6] fix(claude): keep-alive drained refusal sockets and suspend idle expiry under upstream backpressure Co-Authored-By: Epinephrine --- src/claude/intercept/picker-listener.ts | 27 ++++++++--- structure/clients/claude-desktop.md | 8 ++-- .../claude-picker-upload.test.ts | 47 +++++++++++++++++++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index cebc896d60d..7039cbd319d 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -238,8 +238,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (idleTimer) clearTimeout(idleTimer); req.off("data", onProgress); req.off("end", onBodyEnd); - req.off("close", onBodyEnd); - req.off("error", onBodyEnd); + req.off("close", onBodyGone); + req.off("error", onBodyGone); responseWritable.off("finish", onReplyFinished); }; const closeOnTimeout = () => { @@ -252,22 +252,29 @@ export async function startPickerListener(options: PickerListenerOptions): Promi idleTimer = setTimeout(closeOnTimeout, uploadIdleTimeoutMs); idleTimer.unref?.(); }; + // A body that finishes cleanly leaves a keep-alive socket the client may + // reuse; only a deadline or a premature close ends it. const onBodyEnd = () => { + inputFinished = true; + if (replyFinished) cleanup(); + }; + const onBodyGone = () => { + if (inputFinished) return; inputFinished = true; if (replyFinished) { cleanup(); closeInput(true); } }; const onReplyFinished = () => { if (settled) return; replyFinished = true; - if (inputFinished || req.complete) { cleanup(); closeInput(true); return; } + if (inputFinished || req.complete) { cleanup(); return; } deadlineTimer = setTimeout(closeOnTimeout, uploadDeadlineMs); deadlineTimer.unref?.(); onProgress(); }; req.on("data", onProgress); req.once("end", onBodyEnd); - req.once("close", onBodyEnd); - req.once("error", onBodyEnd); + req.once("close", onBodyGone); + req.once("error", onBodyGone); responseWritable.once("finish", onReplyFinished); req.resume(); } @@ -398,9 +405,17 @@ export async function startPickerListener(options: PickerListenerOptions): Promi deadlineTimer = undefined; idleTimer = undefined; }; + const onUploadIdle = () => { + idleTimer = undefined; + // A full upstream buffer pauses the pipe: 'data' stops even while the client + // keeps sending, so backpressure is not client idleness. Recheck each interval + // until the upstream drains; the absolute deadline still bounds the upload. + if (upReq.writableNeedDrain) { onUploadProgress(); return; } + expireUpload(); + }; const onUploadProgress = () => { if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(expireUpload, uploadIdleTimeoutMs); + idleTimer = setTimeout(onUploadIdle, uploadIdleTimeoutMs); idleTimer.unref?.(); }; const finishUpload = () => { diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index e6ca65c2fa2..07b5b421959 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -226,10 +226,12 @@ response closes; upstream request closure alone does not prove a buffered reply After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113 §8.1), including a complete generated 502. Without a complete downstream response, cleanup uses `CANCEL` for a premature downstream close or an upstream close without a reply. -An early HTTP/1.1 refusal preserves its reply, then unpipes upstream and drains unfinished input -until body completion or the absolute or idle deadline, then closes gracefully. Errors and premature downstream +An early HTTP/1.1 refusal preserves its reply, then drains unfinished input: a body that +completes leaves the keep-alive socket reusable, while only a body that stalls past the absolute +or idle deadline, or a premature close, ends it. Errors and premature downstream closes still destroy the request. The 60-second absolute and 15-second idle-progress deadlines -close client input and upstream work and release the aggregate slot. Body +close client input and upstream work and release the aggregate slot; upstream backpressure only +suspends the idle clock, never the absolute deadline. Body completion removes the input-cleanup listeners and both timers, leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket. diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index b411a046662..88da2a16868 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -147,6 +147,29 @@ for (const protocol of ["h1", "h2"] as const) { } } +bounded("h1 refusal keeps the same connection alive once the upload drains", async wait => { + const body = "complete"; + const f = await fixture((req, res) => { + req.resume(); res.end("ordinary"); + }); + try { + // A refused upload that finishes its body must not have its socket closed: + // the keep-alive advertisement in the empty reply stays honest and the next + // request on that socket is relayed instead of dropped. + const upload = f.h1("//[", "POST", { "Content-Length": String(body.length) }); + const firstSocket = Promise.withResolvers(); + upload.once("socket", firstSocket.resolve); + const result = h1Result(upload); upload.end(body); + expect(await wait(result)).toEqual({ status: 400, text: "" }); + const next = f.h1("/next"); + const nextSocket = Promise.withResolvers(); + next.once("socket", nextSocket.resolve); + const nextResult = h1Result(next); next.end(); + expect(await wait(nextResult)).toEqual({ status: 200, text: "ordinary" }); + expect(await wait(nextSocket.promise)).toBe(await wait(firstSocket.promise)); + } finally { await wait(f.close()); } +}); + bounded("h1 completed upload keeps the same connection alive after an upstream failure", async wait => { const body = "complete"; let received = ""; @@ -299,6 +322,30 @@ bounded("absolute upload deadline expires despite continued progress", async wai } finally { if (progress) clearInterval(progress); await wait(f.close()); } }); +bounded("upstream backpressure does not expire an active upload as idle", async wait => { + const arrived = Promise.withResolvers(); + const f = await fixture((req, res) => { + // Never consume the body: the upstream read buffer stays full, so the relay + // pipe pauses client input even though the client has more bytes to send. + if (req.url === "/stall") arrived.resolve(); else res.end("ordinary"); + }, { uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 250 }); + try { + const session = f.h2(); + const upload = session.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); + const closed = observeClose(upload); + let closedEarly = false; + upload.once("close", () => { closedEarly = true; }); + upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); + await wait(arrived.promise); + // Past the idle interval the upload must still be open: the client is stalled + // by upstream backpressure, not idle. The absolute deadline still ends it. + await new Promise(resolve => setTimeout(resolve, 600)); + expect(closedEarly).toBe(false); + await wait(closed); + expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); + } finally { await wait(f.close()); } +}); + bounded("completed uploads are not timed out while a long response is pending", async wait => { const f = await fixture((req, res) => { req.resume(); From ab9c5ccb2a6ad5f0e6060c8482547d5a6a1934c9 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:11:25 +0000 Subject: [PATCH 3/6] fix(claude): close unfinished HTTP/1 refusal connections --- src/claude/intercept/picker-listener.ts | 2 +- structure/clients/claude-desktop.md | 6 +++--- tests/claude-integration/claude-picker-upload.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index 7039cbd319d..ed546b32d97 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -279,7 +279,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi req.resume(); } } - res.writeHead(status, { "Content-Length": "0" }); + res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) }); res.end(); options.log?.(`picker request refused ${status}`); }; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 07b5b421959..90d41c43b8d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -226,9 +226,9 @@ response closes; upstream request closure alone does not prove a buffered reply After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113 §8.1), including a complete generated 502. Without a complete downstream response, cleanup uses `CANCEL` for a premature downstream close or an upstream close without a reply. -An early HTTP/1.1 refusal preserves its reply, then drains unfinished input: a body that -completes leaves the keep-alive socket reusable, while only a body that stalls past the absolute -or idle deadline, or a premature close, ends it. Errors and premature downstream +An early HTTP/1.1 refusal advertises `Connection: close` when input is unfinished at response +headers, preserves its reply, then drains input before graceful close. Requests already complete +retain keep-alive; completing a body after an early refusal does not reverse its close header. Errors and premature downstream closes still destroy the request. The 60-second absolute and 15-second idle-progress deadlines close client input and upstream work and release the aggregate slot; upstream backpressure only suspends the idle clock, never the absolute deadline. Body diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 88da2a16868..9dbf21e35eb 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -133,7 +133,7 @@ for (const protocol of ["h1", "h2"] as const) { } if (input === "buffered") upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); expect(await wait(result)).toEqual({ status, text: "" }); - if (protocol === "h1") expect(connection).not.toBe("close"); + if (protocol === "h1") expect(connection).toBe("close"); await wait(closed); if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR); expect(relayed).toBe(0); @@ -147,7 +147,7 @@ for (const protocol of ["h1", "h2"] as const) { } } -bounded("h1 refusal keeps the same connection alive once the upload drains", async wait => { +bounded("h1 unfinished refusal closes its advertised connection and the next request stays healthy", async wait => { const body = "complete"; const f = await fixture((req, res) => { req.resume(); res.end("ordinary"); @@ -166,7 +166,7 @@ bounded("h1 refusal keeps the same connection alive once the upload drains", asy next.once("socket", nextSocket.resolve); const nextResult = h1Result(next); next.end(); expect(await wait(nextResult)).toEqual({ status: 200, text: "ordinary" }); - expect(await wait(nextSocket.promise)).toBe(await wait(firstSocket.promise)); + expect(await wait(nextSocket.promise)).not.toBe(await wait(firstSocket.promise)); } finally { await wait(f.close()); } }); From 8c3674a8cfacba732c93ec44971a92a2e5ce2b8f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:32:10 +0900 Subject: [PATCH 4/6] test(claude): observe relay upstream backpressure deterministically Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/intercept/picker-listener.ts | 3 ++ structure/clients/claude-desktop.md | 1 + .../claude-picker-upload.test.ts | 29 ++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index ed546b32d97..852dc8e52c9 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -38,6 +38,8 @@ export interface PickerListenerOptions { uploadDeadlineMs?: number; /** Test seam: maximum time without upload progress. */ uploadIdleTimeoutMs?: number; + /** Test seam: observe or hold the relay's upstream writable before piping input. */ + onUpstreamRequest?: (request: ReturnType) => void; log?: (line: string) => void; } export interface PickerListenerHandle { port: number; close(): Promise } @@ -461,6 +463,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi deadlineTimer.unref?.(); onUploadProgress(); } + options.onUpstreamRequest?.(upReq); req.pipe(upReq); }; server.on("request", relay); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 90d41c43b8d..235426b921c 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -238,6 +238,7 @@ generated 502s and can reuse the same client socket. `tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation, failure and shutdown cleanup, and healthy sibling streams. +The upload regression fixture holds and observes the relay's actual upstream writable before asserting idle and absolute-deadline behavior. The picker CA (`picker-ca.ts`) carries critical name constraints permitting only `claude.ai` and excluding every IPv4 and IPv6 address. Its exportable signing identity is protected by the OS credential store and scoped to the canonical config directory; diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 9dbf21e35eb..7cfb9a2e65a 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -323,12 +323,16 @@ bounded("absolute upload deadline expires despite continued progress", async wai }); bounded("upstream backpressure does not expire an active upload as idle", async wait => { - const arrived = Promise.withResolvers(); - const f = await fixture((req, res) => { - // Never consume the body: the upstream read buffer stays full, so the relay - // pipe pauses client input even though the client has more bytes to send. - if (req.url === "/stall") arrived.resolve(); else res.end("ordinary"); - }, { uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 250 }); + const upstreamRequest = Promise.withResolvers(); + const f = await fixture((_req, res) => res.end("ordinary"), { + uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 250, + onUpstreamRequest: request => { + // Hold the actual relay writable so the fixture, not socket buffer sizing, + // determines how long upstream backpressure persists. + request.cork(); + upstreamRequest.resolve(request); + }, + }); try { const session = f.h2(); const upload = session.request({ ":method": "POST", ":path": "/stall", ":authority": "claude.ai" }); @@ -336,10 +340,15 @@ bounded("upstream backpressure does not expire an active upload as idle", async let closedEarly = false; upload.once("close", () => { closedEarly = true; }); upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); - await wait(arrived.promise); - // Past the idle interval the upload must still be open: the client is stalled - // by upstream backpressure, not idle. The absolute deadline still ends it. - await new Promise(resolve => setTimeout(resolve, 600)); + const relayRequest = await wait(upstreamRequest.promise); + await wait((async () => { + while (!relayRequest.writableNeedDrain) { + if (relayRequest.destroyed) throw new Error("relay closed before upstream backpressure"); + await new Promise(resolve => setTimeout(resolve, 5)); + } + })()); + await wait(new Promise(resolve => setTimeout(resolve, 600))); + expect(relayRequest.writableNeedDrain).toBe(true); expect(closedEarly).toBe(false); await wait(closed); expect(upload.rstCode).toBe(constants.NGHTTP2_CANCEL); From 7c0057b3c69a4e720d71be59527dd418dd1e99b9 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:43:38 +0900 Subject: [PATCH 5/6] fix(picker): stop accepted-upload clocks at the first upstream response The pre-response deadline/idle timers armed in the upload block were never cleared when the upstream answered, so a slow reply to an unfinished POST was destroyed mid-delivery by expireUpload. Clear both clocks when the upstream response starts and guard the idle/progress paths against rearming; refused-input and post-response bounds stay. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/intercept/picker-listener.ts | 9 ++++++- .../claude-picker-upload.test.ts | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index f05da62da40..39b3f6d9b4c 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -344,6 +344,9 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding()); let upReq: ReturnType; let upstreamResponded = false; + // Upload clocks only bound the pre-response wait: once upstream answers, the + // reply must be allowed to finish even if the client's input never does. + let stopUploadClocks: () => void = () => {}; try { // The HTTP/1.1 client validates the method, path and header values synchronously. upReq = httpsRequest({ @@ -352,6 +355,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES, }, upRes => { upstreamResponded = true; + stopUploadClocks(); const status = upRes.statusCode ?? 502; const originalHeaders = filteredHeaders(upRes.rawHeaders); const sendHead = (raw: string[]) => { @@ -440,8 +444,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi deadlineTimer = undefined; idleTimer = undefined; }; + stopUploadClocks = clearUploadTimers; const onUploadIdle = () => { idleTimer = undefined; + if (upstreamResponded) return; // A full upstream buffer pauses the pipe: 'data' stops even while the client // keeps sending, so backpressure is not client idleness. Recheck each interval // until the upstream drains; the absolute deadline still bounds the upload. @@ -449,6 +455,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi expireUpload(); }; const onUploadProgress = () => { + if (upstreamResponded) return; if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(onUploadIdle, uploadIdleTimeoutMs); idleTimer.unref?.(); @@ -476,7 +483,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi } }; expireUpload = () => { - if (uploadFinished) return; + if (uploadFinished || upstreamResponded) return; clientGone = true; stopUnfinishedUpload(); release(); diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index b8df94a4797..e2426aff14b 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -496,3 +496,29 @@ for (const protocol of ["h1", "h2"] as const) { } finally { await wait(f.close()); } }); } + +for (const protocol of ["h1", "h2"] as const) { + bounded(`${protocol} first upstream response stops upload clocks so a slow reply completes`, async wait => { + // Upload deadline 400ms, idle 250ms: the reply lands at ~600ms, so either clock + // would abort the relay mid-response if it survived the upstream's first reply. + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/slow-reply") { + res.writeHead(200, { "Content-Length": "5" }); + res.write("s"); + setTimeout(() => res.end("low!"), 600); + } else res.end("ordinary"); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + try { + const session = f.h2(); + const upload = protocol === "h2" + ? session.request({ ":method": "POST", ":path": "/slow-reply", ":authority": "claude.ai" }) + : f.h1("/slow-reply", "POST", { "Content-Length": "100" }); + const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest); + upload.write("x"); + expect(await wait(result)).toEqual({ status: 200, text: "slow!" }); + } finally { await wait(f.close()); } + }); +} From a7b9af82112be564de3ed56a50e9834b6f28e760 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:55:30 +0900 Subject: [PATCH 6/6] test(windows): wait for tray probe readiness before maintenance Match the badge probe's production initialization and first-tick launch order before measuring maintenance. Wait for the child PID readiness signal instead of a fixed sleep, and cover delayed Online readiness. Keep the 250ms maintenance and process reaping assertions intact. Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../windows-tray-probe-lifecycle-driver.ps1 | 43 +++++++++++++------ tests/windows/windows-tray.test.ts | 2 + 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 index 00ea31af25e..0101ad5190f 100644 --- a/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 +++ b/tests/helpers/windows-tray-probe-lifecycle-driver.ps1 @@ -137,21 +137,14 @@ $script:pendingOldProxyPid = $null $script:pendingProcess = $null $childPid = 0 -if ($Scenario -eq "BadgeStaleFailure") { - $script:updateAvailable = $true - $script:updateBadgeObservedAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $script:updateBadgeExpiryMs - 1000L - $notify.Icon = $offlineUpdateIcon - $updateItem.Visible = $true - $updateItem.Enabled = $true - if ($notify.Icon -ne "offline-update" -or -not $updateItem.Visible -or -not $updateItem.Enabled) { - throw "stale case did not start from a dotted icon and available menu" - } -} try { $badgeCase = $Scenario.StartsWith("Badge") if ($badgeCase) { - $script:updateBadgeAttemptAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - Start-UpdateBadgeProbe + # Match production startup: the first tick launches the probe; later ticks + # maintain it. Do not charge first-use JSON/file initialization to reaping. + Initialize-UpdateBadgeReader + $script:updateBadgeAttemptAt = 0L + Update-TrayState if ($null -eq $script:updateBadgeProcess) { throw "badge probe did not start" } } elseif ($Scenario -eq "Online") { # First tick settles wasOnline and launches the probe through the real @@ -167,6 +160,17 @@ try { } $probe = if ($badgeCase) { $script:updateBadgeProcess } else { $script:startupProbeProcess } $childPid = $probe.Id + # Seed the stale presentation after startup, before the maintenance under test. + if ($Scenario -eq "BadgeStaleFailure") { + $script:updateAvailable = $true + $script:updateBadgeObservedAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $script:updateBadgeExpiryMs - 1000L + $notify.Icon = $offlineUpdateIcon + $updateItem.Visible = $true + $updateItem.Enabled = $true + if ($notify.Icon -ne "offline-update" -or -not $updateItem.Visible -or -not $updateItem.Enabled) { + throw "stale case did not start from a dotted icon and available menu" + } + } $iconBeforeMaintenance = $notify.Icon $updateAvailableBeforeMaintenance = $script:updateAvailable $observedAtBeforeMaintenance = $script:updateBadgeObservedAt @@ -190,7 +194,20 @@ try { } if (-not $task.IsFaulted) { throw "badge overflow reader did not enforce the byte cap" } } else { - Start-Sleep -Milliseconds 500 + # Process.Start returning does not mean PowerShell has executed the PID write. + # Wait for this child's readiness before backdating its timeout and killing it. + $pidFile = $env:OCX_PROBE_TEST_PID_FILE + $readyDeadline = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + 5000 + $ready = $false + do { + if ($pidFile -and [System.IO.File]::Exists($pidFile)) { + try { $ready = @([System.IO.File]::ReadAllLines($pidFile)) -contains [string]$childPid } catch { $ready = $false } + } + if ($ready) { break } + if ($probe.HasExited) { throw "probe exited before reporting readiness" } + Start-Sleep -Milliseconds 20 + } while ([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() -lt $readyDeadline) + if (-not $ready) { throw "probe did not report readiness within 5s" } } if ($Scenario -ne "BadgeStaleFailure" -and $probe.HasExited) { throw "probe child exited before maintenance" } $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() diff --git a/tests/windows/windows-tray.test.ts b/tests/windows/windows-tray.test.ts index 68b99502d90..59e3127f362 100644 --- a/tests/windows/windows-tray.test.ts +++ b/tests/windows/windows-tray.test.ts @@ -519,6 +519,8 @@ describe("Windows tray packaging and command safety", () => { // with spaces or `&` even when quoted. const hangChild = join(directory, "hangchild.ps1"); writeFileSync(hangChild, [ + // Prove readiness is observed, not guessed from a fixed 500ms sleep. + ...(scenario === "Online" ? ["Start-Sleep -Milliseconds 1500"] : []), "$pidFile = $env:OCX_PROBE_TEST_PID_FILE", "if ($pidFile) { Add-Content -LiteralPath $pidFile -Value $PID }", ...(scenario === "BadgeOverflowStdout" ? ["[Console]::Out.Write('x' * 32768)"] : []),