diff --git a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md index eca5b46fd..d77e89d53 100644 --- a/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ja/troubleshooting/windows-memory.md @@ -43,6 +43,6 @@ Windows では、#32111 クラッシュを回避するために、opencodex は 未検証の領域 — 私たちがテストしていないランタイムで opencodex を実行しています。自己責任で。サービスのインストールにとって重要: オーバーライドは、サービスの開始時ではなく、**サービス アーティファクトの生成時に**読み込まれます。環境変数を設定し、同じシェルから `ocx service repair` を再実行すると、パスが永続サービス定義に組み込まれます。 env を設定するだけでは、すでにインストールされているサービスには何も影響しません。 3. **`streamMode: "eager-relay"` を使用して有界リレーにオプトインします。** 2 つの方法: -`config.json` を編集する (`"streamMode": "eager-relay"` を追加する) か、管理 API を呼び出します。`PUT /api/settings` と `{"streamMode":"eager-relay"}` は、再起動せずに新しいターンに適用されます。 **クラッシュのリスク警告:** Bun 1.3.14 では、#32111 の影響を受けるストリーム形状が使用されており、ストリームの途中でプロセスがクラッシュする可能性があります (Windows に限らず、どの OS でも)。サービス マネージャーはサービスを再起動しますが、実行中のリクエストは失敗します。 `"legacy-tee"` は現在のデフォルトを固定します。 Windows では、`"auto"` (デフォルト) によりランタイム ゲートが決定します。 macOS では、`"auto"` は常に T 上にあります。明示的な `"eager-relay"` はオプトインです。 +`config.json` を編集する (`"streamMode": "eager-relay"` を追加する) か、管理 API を呼び出します。`PUT /api/settings` と `{"streamMode":"eager-relay"}` は、再起動せずに新しいターンに適用されます。 **クラッシュのリスク警告:** Bun 1.3.14 では、#32111 の影響を受けるストリーム形状が使用されており、ストリームの途中でプロセスがクラッシュする可能性があります (Windows に限らず、どの OS でも)。サービス マネージャーはサービスを再起動しますが、実行中のリクエストは失敗します。通常のストリームでは `"legacy-tee"` が現在のデフォルトを固定します。ただし、クライアント向け payload rewrite が必要なリクエストは、tee/rewrite チェーンの停止を避けるため Windows と macOS で自動的に有界 single-reader relay を使い、この安全上の例外は `streamMode` では上書きできません。Windows の通常ストリームでは `"auto"` (デフォルト) がランタイムゲートに従います。macOS の通常の `"auto"` トラフィックは tee のままで、明示的な `"eager-relay"` がオプトインです。 これらのいずれかを実際の Windows ワークロードで試した場合は、[#314](https://github.com/lidge-jun/opencodex/issues/314) の `ocx doctor` メモリ セクションの前後を報告してください。これがまさにこの軽減策が待っている検証です。 diff --git a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md index 4e53e3d5f..bd4c5bb50 100644 --- a/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ko/troubleshooting/windows-memory.md @@ -36,6 +36,6 @@ Windows에서는 opencodex가 #32111 충돌을 피하기 위해 스트리밍 응 2. **`OPENCODEX_BUN_PATH`로 신뢰하는 Bun 런타임을 사용합니다.** 이 경로는 검증되지 않은 영역입니다. opencodex를 아직 테스트하지 않은 런타임에서 실행하는 것이므로, 위험은 사용자에게 있습니다. 서비스 설치에서 특히 중요한 점은 이 override가 서비스 시작 시가 아니라 **서비스 아티팩트를 생성할 때** 읽힌다는 것입니다. 환경 변수를 설정한 뒤, 같은 셸에서 `ocx service repair`를 다시 실행해야 경로가 영구적인 서비스 정의에 반영됩니다. 환경 변수만 설정하면 이미 설치된 서비스에는 아무 영향이 없습니다. -3. **`streamMode: "eager-relay"`로 bounded relay를 opt-in합니다.** 방법은 두 가지입니다. `config.json`을 수정해 `"streamMode": "eager-relay"`를 추가하거나, 관리 API에 `PUT /api/settings`와 `{"streamMode":"eager-relay"}`를 보내 새 턴에 재시작 없이 적용합니다. **충돌 위험 경고:** Bun 1.3.14에서는 이 방식이 #32111의 영향을 받는 스트림 형태를 사용하므로, 어떤 OS에서든 스트림 중간에 프로세스가 충돌할 수 있습니다. 서비스 관리자가 다시 시작하겠지만, 진행 중이던 요청은 실패합니다. `"legacy-tee"`는 현재 기본값을 고정합니다. Windows에서는 `"auto"`(기본값)가 런타임 게이트 판단을 따르게 합니다. macOS에서는 `"auto"`가 항상 tee를 유지하고, 명시적 `"eager-relay"`만 opt-in입니다. +3. **`streamMode: "eager-relay"`로 bounded relay를 opt-in합니다.** 방법은 두 가지입니다. `config.json`을 수정해 `"streamMode": "eager-relay"`를 추가하거나, 관리 API에 `PUT /api/settings`와 `{"streamMode":"eager-relay"}`를 보내 새 턴에 재시작 없이 적용합니다. **충돌 위험 경고:** Bun 1.3.14에서는 이 방식이 #32111의 영향을 받는 스트림 형태를 사용하므로, 어떤 OS에서든 스트림 중간에 프로세스가 충돌할 수 있습니다. 서비스 관리자가 다시 시작하겠지만, 진행 중이던 요청은 실패합니다. 일반 스트림에서는 `"legacy-tee"`가 현재 기본 경로를 고정합니다. 다만 클라이언트용 payload rewrite가 필요한 요청은 tee/rewrite 체인이 멈출 수 있으므로 Windows와 macOS에서 자동으로 bounded single-reader relay를 사용하며, `streamMode`는 이 전송 안전 예외를 덮어쓰지 않습니다. Windows의 일반 스트림에서는 `"auto"`(기본값)가 런타임 게이트 판단을 따릅니다. macOS의 일반 `"auto"` 트래픽은 tee를 유지하고, 명시적 `"eager-relay"`만 opt-in입니다. 이 중 어떤 방법이든 실제 Windows 워크로드에 적용해 보셨다면, 변경 전후의 `ocx doctor` 메모리 섹션을 [#314](https://github.com/lidge-jun/opencodex/issues/314)에 남겨 주세요. 이것이 바로 이 완화책이 기다리고 있는 검증입니다. diff --git a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md index 11fff3de8..9d50145f4 100644 --- a/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/ru/troubleshooting/windows-memory.md @@ -83,9 +83,12 @@ opencodex поставляет рантайм Bun (сейчас это **1.3.14* перезапуска. **Предупреждение о риске crash:** на Bun 1.3.14 это использует форму streaming, затронутую #32111, и процесс может упасть прямо посреди потока (на любой ОС, не только на Windows). Service manager перезапустит его, но все запросы в полёте потерпят неудачу. - `"legacy-tee"` жёстко фиксирует текущий путь по умолчанию. На Windows `"auto"` (по умолчанию) - позволяет рантайму выбрать путь самому. На macOS `"auto"` всегда остаётся на tee; явный - `"eager-relay"` — это opt-in. + Для обычных потоков `"legacy-tee"` жёстко фиксирует текущий путь по умолчанию. + Запросы, которым нужен клиентский payload rewrite, автоматически используют bounded + single-reader relay на Windows и macOS, потому что цепочка tee/rewrite может зависнуть; + `streamMode` не отменяет это исключение безопасности транспорта. На Windows `"auto"` + (по умолчанию) передаёт выбор runtime-gate для обычных потоков, а на macOS обычный + `"auto"` остаётся на tee; явный `"eager-relay"` — это opt-in. Если вы попробуете любой из этих вариантов на реальной Windows-нагрузке, пожалуйста, пришлите разделы памяти из `ocx doctor` до и после в diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 54a9492d2..debcc03fa 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -91,9 +91,13 @@ restart it. applies to new turns without a restart. **Crash risk warning:** on Bun 1.3.14 this uses the stream shape affected by #32111, which can crash the process mid-stream (on any OS, not just Windows). The service manager will - restart it, but in-flight requests fail. `"legacy-tee"` pins the current - default. On Windows, `"auto"` (default) lets the runtime gate decide. On - macOS, `"auto"` always stays on tee; explicit `"eager-relay"` is the opt-in. + restart it, but in-flight requests fail. For ordinary streams, + `"legacy-tee"` pins the current default. Requests that need a client-facing + payload rewrite use the bounded single-reader relay automatically on Windows + and macOS because the tee/rewrite chain can stall; `streamMode` does not + override that transport-safety exception. On Windows, `"auto"` (default) + lets the runtime gate decide for ordinary streams. On macOS, ordinary + `"auto"` traffic stays on tee; explicit `"eager-relay"` is the opt-in. If you try any of these on a real Windows workload, please report the before and after `ocx doctor` memory sections on diff --git a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md index 8d3ae224d..8c9cb1ce0 100644 --- a/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/zh-cn/troubleshooting/windows-memory.md @@ -36,6 +36,6 @@ opencodex 打包了 Bun 运行时(当前为 **1.3.14**)。这类内存增长 2. **通过 `OPENCODEX_BUN_PATH` 运行你信任的 Bun 运行时。** 这属于未验证区域,你是在一个我们没有测试过的运行时上运行 opencodex,风险自负。对服务安装而言,这个覆盖值是在生成服务产物时读取的,而不是在服务启动时读取的。先设置环境变量,然后在同一个 shell 中重新运行 `ocx service repair`,这样路径才会被写入持久化的服务定义。只设置环境变量对已经安装好的服务没有任何作用。 -3. **通过 `streamMode: "eager-relay"` 显式启用有界中继。** 有两种方式:编辑 `config.json`(添加 `"streamMode": "eager-relay"`),或调用管理 API - `PUT /api/settings` 携带 `{"streamMode":"eager-relay"}`,即可对新轮次生效,无需重启。**崩溃风险警告:** 在 Bun 1.3.14 上,这会使用受 #32111 影响的流形态,可能在流中途使进程崩溃(任何操作系统都会受影响,不只是 Windows)。服务管理器会把它重启,但正在进行的请求会失败。`"legacy-tee"` 会固定在当前默认路径。Windows 上,`"auto"`(默认值)会交给运行时门控决定。macOS 上,`"auto"` 始终保持 tee;显式 `"eager-relay"` 才是显式启用选项。 +3. **通过 `streamMode: "eager-relay"` 显式启用有界中继。** 有两种方式:编辑 `config.json`(添加 `"streamMode": "eager-relay"`),或调用管理 API - `PUT /api/settings` 携带 `{"streamMode":"eager-relay"}`,即可对新轮次生效,无需重启。**崩溃风险警告:** 在 Bun 1.3.14 上,这会使用受 #32111 影响的流形态,可能在流中途使进程崩溃(任何操作系统都会受影响,不只是 Windows)。服务管理器会把它重启,但正在进行的请求会失败。对于普通流,`"legacy-tee"` 会固定当前默认路径;需要面向客户端做 payload rewrite 的请求会在 Windows 和 macOS 上自动使用有界单读者中继,因为 tee/rewrite 链可能卡住,`streamMode` 不会覆盖这个传输安全例外。Windows 上,普通流的 `"auto"`(默认值)会交给运行时门控决定。macOS 上,普通 `"auto"` 流量仍保持 tee;显式 `"eager-relay"` 才会启用有界中继。 如果你在真实的 Windows 工作负载上尝试这些方案,请把变更前后 `ocx doctor` 的内存部分发到 [#314](https://github.com/lidge-jun/opencodex/issues/314)——这正是这个缓解措施在等待的验证。 diff --git a/src/lib/bun-stream-caps.ts b/src/lib/bun-stream-caps.ts index 3b5265e70..c64691582 100644 --- a/src/lib/bun-stream-caps.ts +++ b/src/lib/bun-stream-caps.ts @@ -9,8 +9,10 @@ * follows this runtime/config decision, preserving the explicit legacy-tee * safety pin. Darwin no-rewrite traffic stays on tee * for `auto` regardless of runtime capability and reaches eager relay only via - * explicit `streamMode: "eager-relay"` opt-in (see - * devlog/_plan/260731_macos_rss_retention/100_darwin_eager_optin.md). + * explicit `streamMode: "eager-relay"` opt-in. On Windows and Darwin, traffic + * that needs a client payload rewrite always uses the eager single-reader path: + * the tee()+JS-pull rewrite chain can stall before delivering any client frames + * (see #864 and devlog/_plan/260731_macos_rss_retention/100_darwin_eager_optin.md). * * Prerelease conservatism: a version carrying a prerelease suffix (e.g. * `1.4.0-canary.3`) is NEVER treated as fixed even when its numeric triple @@ -94,7 +96,8 @@ export function decideEagerRelay( * Windows preserves the decision for no-rewrite traffic. Darwin permits only * explicit config opt-in; `auto` remains tee even on a future fixed runtime. * Returns the normalized effective decision, or null when platform policy, - * rewrite needs, or a Darwin non-config-eager mode selects tee. + * rewrite needs, or a Darwin non-config-eager mode selects tee. Rewrite traffic + * is handled separately by `requiresEagerRewriteRelay`. */ export function selectEagerPath( platform: NodeJS.Platform, @@ -113,15 +116,16 @@ export function selectEagerPath( } /** - * #864 transport gate: win32 traffic that needs a client payload rewrite must - * use the eager single reader with the rewrite applied inline, because the - * alternative tee()+JS-pull chain is the Bun#32111-unsafe path that loses the - * terminal SSE block on Windows. Independent of the version-based eager - * policy: the pull chain is unsafe on the AFFECTED runtimes by definition. + * Transport safety gate: Windows and Darwin traffic that needs a client payload + * rewrite must use the eager single reader with the rewrite applied inline. + * The alternative tee()+JS-pull chain can lose the terminal block on Windows + * (#864) and can stall before the first client frame on Darwin when a Desktop- + * shaped request produces large SSE events. This overrides `streamMode`, since + * pinning the known-bad chain would reintroduce a turn that never completes. */ -export function isWin32EagerRewrite( +export function requiresEagerRewriteRelay( platform: NodeJS.Platform, needsClientRewrite: boolean, ): boolean { - return platform === "win32" && needsClientRewrite; + return (platform === "win32" || platform === "darwin") && needsClientRewrite; } diff --git a/src/server/index.ts b/src/server/index.ts index 674573c8e..924727c6e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -328,9 +328,10 @@ function attachLiveSidebandUpstream( // if (isEventStream && upstreamResponse.body) { // const repairConfig = route.provider.responsesItemIdRepair; // const needsClientRewrite = imageGenCallAliases.size > 0 -// #314 gated shape: win32 always uses the terminal-aware eager relay so a keep-alive -// upstream cannot hold Codex open after response.completed; darwin no-rewrite traffic -// requires explicit config-eager opt-in (`auto` always stays tee on darwin). +// #314 gated shape: win32 no-rewrite traffic follows runtime/config policy; darwin no-rewrite +// traffic requires explicit config-eager opt-in (`auto` always stays tee on darwin). Rewrite +// traffic on either platform takes the terminal-aware eager single-reader safety path regardless +// of streamMode, so a keep-alive upstream cannot hold Codex open after response.completed. // selectEagerPath(process.platform, needsClientRewrite, config.streamMode ?? "auto") // relaySseEagerBounded(upstreamResponse.body, turnAc, // new Response(eagerBody, diff --git a/src/server/relay-eager.ts b/src/server/relay-eager.ts index 5c1241f22..1322983b3 100644 --- a/src/server/relay-eager.ts +++ b/src/server/relay-eager.ts @@ -38,8 +38,8 @@ export type EagerRelayHooks = { inspectChunk: (chunk: Uint8Array) => void; /** * Optional inline client-facing payload rewrite, framed to complete SSE - * blocks inside the single reader. This is what lets win32 rewrite traffic - * (image_gen restore, item-id repair) use this relay instead of the + * blocks inside the single reader. This is what lets Windows/Darwin rewrite + * traffic (image_gen restore, item-id repair) use this relay instead of the * Bun#32111-unsafe tee()+JS-pull chain (#864). */ rewritePayload?: SsePayloadRewrite; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 619a2e7f0..640eb059f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -158,7 +158,7 @@ import { sanitizePassthroughHeaders, } from "../relay"; import { relaySseEagerBounded } from "../relay-eager"; -import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps"; +import { requiresEagerRewriteRelay, selectEagerPath } from "../../lib/bun-stream-caps"; import { cancelBodyOnAbort } from "../../lib/abort"; import { createResponsesItemIdPayloadRewrite, @@ -1974,16 +1974,17 @@ async function handleResponsesInner( ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); - // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain - // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is - // lost). The eager single reader applies the same rewrites inline. - const win32EagerRewrite = isWin32EagerRewrite(process.platform, needsClientRewrite); + // Rewrite traffic on Windows or Darwin must never enter the tee()+JS-pull + // chain: Windows can lose the terminal block (#864), while Darwin can stall + // before delivering the first large Desktop-shaped SSE frame. The eager + // single reader applies the same bounded rewrites inline on both platforms. + const eagerRewrite = requiresEagerRewriteRelay(process.platform, needsClientRewrite); const eagerPath = selectEagerPath( process.platform, needsClientRewrite, config.streamMode ?? "auto", ); - if (eagerPath?.useEagerRelay || win32EagerRewrite) { + if (eagerPath?.useEagerRelay || eagerRewrite) { const turnAc = new AbortController(); linkAbortSignal(upstream, turnAc.signal); registerTurn(turnAc, options.turnAdmissionLease); @@ -2022,7 +2023,7 @@ async function handleResponsesInner( // Stream lifetime follows the protocol terminal even when this request // has no outcome callback configured (reported() would stay false). sawTerminal: () => inspector.terminalSeen(), - ...(win32EagerRewrite + ...(eagerRewrite ? { rewritePayload: composeSsePayloadRewrites(...payloadRewrites) } : {}), onSynthetic: kind => { @@ -2038,11 +2039,12 @@ async function handleResponsesInner( }, onClientCancel: () => options.onNativePassthroughCancel?.(), onDone: () => unregisterTurn(turnAc), - }, win32EagerRewrite ? { rewriteBudget: translatorBudget } : undefined); - // When selected, this relay closes response.completed even if upstream - // keeps the connection alive. Windows rewrite traffic applies its - // payload transform inline — never via the Bun#32111-unsafe - // tee()+JS-pull chain (#864). + }, eagerRewrite ? { rewriteBudget: translatorBudget } : undefined); + // selectEagerPath admits only no-rewrite traffic on both eligible platforms; + // Windows/Darwin rewrite traffic reaches this relay through the safety gate, + // with the payload rewrite inline rather than an image/item-id JS pull + // wrapper. The relay closes at response.completed even if upstream keeps + // the connection alive. if (!headers.has("content-type")) headers.set("content-type", "text/event-stream"); return markEagerRelaySseResponse( markNativePassthroughSseResponse(new Response(eagerBody, { diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 7653876ee..b672a3f0a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -43,18 +43,24 @@ Native passthrough SSE has TWO shapes, selected per request in drained eagerly by `consumeForInspection`/`consumeForResponseLogMetadata` for terminal-outcome recording, quota, the passthrough continuation cache, and request logs. This remains the default shape on bundled Bun 1.3.14. -- **Terminal-aware eager bounded relay** (`src/server/relay-eager.ts`). Windows - uses this single-reader shape for rewrite traffic and for no-rewrite traffic - selected by `selectEagerPath` in `src/lib/bun-stream-caps.ts`; the latter keeps - `legacy-tee` and known-bad-runtime `auto` on tee as documented. When selected, - `response.completed` closes the client stream even if upstream keeps HTTP/SSE - alive. Darwin uses it for no-client-rewrite traffic only (neither image-gen - aliases nor item-id repair) and is explicit-only: `auto` stays tee even after - a future threshold bump. One eager reader + byte-bounded - client queue + post-cancel bounded discard-drain replaces the tee and goes - directly to the response without a JS rewrite wrapper, preserving the full - inspection side-effect set (shared `createSseInspector` factory in `relay.ts`) - including the #44 late-terminal semantics. +- **Terminal-aware eager bounded relay** (`src/server/relay-eager.ts`). No-client-rewrite + traffic (neither image-gen aliases nor item-id repair) is gated by + `selectEagerPath` in `src/lib/bun-stream-caps.ts`. Windows `auto` becomes eager + only on runtimes proven to carry the Bun#32111 fix (`MIN_FIXED_BUN_VERSION`, + null until a bundle bump), while explicit `streamMode: "eager-relay"` opts in + today. Darwin is explicit-only for ordinary traffic: `auto` stays tee even + after a future threshold bump. Separately, passthrough SSE that needs a + client-facing payload rewrite uses this relay on Windows and Darwin regardless + of `streamMode`: the tee plus JS-pull rewrite chain can lose the terminal block + on Windows and can stall before the first large Codex Desktop frame on Darwin. + The single reader applies that rewrite inline, keeps its frame buffer charged + to the turn's translator budget, and feeds raw upstream bytes to inspection + and continuation state first. When selected, `response.completed` closes the + client stream even if upstream keeps HTTP/SSE alive. Its byte-bounded client + queue and post-cancel bounded discard-drain preserve the full inspection + side-effect set (shared `createSseInspector` factory in `relay.ts`), including + the #44 late-terminal semantics. Linux and no-rewrite traffic outside the gates + remain unchanged. The two-shape contract is mirror-commented in `src/server/index.ts`; the real `core.ts` gate is source-invariant-tested by `tests/passthrough-abort.test.ts`, diff --git a/tests/bun-stream-caps.test.ts b/tests/bun-stream-caps.test.ts index 2a7ad0b4c..1e5d7b450 100644 --- a/tests/bun-stream-caps.test.ts +++ b/tests/bun-stream-caps.test.ts @@ -4,18 +4,19 @@ import { compareBunVersions, decideEagerRelay, isStreamMode, - isWin32EagerRewrite, + requiresEagerRewriteRelay, MIN_FIXED_BUN_VERSION, parseBunVersion, selectEagerPath, } from "../src/lib/bun-stream-caps"; -describe("isWin32EagerRewrite (#864 transport gate)", () => { - test("win32 + rewrite → eager inline rewrite; everything else stays out", () => { - expect(isWin32EagerRewrite("win32", true)).toBe(true); - expect(isWin32EagerRewrite("win32", false)).toBe(false); - expect(isWin32EagerRewrite("darwin", true)).toBe(false); - expect(isWin32EagerRewrite("linux", true)).toBe(false); +describe("requiresEagerRewriteRelay (rewrite transport safety gate)", () => { + test("Windows and Darwin rewrite inline; no-rewrite and unverified platforms stay out", () => { + expect(requiresEagerRewriteRelay("win32", true)).toBe(true); + expect(requiresEagerRewriteRelay("darwin", true)).toBe(true); + expect(requiresEagerRewriteRelay("win32", false)).toBe(false); + expect(requiresEagerRewriteRelay("darwin", false)).toBe(false); + expect(requiresEagerRewriteRelay("linux", true)).toBe(false); }); }); @@ -122,7 +123,7 @@ describe("selectEagerPath (platform policy matrix)", () => { expect(selectEagerPath("darwin", false, "auto", "1.4.0", "1.4.0")).toBeNull(); }); - test("darwin + rewrite + config-eager → tee", () => { + test("darwin rewrite selection is delegated to the separate safety gate", () => { expect(selectEagerPath("darwin", true, "eager-relay", "1.3.14", null)).toBeNull(); }); diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index d702452cb..171bc8973 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -53,13 +53,16 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("const needsClientRewrite = imageGenCallAliases.size > 0"); expect(sseBranch).toContain("new Response(eagerBody"); expect(sseBranch).toContain("const rewrittenBody = payloadRewrites.length > 0"); - expect(sseBranch).toContain("eagerPath?.useEagerRelay || win32EagerRewrite"); + // Windows/Darwin traffic that needs a client rewrite takes the eager single + // reader with the payload rewrite applied inline. It never enters the + // tee()+JS-pull chain that loses delivery on these runtimes. + expect(sseBranch).toContain("requiresEagerRewriteRelay"); + expect(sseBranch).toContain("eagerRewrite"); + expect(sseBranch).toContain("eagerPath?.useEagerRelay || eagerRewrite"); expect(sseBranch).not.toContain("win32TerminalRelay"); - // #864: win32 traffic that DOES need a client rewrite takes the eager single - // reader with the payload rewrite applied inline — never the tee()+JS-pull - // chain that loses the terminal block on Windows (Bun#32111). - expect(sseBranch).toContain("win32EagerRewrite"); + expect(sseBranch).not.toContain("win32EagerRewrite"); expect(sseBranch).toContain("rewritePayload: composeSsePayloadRewrites(...payloadRewrites)"); + expect(sseBranch).toContain("sawTerminal: () => inspector.terminalSeen()"); // Elsewhere the failed-tail relay converts mid-stream resets into a clean response.failed. expect(sseBranch).toContain("relaySseWithFailedTail(rewrittenBody, upstream"); expect(sseBranch).toContain("new Response(clientBody"); diff --git a/tests/relay-eager.test.ts b/tests/relay-eager.test.ts index 9840ead4b..cf6c57503 100644 --- a/tests/relay-eager.test.ts +++ b/tests/relay-eager.test.ts @@ -284,6 +284,52 @@ describe("relaySseEagerBounded — inline payload rewrite (#864)", () => { expect(budget.snapshot().currentBytes).toBe(0); }); + test("client cancel during a partial frame drains and leaves the rewrite budget clear", async () => { + const budget = createTranslatorBudget(); + const up = controlledUpstream(); + const upstream = new AbortController(); + const { hooks, rec } = makeHooks(); + hooks.rewritePayload = (payload: string) => payload; + let resolveDone!: () => void; + const done = new Promise(resolve => { resolveDone = resolve; }); + const previousOnDone = hooks.onDone; + hooks.onDone = () => { + try { previousOnDone(); } finally { resolveDone(); } + }; + let timeout: ReturnType | undefined; + + try { + const relayed = relaySseEagerBounded(up.stream, upstream, hooks, { + rewriteBudget: budget, + postCancelDrainMs: 20, + }); + const reader = relayed.getReader(); + + up.push(enc.encode(`data: {"type":"response.created","padding":"${"x".repeat(256 * 1024)}`)); + await settle(); + // The terminal boundary owns incomplete SSE framing, so the downstream + // rewrite stage must not retain or charge this partial block. + expect(budget.snapshot().currentBytes).toBe(0); + + await reader.cancel(); + await Promise.race([ + done, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("relay cleanup timed out")), 2_000); + }), + ]); + + expect(upstream.signal.aborted).toBe(true); + expect(budget.snapshot().currentBytes).toBe(0); + expect(rec.cancels).toBe(1); + expect(rec.dones).toBe(1); + expect(rec.synthetics).toEqual([]); + } finally { + if (timeout) clearTimeout(timeout); + budget.dispose(); + } + }); + test("blocks without a data field pass through untouched before the terminal", async () => { const up = controlledUpstream(); const { hooks } = makeHooks(); diff --git a/tests/responses-image-gen-repair.test.ts b/tests/responses-image-gen-repair.test.ts index 9ed1e1f74..fccb49c89 100644 --- a/tests/responses-image-gen-repair.test.ts +++ b/tests/responses-image-gen-repair.test.ts @@ -5,6 +5,7 @@ import { restoreImageGenCallsInJson, } from "../src/server/responses-image-gen-repair"; import { handleResponses } from "../src/server/responses"; +import { isEagerRelaySseResponse } from "../src/server/relay"; import type { OcxConfig } from "../src/types"; import { finalizeTranslatorBudgetResponse } from "../src/lib/translator-budget"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -274,6 +275,7 @@ describe("Responses image-gen call restoration", () => { test("handleResponses restores client-facing SSE calls on the passthrough rewrite path", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; + const largeEcho = "x".repeat(256 * 1024); const item = { type: "function_call", id: "fc_1", @@ -283,6 +285,10 @@ describe("Responses image-gen call restoration", () => { status: "completed", }; const upstream = [ + `event: response.created\ndata: ${JSON.stringify({ + type: "response.created", + response: { id: "resp_1", status: "in_progress", output: [], instructions: largeEcho }, + })}\n\n`, `event: response.output_item.added\ndata: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, @@ -306,6 +312,9 @@ describe("Responses image-gen call restoration", () => { const config = { port: 0, defaultProvider: "fixture", + // Rewrite traffic must stay off the tee()+JS-pull chain on Windows and + // Darwin even when an operator pins the legacy path for ordinary streams. + streamMode: "legacy-tee", providers: { fixture: { adapter: "openai-responses", @@ -335,8 +344,13 @@ describe("Responses image-gen call restoration", () => { const outboundTools = outboundBody?.tools as Array<{ name?: string }> | undefined; expect(outboundTools?.[0]?.name).toBe("image_gen__imagegen"); + expect(isEagerRelaySseResponse(response)).toBe( + process.platform === "win32" || process.platform === "darwin", + ); + expect(clientBody).toContain(largeEcho); expect(clientBody).not.toContain("image_gen__imagegen"); expect(clientBody.match(/\"namespace\":\"image_gen\"/g)).toHaveLength(3); + expect(clientBody.match(/event: response\.completed/g)).toHaveLength(1); expect(clientBody).toContain("data: [DONE]"); } finally { globalThis.fetch = savedFetch;