From 723348c33d6c43d25791fcca4b7481e8fef1d1c5 Mon Sep 17 00:00:00 2001 From: Hylouis233 <88263959+Hylouis233@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:41:21 +0000 Subject: [PATCH 1/3] fix: preserve compaction progress and increase stall budget --- .../content/docs/fr/reference/architecture.md | 2 +- .../docs/fr/reference/configuration/server.md | 2 +- .../content/docs/ja/reference/architecture.md | 2 +- .../docs/ja/reference/configuration/server.md | 2 +- .../content/docs/ko/reference/architecture.md | 2 +- .../docs/ko/reference/configuration/server.md | 2 +- .../content/docs/reference/architecture.md | 4 +- .../docs/reference/configuration/server.md | 2 +- .../content/docs/ru/reference/architecture.md | 2 +- .../docs/ru/reference/configuration/server.md | 2 +- .../content/docs/tr/reference/architecture.md | 2 +- .../docs/tr/reference/configuration/server.md | 2 +- .../docs/zh-cn/reference/architecture.md | 2 +- .../zh-cn/reference/configuration/server.md | 2 +- .../docs/zh-tw/reference/architecture.md | 2 +- .../zh-tw/reference/configuration/server.md | 2 +- scripts/test-layout/layout.json | 1 + src/adapters/openai-responses.ts | 11 +++ src/stall-timeout.ts | 6 +- tests/fixtures/test-layout-expected.json | 1 + tests/lib/stall-timeout.test.ts | 12 +-- tests/responses/compaction-progress.test.ts | 98 +++++++++++++++++++ 22 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 tests/responses/compaction-progress.test.ts diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index f197d85c11..91caae672d 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -73,7 +73,7 @@ Trois anciens points d’entrée volumineux préservent désormais la compatibil | `done` | `response.completed` (avec l’utilisation) | | `error` | `response.failed` (avec `last_error`) | -Le pont émet également un **signal de maintien en vie** (RC3) : lorsque le service en amont reste silencieux, il envoie toutes les 2 secondes une ligne de commentaire SSE (`: opencodex heartbeat`), ignorée par l’analyseur, afin de réarmer la minuterie d’inactivité de Codex. Une ligne de commentaire est ignorée par tous les analyseurs eventsource sans produire d’événement, donc les décodeurs Responses stricts ne voient jamais de variante inconnue. Le **délai maximal de blocage** est de 300 secondes par défaut (`stallTimeoutSec`). Une fois ce délai atteint, le service en amont est interrompu et `response.incomplete` est émis avec le motif `upstream_stall_timeout`, ce qui empêche une connexion bloquée d’immobiliser Codex indéfiniment. +Le pont émet également un **signal de maintien en vie** (RC3) : lorsque le service en amont reste silencieux, il envoie toutes les 2 secondes une ligne de commentaire SSE (`: opencodex heartbeat`), ignorée par l’analyseur, afin de réarmer la minuterie d’inactivité de Codex. Une ligne de commentaire est ignorée par tous les analyseurs eventsource sans produire d’événement, donc les décodeurs Responses stricts ne voient jamais de variante inconnue. Le **délai maximal de blocage** est de 600 secondes par défaut (`stallTimeoutSec`). Une fois ce délai atteint, le service en amont est interrompu et `response.incomplete` est émis avec le motif `upstream_stall_timeout`, ce qui empêche une connexion bloquée d’immobiliser Codex indéfiniment. Les appels d’outils sont répartis entre trois types d’éléments Responses à l’aide de la table des espaces de noms, de l’ensemble des outils libres et de l’ensemble des outils de recherche capturés par l’analyseur. Les espaces de noms MCP, les outils libres tels que `apply_patch` et les appels `tool_search` exécutés par le client peuvent ainsi effectuer un aller-retour complet. Une variante `buildResponseJSON()` produit à partir des mêmes événements un objet de réponse unique hors flux. diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index 207f8a4c08..53d37834f4 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -14,7 +14,7 @@ exécute des fonctionnalités d'assistance autour des demandes du fournisseur. | `hostname?` | `string` | `"127.0.0.1"` | Adresse de liaison. Les liaisons hors bouclage nécessitent `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL du proxy HTTP(S) sortant ou `${ENV_VAR}`. Appliquée à `HTTP_PROXY` / `HTTPS_PROXY` uniquement lorsque ces variables ne sont pas définies ; le bouclage reste dans `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Active une nouvelle tentative Responses identique lorsqu’une réponse ne contient ni texte ni appel d’outil. Cette tentative peut être facturée. `OCX_EMPTY_COMPLETION_RETRY=0` la désactive sans modifier la configuration ; les combinaisons et les tours de compactage routés restent exclus. | -| `stallTimeoutSec?` | `number` | `300` | Nombre de secondes sans données en amont avant `response.incomplete`. Minimum : 1. | +| `stallTimeoutSec?` | `number` | `600` | Nombre de secondes sans données en amont avant `response.incomplete`. Minimum : 1. | | `connectTimeoutMs?` | `number` | `200000` | Délai maximal par tentative pour DNS/TCP/TLS et les en-têtes finaux ; il prend fin avant la génération du corps. | | `shutdownTimeoutMs?` | `number` | `5000` | Délai de vidange gracieux avant l’annulation des tours actifs. | | `websockets?` | `boolean` | `false` | Annonce et autorise la route WebSocket Responses destinée aux clients. La valeur false maintient les clients sur HTTP/SSE ; elle ne désactive pas une optimisation WebSocket canonique admissible vers ChatGPT en amont. | diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index cd2ce3db56..89aa5f1266 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -89,7 +89,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン | `done` | `response.completed`(usage 付き) | | `error` | `response.failed`(`last_error` 付き) | -ブリッジは **ハートビートキープアライブ**(RC3)も実行します。上流からデータが来ないとき 2 秒ごとにパーサーが無視する `: opencodex heartbeat` SSE コメント行を送り、Codex のアイドルタイマーを再開します。コメント行はイベントを生成せずに任意の eventsource パーサーに破棄されるため、厳格な Responses デコーダは未知のバリアントを決して見ません。デフォルトの **stall deadline** は 300 秒(`stallTimeoutSec`)です。この時間を超えると上流を中断し、理由が `upstream_stall_timeout` の `response.incomplete` を送り、接続が延々とぶら下がらないようにします。 +ブリッジは **ハートビートキープアライブ**(RC3)も実行します。上流からデータが来ないとき 2 秒ごとにパーサーが無視する `: opencodex heartbeat` SSE コメント行を送り、Codex のアイドルタイマーを再開します。コメント行はイベントを生成せずに任意の eventsource パーサーに破棄されるため、厳格な Responses デコーダは未知のバリアントを決して見ません。デフォルトの **stall deadline** は 600 秒(`stallTimeoutSec`)です。この時間を超えると上流を中断し、理由が `upstream_stall_timeout` の `response.incomplete` を送り、接続が延々とぶら下がらないようにします。 ツール呼び出しはパーサーが取得した名前空間マップ、freeform 集合、tool-search 集合を使って 3 種類の Responses 項目タイプに振り分けます — そのため MCP 名前空間、`apply_patch` スタイルの freeform ツール、クライアントが実行する `tool_search` がすべてラウンドトリップします。`buildResponseJSON()` 変種は同じイベントから単一の非ストリーミングレスポンスオブジェクトを生成します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 86b6cbfa5f..79de990b21 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -13,7 +13,7 @@ description: リスナー、リモート アクセス、アドミッション | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | | `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | -| `stallTimeoutSec?` | `number` | `300` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 +| `stallTimeoutSec?` | `number` | `600` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | | `websockets?` | `boolean` | `false` | クライアント向け Responses WebSocket パスを広告して許可します。false の場合クライアントは HTTP/SSE を使いますが、対象となる canonical ChatGPT upstream WS 最適化は無効にしません。 | diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index fa3056fb49..e070f51257 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -103,7 +103,7 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se 브리지는 **하트비트 킵얼라이브**(RC3)도 실행합니다. 업스트림에서 데이터가 오지 않을 때 2초마다 파서가 무시하는 `: opencodex heartbeat` SSE 주석 줄을 보내 Codex의 유휴 타이머를 다시 시작합니다. 주석 줄은 이벤트를 생성하지 않고 모든 eventsource 파서에 의해 버려지므로, 엄격한 Responses 디코더는 -알 수 없는 variant를 절대 보지 못합니다. 기본 **stall deadline**은 300초(`stallTimeoutSec`)입니다. +알 수 없는 variant를 절대 보지 못합니다. 기본 **stall deadline**은 600초(`stallTimeoutSec`)입니다. 이 시간을 넘기면 업스트림을 중단하고 이유가 `upstream_stall_timeout`인 `response.incomplete`를 내보내 연결이 끝없이 매달리지 않게 합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 879b9d40a6..82349409f6 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -13,7 +13,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 `OPENCODEX_API_AUTH_TOKEN`이 필요합니다. | | `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | | `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출이 없는 Responses 턴을, 터미널 이벤트 전에 스트림이 종료된 경우를 포함해 동일한 요청으로 한 번 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | -| `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | +| `stallTimeoutSec?` | `number` | `600` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. | | `websockets?` | `boolean` | `false` | 클라이언트용 Responses WebSocket 경로를 광고하고 허용합니다. `false`이면 클라이언트는 HTTP/SSE를 사용하며, 적격 canonical ChatGPT 업스트림 WS 최적화는 비활성화하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 96a3aec6dc..15316348f3 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -106,7 +106,9 @@ understands: The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits an SSE comment line (`: opencodex heartbeat`) every 2 seconds to re-arm Codex's idle timer. Comment lines are discarded by every eventsource parser without producing an event, so strict Responses decoders -never see an unknown variant. The default **stall deadline** is 300 seconds (`stallTimeoutSec`); +never see an unknown variant. Buffered Responses compaction reports non-empty text and reasoning deltas as upstream progress, +while gateway comment keepalives do not reset its stall watchdog. +The default **stall deadline** is 600 seconds (`stallTimeoutSec`); reaching it aborts the upstream and emits `response.incomplete` with reason `upstream_stall_timeout`, preventing a hung connection from blocking Codex indefinitely. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 832817a205..94a8dbccc6 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -15,7 +15,7 @@ runs helper features around provider requests. | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL, `${ENV_VAR}`, or `"auto"`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. `"auto"` reads the Windows system proxy (WinINET `ProxyEnable`/`ProxyServer`, `https=` then `http=` entry) once at process start and logs the host it chose. On other platforms, or when the system proxy is off, SOCKS-only, or unreadable, it uses direct egress and says so. PAC/WPAD and live proxy changes are not followed; restart the service after changing the system proxy. | | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | -| `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | +| `stallTimeoutSec?` | `number` | `600` | Seconds without upstream data before `response.incomplete`. Minimum 1. | | `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index c46da773c0..5e213ac13e 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -116,7 +116,7 @@ src/ 2 секунды генерирует комментарий-строку SSE (`: opencodex heartbeat`), чтобы перезапускать таймер простоя Codex. Комментарий отбрасывается любым eventsource-парсером без создания события, поэтому строгие декодеры Responses никогда не видят неизвестный вариант. **Дедлайн зависания** по -умолчанию — 300 секунд (`stallTimeoutSec`); по его достижении запрос к вышестоящей стороне +умолчанию — 600 секунд (`stallTimeoutSec`); по его достижении запрос к вышестоящей стороне прерывается и генерируется `response.incomplete` с причиной `upstream_stall_timeout`, что не даёт зависшему соединению блокировать Codex бесконечно. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 306534a3e1..9daed0ce73 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -14,7 +14,7 @@ description: Listener, удалённый доступ, admission key, тайм | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; loopback всегда остаётся в `NO_PROXY`. | | `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если в turn нет ни текста, ни tool call, включая случай, когда stream завершается до terminal event. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | -| `stallTimeoutSec?` | `number` | `300` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | +| `stallTimeoutSec?` | `number` | `600` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. | | `websockets?` | `boolean` | `false` | Объявляет и разрешает клиентский WebSocket-путь Responses. При false клиенты используют HTTP/SSE; это не отключает подходящую upstream WS-оптимизацию canonical ChatGPT. | diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 24d2bbb7aa..e0aad2f229 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -124,7 +124,7 @@ Köprü ayrıca bir **kalp atışı canlı tutması (heartbeat keep-alive)** ça yeniden kurmak için her 2 saniyede bir ayrıştırıcı tarafından yok sayılan `: opencodex heartbeat` SSE yorum satırı yayar. Yorum satırı, olay üretmeden her eventsource ayrıştırıcısı tarafından atılır, böylece katı Responses kod çözücüleri -asla bilinmeyen bir varyant görmez. Varsayılan **durma süresi sınırı** 300 +asla bilinmeyen bir varyant görmez. Varsayılan **durma süresi sınırı** 600 saniyedir (`stallTimeoutSec`); bu sınıra ulaşılması yukarı akışı iptal eder ve `upstream_stall_timeout` nedeni ile `response.incomplete` yayar, böylece askıda kalan bir bağlantının Codex'i süresiz olarak engellemesi önlenir. diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 47c6147904..36d0269dcc 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -15,7 +15,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | `hostname?` | `string` | `"127.0.0.1"` | Bağlama adresi. Geri döngü olmayan bağlamalar `OPENCODEX_API_AUTH_TOKEN` gerektirir. | | `proxy?` | `string` | — | Giden HTTP(S) proxy URL'si veya `${ENV_VAR}`. Yalnızca bu değişkenler ayarlanmadığında `HTTP_PROXY` / `HTTPS_PROXY`'ye uygulanır; geri döngü `NO_PROXY` içinde kalır. | | `emptyCompletionRetry?` | `boolean` | `false` | Metin veya araç çağrısı içermeyen bir Responses tamamlamasını aynı istekle bir kez yeniden denemeyi açıkça etkinleştirir. Yeniden deneme ücretlendirilebilir. `OCX_EMPTY_COMPLETION_RETRY=0`, yapılandırmayı değiştirmeden devre dışı bırakır; combo ve routed-compaction turları hariçtir. | -| `stallTimeoutSec?` | `number` | `300` | `response.incomplete` öncesinde yukarı akış verisi olmadan geçen saniye. Minimum 1. | +| `stallTimeoutSec?` | `number` | `600` | `response.incomplete` öncesinde yukarı akış verisi olmadan geçen saniye. Minimum 1. | | `connectTimeoutMs?` | `number` | `200000` | Deneme başına DNS/TCP/TLS/nihai başlık son tarihi; gövde üretiminden önce biter. | | `shutdownTimeoutMs?` | `number` | `5000` | Aktif turlar iptal edilmeden önce zarif boşaltma süresi sınırı. | | `websockets?` | `boolean` | `false` | Responses WebSocket yolu için `supports_websockets` bildirin. False, HTTP/SSE'yi tutar. | diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index 94c5eb8ea0..57c7cddd7e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -104,7 +104,7 @@ src/ 桥接器还会运行**心跳保活**(RC3):上游没有数据时,每 2 秒发送一个 SSE 注释行 (`: opencodex heartbeat`)来重新启动 Codex 的空闲计时器。注释行会被每个 eventsource 解析器丢弃而不会产生任何事件,因此严格的 Responses 解码器永远不会 -遇到未知 variant。默认**停滞截止时间**为 300 秒(`stallTimeoutSec`);达到该时限后 +遇到未知 variant。默认**停滞截止时间**为 600 秒(`stallTimeoutSec`);达到该时限后 会中止上游,并发出 reason 为 `upstream_stall_timeout` 的 `response.incomplete`, 避免挂起的连接无限期阻塞 Codex。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index bee7942398..73725ab4c5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -14,7 +14,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 出站 HTTP(S) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;回环地址始终保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses turn 既无文本也无工具调用时,使用相同请求重试一次,包括流在终止事件之前结束的情况。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | -| `stallTimeoutSec?` | `number` | `300` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | +| `stallTimeoutSec?` | `number` | `600` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 | | `websockets?` | `boolean` | `false` | 声明并允许面向客户端的 Responses WebSocket 路径。设为 false 时客户端使用 HTTP/SSE;它不会禁用符合条件的 canonical ChatGPT 上游 WS 优化。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index daf9bc9080..1966beaedf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -104,7 +104,7 @@ src/ 橋接器還會執行**心跳保活**(RC3):上游沒有資料時,每 2 秒傳送一個 SSE 註解行 (`: opencodex heartbeat`)來重新啟動 Codex 的空閒計時器。註解行會被每個 eventsource 解析器丟棄而不會產生任何事件,因此嚴格的 Responses 解碼器永遠不會 -遇到未知 variant。預設**停滯截止時間**為 300 秒(`stallTimeoutSec`);達到該時限後 +遇到未知 variant。預設**停滯截止時間**為 600 秒(`stallTimeoutSec`);達到該時限後 會中止上游,並發出 reason 為 `upstream_stall_timeout` 的 `response.incomplete`, 避免掛起的連線無限期阻塞 Codex。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index e85594740d..b9ededde3e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -13,7 +13,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `hostname?` | `string` | `"127.0.0.1"` | 綁定位址。非回送綁定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 對外 HTTP(S) 代理 URL 或 `${ENV_VAR}`。僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`;回送保留在 `NO_PROXY` 中。 | | `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | -| `stallTimeoutSec?` | `number` | `300` | 在 `response.incomplete` 前無上游資料的秒數。最小 1。 | +| `stallTimeoutSec?` | `number` | `600` | 在 `response.incomplete` 前無上游資料的秒數。最小 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次嘗試的 DNS/TCP/TLS/final-header 截止時間;它在 body 生成前結束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 在中止活躍回合前的優雅排空截止時間。 | | `websockets?` | `boolean` | `false` | 廣告並允許面向 client 的 Responses WebSocket 路徑。False 時 client 使用 HTTP/SSE;不會停用符合條件的 canonical ChatGPT upstream WS 最佳化。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0fbe7cf746..624cfd11d2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -486,6 +486,7 @@ "combo-management-api.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", + "compaction-progress.test.ts": "responses", "combos.test.ts": "codex-integration", "command-code-error-finish.test.ts": "providers", "command-code-provider.test.ts": "providers", diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 1faa9c0cbb..7c9ee429aa 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2550,6 +2550,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): let payload: unknown; try { payload = JSON.parse(event.data); } catch { continue; } if (!isPlainObject(payload)) continue; + // Buffered compaction output still represents real upstream progress. + // Gateway comment keepalives must not hide a stalled model. + if ( + (payload.type === "response.output_text.delta" + || payload.type === "response.reasoning_summary_text.delta" + || payload.type === "response.reasoning_text.delta") + && typeof payload.delta === "string" + && payload.delta.length > 0 + ) { + yield { type: "heartbeat" }; + } switch (payload.type) { case "response.output_text.delta": if (typeof payload.delta === "string") { diff --git a/src/stall-timeout.ts b/src/stall-timeout.ts index 06a7ef3ca2..d1b29ef16c 100644 --- a/src/stall-timeout.ts +++ b/src/stall-timeout.ts @@ -2,10 +2,10 @@ * Bridge upstream stall budget: seconds of silence (no adapter events) before the * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`. * - * Raised from 90s so long reasoning + large tool writes are not cut mid-turn. - * Hung streams still die; they just get a more realistic window. + * Allow ten minutes for long reasoning and buffered compaction requests. + * Explicit per-installation overrides still control the inactivity budget. */ -export const DEFAULT_STALL_TIMEOUT_SEC = 300; +export const DEFAULT_STALL_TIMEOUT_SEC = 600; /** * Resolve the effective bridge stall deadline for a turn. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index db2583b00b..6cf3a9a4a0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -321,6 +321,7 @@ "combo-management-api.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", + "compaction-progress.test.ts": "responses", "combos.test.ts": "codex-integration", "command-code-error-finish.test.ts": "providers", "command-code-provider.test.ts": "providers", diff --git a/tests/lib/stall-timeout.test.ts b/tests/lib/stall-timeout.test.ts index f1b53700e8..fe80bb0294 100644 --- a/tests/lib/stall-timeout.test.ts +++ b/tests/lib/stall-timeout.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_STALL_TIMEOUT_SEC, resolveStallTimeoutSec } from "../../src/stall-timeout"; describe("resolveStallTimeoutSec", () => { - test("defaults to 300 seconds when unset", () => { - expect(DEFAULT_STALL_TIMEOUT_SEC).toBe(300); - expect(resolveStallTimeoutSec(undefined)).toBe(300); + test("defaults to 600 seconds when unset", () => { + expect(DEFAULT_STALL_TIMEOUT_SEC).toBe(600); + expect(resolveStallTimeoutSec(undefined)).toBe(600); }); test("honors finite configured values with a minimum of 1", () => { @@ -15,8 +15,8 @@ describe("resolveStallTimeoutSec", () => { }); test("rejects non-finite values back to the default", () => { - expect(resolveStallTimeoutSec(Number.NaN)).toBe(300); - expect(resolveStallTimeoutSec(Number.POSITIVE_INFINITY)).toBe(300); - expect(resolveStallTimeoutSec(Number.NEGATIVE_INFINITY)).toBe(300); + expect(resolveStallTimeoutSec(Number.NaN)).toBe(600); + expect(resolveStallTimeoutSec(Number.POSITIVE_INFINITY)).toBe(600); + expect(resolveStallTimeoutSec(Number.NEGATIVE_INFINITY)).toBe(600); }); }); diff --git a/tests/responses/compaction-progress.test.ts b/tests/responses/compaction-progress.test.ts new file mode 100644 index 0000000000..a512f60db2 --- /dev/null +++ b/tests/responses/compaction-progress.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; + +const encoder = new TextEncoder(); +const provider = { adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key" as const }; +const completed = { + type: "response.completed", + response: { + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Final summary" }] }], + }, +}; +const frame = (payload: unknown) => "data: " + JSON.stringify(payload) + "\n\n"; + +function harness() { + const budget = createTranslatorBudget(); + let upstream!: ReadableStreamDefaultController; + let ended = false; + let beat = () => {}; + const close = () => { + if (!ended) { ended = true; upstream.close(); } + }; + const body = new ReadableStream({ start(controller) { upstream = controller; } }); + const adapter = createResponsesPassthroughAdapter(provider); + const stream = bridgeToResponsesSSE( + adapter.parseStream(new Response(body), budget), "example-model", undefined, undefined, undefined, + close, 500, + { + translatorBudget: budget, compaction: true, stallTimeoutSec: 1, + timers: { + setInterval(callback) { beat = callback; return 1; }, + clearInterval() { beat = () => {}; }, + }, + }, + ); + const text = new Response(stream).text(); + return { + text, close, + send(value: string) { if (!ended) upstream.enqueue(encoder.encode(value)); }, + async tick() { + // Finish each pending adapter read before the manual watchdog beat. + await Bun.sleep(0); + beat(); + await Bun.sleep(0); + }, + }; +} + +describe("buffered Responses compaction progress", () => { + for (const type of ["response.output_text.delta", "response.reasoning_summary_text.delta", "response.reasoning_text.delta"]) { + test(type + " keeps compaction alive without exposing buffered content", async () => { + const h = harness(); + try { + for (let i = 0; i < 6; i++) { + h.send(frame({ type, delta: "Buffered progress" })); + await h.tick(); + } + h.send(frame(completed)); + h.close(); + const wire = await h.text; + expect(wire).toContain("event: response.completed"); + expect(wire).toContain('"type":"compaction"'); + expect(wire).not.toContain("upstream_stall_timeout"); + expect(wire).not.toContain("Buffered progress"); + expect(wire).not.toContain("event: response.output_text.delta"); + } finally { h.close(); } + }); + } + + test("comment keepalives and empty or malformed deltas cannot hide a stalled provider", async () => { + const h = harness(); + try { + for (let i = 0; i < 6; i++) { + h.send(": keep-alive\n\n" + frame({ type: "response.output_text.delta", delta: "" }) + + frame({ type: "response.reasoning_text.delta", delta: 42 })); + await h.tick(); + } + h.close(); + const wire = await h.text; + expect(wire).toContain("upstream_stall_timeout"); + expect(wire).not.toContain("event: response.completed"); + } finally { h.close(); } + }); + + test("progress does not duplicate buffered text or override the completed snapshot", async () => { + const budget = createTranslatorBudget(); + try { + const input = frame({ type: "response.output_text.delta", delta: "Partial text" }) + + frame({ type: "response.output_text.done", text: "Done text" }) + frame(completed); + const events = []; + for await (const event of createResponsesPassthroughAdapter(provider).parseStream(new Response(input), budget)) events.push(event); + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: "Final summary" }]); + expect(events.filter(event => event.type === "done")).toHaveLength(1); + } finally { budget.dispose(); } + }); +}); From 6d50ffad1ffac8d8f5ce6dbe83aae82b26e9561d Mon Sep 17 00:00:00 2001 From: Hylouis233 <88263959+Hylouis233@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:18:30 +0000 Subject: [PATCH 2/3] fix: address watchdog review regressions and test cleanup --- .../docs/ja/reference/configuration/server.md | 2 +- structure/01_runtime.md | 2 +- structure/04_transports-and-sidecars.md | 13 ++++++------- .../cursor/cursor-stream-health.test.ts | 2 +- tests/responses/compaction-progress.test.ts | 6 +++--- .../web-search/web-search-timeout-plan.test.ts | 8 ++++---- tests/web-search/web-search.test.ts | 17 ++++++++--------- 7 files changed, 24 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 79de990b21..1022decc83 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -13,7 +13,7 @@ description: リスナー、リモート アクセス、アドミッション | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | | `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | | `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses ターンを、ターミナルイベント前にストリームが終了した場合も含め、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | -| `stallTimeoutSec?` | `number` | `600` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 +| `stallTimeoutSec?` | `number` | `600` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | | `websockets?` | `boolean` | `false` | クライアント向け Responses WebSocket パスを広告して許可します。false の場合クライアントは HTTP/SSE を使いますが、対象となる canonical ChatGPT upstream WS 最適化は無効にしません。 | diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 7fb1c00997..bee272ca7a 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -113,7 +113,7 @@ absolute environment candidates for lexical app-bundle/version-manager reporting This check does not attest or admit a selected runtime. The command exposes no private mutation authority and does not query a registry, execute Codex/npm, install, repair, stop, restart, or change configuration/cache state. -The bridge enforces a heartbeat stall deadline. It defaults to 300 seconds sampled on a 2 s tick +The bridge enforces a heartbeat stall deadline. It defaults to 600 seconds sampled on a 2 s tick (`src/stall-timeout.ts`) and is configurable, so treat the number as a default rather than an invariant; sidecars keep their own clocks. On expiry the stream is closed and the upstream request cancelled. If the adapter generator ends without an explicit done/error event, the response is marked diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c26bc56577..6c0a5b9dbc 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -687,11 +687,10 @@ frame rather than always emitting `response.completed`. If the response status i ## Heartbeat and stall deadline -The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat`) during upstream -silence to re-arm Codex's idle timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE -bytes re-arm it). A comment line is discarded by every eventsource parser without producing an event, -so strict Responses decoders never see an unknown variant. Those bridge-enqueued keepalive frames do -NOT count as activity for the bridge's own watchdog: a bounded stall deadline (default 300 s, +The HTTP/SSE bridge emits a typed `response.heartbeat` frame during wire silence so event-based +Codex readers can re-arm their idle timer. Clients requiring comment-only keepalives opt into +`heartbeatStyle: "comment"`. Those bridge-enqueued keepalive frames do NOT count as activity for +the bridge's own watchdog: a bounded stall deadline (default 600 s, configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with `response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. @@ -827,8 +826,8 @@ raw response-byte inactivity for a routed-model iteration and resets on every no from 200 s so an unavailable/limit-exhausted search backend degrades within ~1 min instead of hanging the whole turn, #398). The effective web-search bridge watchdog is -`max(base stall, connect timeout, routed-model stall, sidecar timeout) + 30 s` (230 s at defaults, -dominated by the routed-model stall clock), +`max(base stall, connect timeout, routed-model stall, sidecar timeout) + 30 s` (630 s at defaults, +dominated by the base bridge stall clock), with seam heartbeats between bounded units. None of these clocks is a total generation deadline. ## Reasoning and tool-result compatibility diff --git a/tests/providers/cursor/cursor-stream-health.test.ts b/tests/providers/cursor/cursor-stream-health.test.ts index dc7b572bf1..53a1268d6d 100644 --- a/tests/providers/cursor/cursor-stream-health.test.ts +++ b/tests/providers/cursor/cursor-stream-health.test.ts @@ -18,7 +18,7 @@ import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapter * T04 (devlog 260822_senpi_cursor_transfer/110): inbound stream-health watchdog. * A turn that received its first frame but then goes silent (or heartbeat-only) * must fail at the transport with a typed stall error instead of waiting for the - * 300s bridge stall watchdog (issue #2210 class). + * 600s bridge stall watchdog (issue #2210 class). */ function agentFrame(message: Parameters>[1]): Uint8Array { diff --git a/tests/responses/compaction-progress.test.ts b/tests/responses/compaction-progress.test.ts index a512f60db2..b7e4f45bac 100644 --- a/tests/responses/compaction-progress.test.ts +++ b/tests/responses/compaction-progress.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; import { bridgeToResponsesSSE } from "../../src/bridge"; -import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; const encoder = new TextEncoder(); const provider = { adapter: "openai-responses", baseUrl: "https://gateway.example/v1", authMode: "key" as const }; @@ -15,7 +15,7 @@ const completed = { const frame = (payload: unknown) => "data: " + JSON.stringify(payload) + "\n\n"; function harness() { - const budget = createTranslatorBudget(); + const budget = createTestTranslatorBudget(); let upstream!: ReadableStreamDefaultController; let ended = false; let beat = () => {}; @@ -85,7 +85,7 @@ describe("buffered Responses compaction progress", () => { }); test("progress does not duplicate buffered text or override the completed snapshot", async () => { - const budget = createTranslatorBudget(); + const budget = createTestTranslatorBudget(); try { const input = frame({ type: "response.output_text.delta", delta: "Partial text" }) + frame({ type: "response.output_text.done", text: "Done text" }) + frame(completed); diff --git a/tests/web-search/web-search-timeout-plan.test.ts b/tests/web-search/web-search-timeout-plan.test.ts index 40f8d5e800..97209f6d52 100644 --- a/tests/web-search/web-search-timeout-plan.test.ts +++ b/tests/web-search/web-search-timeout-plan.test.ts @@ -88,9 +88,9 @@ describe("routed-model web-search inactivity timeout", () => { }); test("bridge budget covers every timeout plus a thirty-second margin", () => { - expect(webSearchStallTimeoutSec(undefined, 200_000, 200_000, 200_000)).toBe(330); - expect(webSearchStallTimeoutSec(undefined, 200_000, 240_000, 200_000)).toBe(330); - expect(webSearchStallTimeoutSec(600, 200_000, 240_000, 200_000)).toBe(630); + expect(webSearchStallTimeoutSec(undefined, 200_000, 200_000, 200_000)).toBe(630); + expect(webSearchStallTimeoutSec(undefined, 200_000, 240_000, 200_000)).toBe(630); + expect(webSearchStallTimeoutSec(900, 200_000, 240_000, 200_000)).toBe(930); const maximum = webSearchStallTimeoutSec(undefined, 200_000, 2_147_483_647, 200_000); expect(maximum).toBe(2_147_514); @@ -103,7 +103,7 @@ describe("routed-model web-search inactivity timeout", () => { webSearchSidecar: { routedModelStallTimeoutMs: 240_000 }, }))).toMatchObject({ routedModelStallTimeoutMs: 240_000, - stallTimeoutSec: 330, + stallTimeoutSec: 630, }); }); }); diff --git a/tests/web-search/web-search.test.ts b/tests/web-search/web-search.test.ts index ce8f6e0f8a..e09e75ce66 100644 --- a/tests/web-search/web-search.test.ts +++ b/tests/web-search/web-search.test.ts @@ -2116,25 +2116,25 @@ describe("web-search stall deadline", () => { test("planWebSearch computes the effective stall deadline covering bounded silent units", () => { const parsed = parsedWithWebSearch(); const auth = new Headers({ authorization: "Bearer chatgpt" }); - // defaults: max(300 bridge, connect 200s, sidecar 200s) + 30 margin - expect(planWebSearch(config(), parsed, false, auth, routedProvider, "model")?.stallTimeoutSec).toBe(330); + // defaults: max(600 bridge, connect 200s, sidecar 60s) + 30 margin + expect(planWebSearch(config(), parsed, false, auth, routedProvider, "model")?.stallTimeoutSec).toBe(630); // a larger user-configured stallTimeoutSec dominates - expect(planWebSearch(config({ stallTimeoutSec: 600 }), parsed, false, auth, routedProvider, "model")?.stallTimeoutSec).toBe(630); - // small unit budgets -> the bridge's 300s default dominates + expect(planWebSearch(config({ stallTimeoutSec: 900 }), parsed, false, auth, routedProvider, "model")?.stallTimeoutSec).toBe(930); + // small unit budgets -> the bridge's 600s default dominates expect(planWebSearch( config({ connectTimeoutMs: 30_000, webSearchSidecar: { timeoutMs: 30_000, routedModelStallTimeoutMs: 30_000 }, }), parsed, false, auth, routedProvider, "model", - )?.stallTimeoutSec).toBe(330); + )?.stallTimeoutSec).toBe(630); }); test("webSearchStallTimeoutSec helper covers the largest bounded unit plus margin", () => { - expect(webSearchStallTimeoutSec(undefined, undefined, 200_000)).toBe(330); + expect(webSearchStallTimeoutSec(undefined, undefined, 200_000)).toBe(630); expect(webSearchStallTimeoutSec(90, 200_000, 200_000)).toBe(230); - expect(webSearchStallTimeoutSec(600, 200_000, 200_000)).toBe(630); - expect(webSearchStallTimeoutSec(undefined, 30_000, 30_000)).toBe(330); + expect(webSearchStallTimeoutSec(900, 200_000, 200_000)).toBe(930); + expect(webSearchStallTimeoutSec(undefined, 30_000, 30_000)).toBe(630); }); test("#398: the default sidecar search deadline is bounded (60s, not 200s)", () => { @@ -2651,4 +2651,3 @@ describe("connection-reset recovery parity on the web-search legs", () => { expect(typeof attempts[1]!.body).toBe("string"); }); }); - From b7d0e3436e2594f9b904ed17b278db75e7ee49ce Mon Sep 17 00:00:00 2001 From: Hylouis233 <88263959+Hylouis233@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:29:53 +0000 Subject: [PATCH 3/3] test: forward admitted lock capabilities to nested Windows fixtures --- scripts/test-run-lock.ts | 10 +++++----- tests/preload.ts | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/scripts/test-run-lock.ts b/scripts/test-run-lock.ts index edabe8ccfd..19806798d9 100644 --- a/scripts/test-run-lock.ts +++ b/scripts/test-run-lock.ts @@ -150,11 +150,11 @@ function machineDiscriminator(hostName: string): string { } /** - * Read a Windows-wrapper-provided lock path without repeating effective-user - * discovery in every Bun worker. Bare and POSIX runs never trust this environment - * value. Wrapped Windows paths are constrained to the exact host-specific lock - * filename and namespace shape; the wrapper remains responsible for resolving - * and validating the directory. + * Read a Windows lock path supplied by a wrapper or an admitted parent preload + * without repeating effective-user discovery in every Bun worker. Runs without + * an inherited run ID, and POSIX runs, never trust this environment value. + * Inherited paths are constrained to the exact host-specific lock filename and + * namespace shape; the originating process resolves and validates the directory. */ export function resolveInheritedTestRunLock( options: ResolveInheritedTestRunLockOptions, diff --git a/tests/preload.ts b/tests/preload.ts index ad64ff6384..57df06f158 100644 --- a/tests/preload.ts +++ b/tests/preload.ts @@ -17,7 +17,10 @@ import { acquireTestRunLock, resolveBareTestRunIdentity, resolveInheritedTestRunLock, + resolveWrappedTestRunLockPath, TEST_RUN_ID_ENV, + TEST_RUN_LOCK_PATH_ENV, + TEST_RUN_LOCK_TOKEN_ENV, } from "../scripts/test-run-lock"; import { rmSync } from "node:fs"; @@ -76,18 +79,27 @@ const inheritedLock = resolveInheritedTestRunLock({ wrappedRunId, env: process.env, }); +const lockPath = inheritedLock?.lockPath + ?? (process.platform === "win32" ? resolveWrappedTestRunLockPath({ env: process.env }) : undefined); process.env[TEST_RUN_ID_ENV] = runId; -await acquireTestRunLock({ +const lock = await acquireTestRunLock({ runId, ownerPid: bareIdentity.ownerPid, - lockPath: inheritedLock?.lockPath, - validatedRuntimePath: inheritedLock !== undefined, + lockPath, + validatedRuntimePath: lockPath !== undefined, joinExistingOwnerToken: inheritedLock?.ownerToken, onWait: owner => console.warn( `[test] bare Bun worker ${process.pid} is waiting for test run${owner ? ` pid ${owner.pid}` : ""} to release the user lock.`, ), }); +// Bare Windows runs also spawn nested bun:test fixtures. Pass the capability +// only after acquiring or joining the validated lock, just like the wrapper. +if (process.platform === "win32" && lockPath && lock.owner) { + process.env[TEST_RUN_LOCK_PATH_ENV] = lockPath; + process.env[TEST_RUN_LOCK_TOKEN_ENV] = lock.owner.token; +} + // Clean up only the root this preload created. The `bun run test` wrapper owns its own. process.on("exit", () => { try { rmSync(isolated.root, { recursive: true, force: true }); } catch { /* best effort at exit */ }