From 5977aab4c36fbc9af512d05664fc12ccfbbff2fb Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:10:32 +0000 Subject: [PATCH] fix(claude): bound uploads until the first upstream reply Add 60-second absolute and 15-second idle-progress limits for accepted unfinished uploads. Release aggregate admission on expiry and stop both clocks on body completion or the first upstream reply. Keep upstream backpressure distinct from client idleness. Preserve the merged graceful FIN and 30-second TCP linger policies from upstream dev bf57900f55175ae435e311e2989a9d65b162ea82. Adapt upload regressions from fork PR #724 at a4195a9eb031713dc42a2ed4a23446d91833c727, with new HTTP/1 admission and early-reply tests. --- .../src/content/docs/fr/guides/claude-code.md | 2 + .../src/content/docs/guides/claude-code.md | 2 + .../src/content/docs/ja/guides/claude-code.md | 2 + .../src/content/docs/ko/guides/claude-code.md | 2 + .../src/content/docs/ru/guides/claude-code.md | 2 + .../src/content/docs/tr/guides/claude-code.md | 2 + .../content/docs/zh-cn/guides/claude-code.md | 2 + .../content/docs/zh-tw/guides/claude-code.md | 2 + src/claude/intercept/picker-listener.ts | 52 +++++++ structure/clients/claude-desktop.md | 9 +- .../claude-picker-upload.test.ts | 137 ++++++++++++++++++ 11 files changed, 212 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 374ea9949eb..d5f3d2f77b4 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -200,6 +200,8 @@ quittez complètement puis rouvrez Claude Desktop. Le mode picker fait partie de first-party : le [risque pour le compte du mode first-party](#first-party-sur-demande) s'applique donc aussi à ce mode. +Un envoi en attente de la réponse du serveur amont est arrêté après 60 secondes au total ou 15 secondes sans progression. La contre-pression du serveur amont ne compte pas comme une période d’inactivité. Les limites sont supprimées une fois le corps envoyé ou dès la première réponse amont, afin que les réponses longues et les flux SSE puissent continuer. + ### Utiliser les modèles opencodex depuis l'onglet Code de Desktop (associations first-party) En mode first-party, le sélecteur de modèles de l'onglet Code appartient à claude.ai : ses lignes diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d780ece5c1a..54a6600c8fa 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -339,6 +339,8 @@ applies to it as well. Desktop and CLI catalog rewrites share bounded row and me if adding routed models would exceed a limit, OpenCodex returns the original Anthropic catalog unchanged rather than publishing a partial list. +Uploads waiting for an upstream reply are stopped after 60 seconds in total or 15 seconds without upload progress. Upstream backpressure does not count as idleness. Completing the body or receiving the first upstream reply removes these limits, so long responses and SSE streams can continue. + ### Use opencodex models from the Desktop Code tab (first-party bindings) In first-party mode the Code tab's model picker belongs to claude.ai: its rows (Opus 5.5, diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 07899bcfb0b..f24a4233d2d 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -172,6 +172,8 @@ Picker プロファイルを選択した後は、Claude Desktop を完全に終 Picker モードは 1P の一部なので、[1P のアカウントリスク](#1pオプトイン)も同じように適用されます。 +上流からの応答を待つアップロードは、開始から60秒、または進行がない状態が15秒続くと停止します。上流のバックプレッシャーによる停止は無通信として扱いません。本文の送信が完了するか上流の応答が始まると、この制限は解除され、長い応答やSSEストリームを継続できます。 + ### Code タブで opencodex モデルを使う(1P バインディング) 1P モードでは、Code タブのモデルピッカーは claude.ai が提供します。各行(Opus 5.5、Sonnet 5、 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index f5ee8cf4068..f260993ec04 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -194,6 +194,8 @@ Picker 모드를 끄거나 Desktop을 완전히 다시 시작할 때까지 Deskt Picker 모드는 1P의 일부이므로 [1P 계정 위험](#1p직접-선택)도 그대로 적용돼요. +상류 응답을 기다리는 업로드는 전체 60초가 지나거나 업로드 진행이 15초 동안 없으면 중단됩니다. 상류의 흐름 제어로 전송이 멈춘 시간은 정체로 계산하지 않습니다. 본문 전송을 완료하거나 상류가 응답을 시작하면 이 제한이 해제되므로 긴 응답과 SSE 스트림은 계속 진행할 수 있습니다. + ### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩) 1P 모드에서 Code 탭의 모델 선택기는 claude.ai가 채워요. Opus 5.5, Sonnet 5, Haiku 4.5와 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 972f5f7e7d7..75ae873039d 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -182,6 +182,8 @@ Desktop будет офлайн, пока вы полностью не пере Режим picker входит в first-party, поэтому к нему также применяется [риск для аккаунта first-party](#first-party-по-выбору). +Загрузка, ожидающая ответа вышестоящего сервера, прекращается через 60 секунд суммарно или через 15 секунд без прогресса. Обратное давление со стороны сервера не считается бездействием. После завершения отправки тела или получения первого ответа эти ограничения снимаются, поэтому долгие ответы и потоки SSE могут продолжаться. + ## Claude Desktop через удалённый хаб На подключённой машине `ocx claude desktop apply` или `ocx claude desktop` получает снимок diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index cd60138f3fc..c42049119e4 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -219,6 +219,8 @@ seçildikten sonra Claude Desktop'ı tamamen kapatıp yeniden açın. Picker modu first-party'nin parçasıdır; bu nedenle [first-party hesap riski](#first-party-isteğe-bağlı) aynı şekilde geçerlidir. +Üst sunucudan yanıt bekleyen yüklemeler toplam 60 saniye sonra veya 15 saniye boyunca ilerleme olmadığında durdurulur. Üst sunucunun geri basıncı hareketsizlik sayılmaz. Gövde gönderimi tamamlandığında veya ilk üst sunucu yanıtı alındığında bu sınırlar kaldırılır; uzun yanıtlar ve SSE akışları devam edebilir. + ## Claude Desktop profili Bu profil yalnızca ağ geçidi modunda Desktop'a yazılır. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 6b0c397f80a..a15dfe4d7ec 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -160,6 +160,8 @@ Picker 模式开启期间,Claude Desktop 通过 OpenCodex 访问网络。如 Picker 模式属于第一方模式,因此[第一方账户风险](#第一方主动选择)同样适用。 +等待上游响应的上传会在总计60秒后,或连续15秒没有上传进展时停止。上游背压不视为空闲。请求正文发送完成或收到首个上游响应后,这些限制会解除,因此长响应和SSE流可以继续。 + ## 连接远程 hub 的 Claude Desktop 已连接的机器运行 `ocx claude desktop apply` 或 `ocx claude desktop` 时,会读取 hub 的 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 33395a06123..5cd63fe0d50 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -167,6 +167,8 @@ Picker 模式開啟期間,Claude Desktop 會透過 OpenCodex 存取網路。 Picker 模式屬於第一方模式,因此[第一方帳號風險](#第一方自行選擇)同樣適用。 +等待上游回應的上傳會在總計60秒後,或連續15秒沒有上傳進展時停止。上游背壓不視為閒置。請求本文傳送完成或收到首個上游回應後,這些限制會解除,因此長回應和SSE串流可以繼續。 + ## Claude Desktop 設定檔 只有閘道模式會將以下設定檔寫入 Desktop。 diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts index d115c7faad1..dce5ee56b5f 100644 --- a/src/claude/intercept/picker-listener.ts +++ b/src/claude/intercept/picker-listener.ts @@ -34,6 +34,12 @@ export interface PickerListenerOptions { maxEncodedBytes?: number; /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */ maxActiveUpstreams?: number; + /** Test seam: absolute deadline until upload completion or the first upstream reply. */ + uploadDeadlineMs?: number; + /** Test seam: maximum time without upload progress before an upstream reply. */ + uploadIdleTimeoutMs?: number; + /** Test seam: observe or hold the upstream writable before piping input. */ + onUpstreamRequest?: (request: ReturnType) => void; /** * Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS; * returns a cancel function. Production uses a timer. @@ -56,6 +62,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; /** * Once the backend has ended a connection and its reply has been flushed, input the client still * sends is discarded for at most this long before the connection is dropped. A cooperative client @@ -163,6 +172,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`); + } + } const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => { const timer = setTimeout(drop, ms); return () => clearTimeout(timer); @@ -271,6 +287,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding()); let upReq: ReturnType; let upstreamResponded = false; + let stopUploadClock = () => {}; try { // The HTTP/1.1 client validates the method, path and header values synchronously. upReq = httpsRequest({ @@ -279,6 +296,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES, }, upRes => { upstreamResponded = true; + stopUploadClock(); const status = upRes.statusCode ?? 502; const originalHeaders = filteredHeaders(upRes.rawHeaders); const sendHead = (raw: string[]) => { @@ -358,9 +376,32 @@ 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; + const onUploadIdle = () => { + idleTimer = undefined; + // Pipe backpressure pauses data events without making the client idle. + // The absolute clock still bounds an upload whose upstream never drains. + if (upReq.writableNeedDrain) { onUploadProgress(); return; } + expireUpload(); + }; + const onUploadProgress = () => { + if (uploadFinished || upstreamResponded) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(onUploadIdle, uploadIdleTimeoutMs); + idleTimer.unref?.(); + }; + stopUploadClock = () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + if (idleTimer) clearTimeout(idleTimer); + deadlineTimer = undefined; + idleTimer = undefined; + req.off("data", onUploadProgress); + }; const finishUpload = () => { if (uploadFinished) return; uploadFinished = true; + stopUploadClock(); req.off("end", finishUpload); req.off("close", finishUpload); upReq.off("close", onUpstreamClose); @@ -378,17 +419,28 @@ export async function startPickerListener(options: PickerListenerOptions): Promi upReq.destroy(); } }; + const expireUpload = () => { + if (uploadFinished || upstreamResponded) 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(); } + 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 78893a3bb85..199bac62b25 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -231,8 +231,13 @@ After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ER 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. The request-side drain adds no upload deadline or byte cap and does not wait for request-body -completion before ending the response side. Bun 1.4.3's HTTP server ends a `Connection: close` +the request. The request-side drain adds no byte cap and does not wait for request-body +completion before ending the response side. Accepted uploads awaiting an upstream reply have a +60-second absolute and 15-second idle-progress deadline. Expiry closes that input and upstream +work and releases its aggregate slot; upstream backpressure suspends only the idle clock. +Body completion or the first upstream reply removes both clocks, so they cannot truncate a slow +early response or SSE stream. The existing reply cleanup and TCP linger policy take over after +an upstream reply. Bun 1.4.3's HTTP server ends a `Connection: close` response with `socket.destroySoon()` before the listener's own finish handler runs, which reset the splice before the reply reached the client; every HTTP/1.1 reply that closes unfinished input (refusal, generated 502, early upstream reply) therefore replaces that socket's `destroySoon` with diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..6ffe3765d54 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,3 +378,140 @@ for (const protocol of ["h1", "h2"] as const) { } finally { await wait(f.close()); } }); } + +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("upstream backpressure does not expire an active upload as idle", async wait => { + 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" }); + const closed = observeClose(upload); + let closedEarly = false; + upload.once("close", () => { closedEarly = true; }); + upload.write(Buffer.alloc(4 * 1024 * 1024, 0x61)); + 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); + } 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(); + 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()); } +}); + +bounded("h1 idle upload releases the shared slot for a healthy h2 request", async wait => { + const arrived = Promise.withResolvers(); + let requests = 0; + const f = await fixture((req, res) => { + requests++; + req.on("error", () => res.destroy()); req.resume(); + if (req.url === "/stall") { arrived.resolve(); req.once("end", () => res.end("uploaded")); } + else res.end("ordinary"); + }, { maxActiveUpstreams: 1, uploadDeadlineMs: 1_000, uploadIdleTimeoutMs: 350 }); + try { + const upload = f.h1("/stall", "POST", { "Content-Length": "100" }); + const closed = observeSocketClose(upload); + upload.write("partial"); + await wait(arrived.promise); + const session = f.h2(); + expect((await wait(normalH2(session, "/blocked"))).status).toBe(503); + await wait(closed); + expect((await wait(normalH2(session, "/ok"))).status).toBe(200); + expect(requests).toBe(2); + } finally { await wait(f.close()); } +}); + +for (const protocol of ["h1", "h2"] as const) { + bounded(`${protocol} an early reply disables pre-response clocks while its upload is unfinished`, async wait => { + const f = await fixture((req, res) => { + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + res.writeHead(200, { "Content-Length": "5" }); + res.write("s"); + setTimeout(() => res.end("low!"), 600); + }, { uploadDeadlineMs: 400, uploadIdleTimeoutMs: 250 }); + try { + const session = f.h2(); + const upload = protocol === "h2" + ? session.request({ ":method": "POST", ":path": "/early", ":authority": "claude.ai" }) + : f.h1("/early", "POST", { "Content-Length": "100" }); + const result = protocol === "h2" ? h2Result(upload as ClientHttp2Stream) : h1Result(upload as ClientRequest); + upload.write("partial"); + expect(await wait(result)).toEqual({ status: 200, text: "slow!" }); + } finally { await wait(f.close()); } + }); +}