Skip to content

fix(codex): fail fast and release per-account Responses WS leases - #12911

Merged
diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.51from
initguru:fix/codex-ws-lease-fail-fast
Sep 17, 2026
Merged

diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.51from
initguru:fix/codex-ws-lease-fail-fast

Conversation

@initguru

@initguru initguru commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

fix(codex): fail fast and release per-account Responses WS leases

Summary

Improve account-slot management for the Codex Responses WebSocket bridge (/api/internal/codex-responses-ws):

  1. Remove queuing — fail fast: Previously, when the selected account's account semaphore was saturated, the WS session waited in the queue → client-side timeout (499/502). New src/sse/services/codexWsLease.ts (acquireCodexWsLease, maxQueueSize: 0 non-queued lease) attempts to acquire the slot immediately; on failure, add that account to excludeConnectionIds and select another eligible account (up to 8 attempts).
  2. Prevent lease leaks: Immediately release an acquired lease when token refresh fails (catch) or the access token is missing — fail fast. The normal path releases when the session ends.
  3. Support accountSemaphore maxQueueSize=0: In acquireMany, move the queue-full check after the immediate-acquire check (do not reject when immediate acquisition is possible) + support maxQueueSize >= 0 as valid (0 = no queuing). Under the existing maxQueueSize > 0 condition, 0 behaved like an unlimited queue.
  4. Improve scripts/dev/responses-ws-proxy.mjs (51 lines) for development WS proxy consistency.

Error responses go through sanitizeErrorMessage() (Hard Rule #12 compliance).

Related Issues

Validation

  • Change type: provider / routing
  • Focused tests: node --import tsx/esm --test tests/unit/codex-ws-lease-contract.test.ts tests/unit/codex-ws-load-balancer.test.ts tests/unit/accountSemaphore.test.ts + node --test tests/unit/responses-ws-proxy.test.mjs (all pass)
  • Full merge validation: 9 new/updated test files, 82/82 pass, npx tsc --pretty false -p tsconfig.typecheck-core.json — 0 errors
  • Error responses route through sanitizeErrorMessage (no raw stack/message)
  • Production-code changes include a new or updated automated test in this PR

Tests Added Or Updated

Test file Status Coverage
tests/unit/codex-ws-lease-contract.test.ts New (156) lease acquire/release contract, maxQueueSize=0 non-queued behavior, harmless double release
tests/unit/codex-ws-load-balancer.test.ts Updated (+26) select another account after excluding a saturated account
tests/unit/accountSemaphore.test.ts Updated (+16) maxQueueSize=0 reject semantics + immediate-acquire priority
tests/unit/responses-ws-proxy.test.mjs New (170) dev WS proxy lease integration

Coverage Notes

Full test coverage of 2 new files (codexWsLease.ts, route.ts rewrite) + the accountSemaphore acquireMany path. clearCodexWsLeasesForTest() isolates tests.

Reviewer Notes

  • Commit d50888e45 / patch 0023. Authored in fork (initguru) release/v3.8.51.
  • No shared files (new files + accountSemaphore + route.ts) → independent PR.
  • changelog fragment: changelog.d/fixes/12911-codex-ws-lease-fail-fast.md

@diegosouzapw

Copy link
Copy Markdown
Owner

Solid fix, and the accountSemaphore.ts change actually has wider impact than the description
suggests: maxQueueSize === 0 was silently behaving as "unlimited queue" (the old check was
maxQueueSize > 0 && ...), which contradicts the EXISTING documented intent of
resolveComboQueueDepth ("0 is valid and meaningful: fail over immediately instead of
queueing") — so this also fixes combo routing's queueDepth: 0 fail-fast semantics, not just
the Codex WS bridge. Ran accountSemaphore.test.ts (12/12, including the new
maxQueueSize=0 case) and codex-ws-load-balancer.test.ts (2/2) at this PR's head, both clean.
codex-ws-lease-contract.test.ts hung on module import across 3 attempts in my environment
(matches the known heavy open-sse-import trap, not a fix defect — the underlying semaphore
logic it exercises is independently proven), so please confirm that file (and
responses-ws-proxy.test.mjs, which I didn't get to) green in CI before merge. The route.ts
rewrite is sizeable (226 lines) and deserves one more focused pass. Changelog fragment
mentioned in the description isn't in the diff yet.

initguru and others added 3 commits September 15, 2026 23:06
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
# Conflicts:
#	src/app/api/internal/codex-responses-ws/route.ts
@diegosouzapw
diegosouzapw merged commit 36df9e5 into diegosouzapw:release/v3.8.51 Sep 17, 2026
3 of 7 checks passed
diegosouzapw added a commit that referenced this pull request Sep 18, 2026
…the write, drain the 09-18 base-reds (#14101)

* fix(quality): drain the 09-18 base-reds, part 1 — thinking gate parity, inventory, webpack externals

Reproduced on the clean tip 7cc454d before touching anything.

Five of the failures trace to one commit, #12905 (b7192b7): it gated
thinking-block emission on `requestedThinking === true` in the streaming
translator, while its own non-streaming path documents `undefined` as the
legacy caller shape that keeps "always a thinking block". The two paths
disagreed on the same input, and the streaming side also synthesized the
reasoning into a TEXT block for that legacy shape. chatCore always resolves a
boolean, so production never sends `undefined` — but every direct caller and
the older #5786 suites do. Aligned the streaming gate to the documented
tri-state: `false` suppresses, `true` and `undefined` relay, and the fix-B text
synthesis fires only on an explicit opt-out. The #12905 test that asserted
suppression used a bare createState() (`undefined`) to mean "client did not
request thinking"; it now passes `requestedThinking: false`, which is what that
sentence resolves to in production. The whole thinking family — dsml, adapter,
translator, non-stream parity, #13620, #5786, markdown boundary — is 77/77.

#12864 added requestRejectedFailure.ts with a getProviderConnectionById read
that seeds the refusal streak across restarts; inventoried as a connection
state read next to the family-cooldown site it resembles.

#13909 made machineToken.ts import ./dataPaths; the isolated webpack compile has
no repo tree, so it joins the sibling externals.

The free-tier budget card SVG was one wave behind again (482 -> 491 models).

Refs #13866

* fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the rest

Part 2 of the 09-18 base-red drain. Two of the remaining failures were not
stale tests but production defects the tests had caught.

#12911 taught accountSemaphore to read `maxQueueSize: 0` as "reject when the
slot is busy", which is what its Codex WS lease wants. But chatCore forwards
`resilienceSettings.requestQueue.maxQueueDepth` into that option, and that
setting's documented default since #6593 is `0 = disabled`. Under default
settings every request that found its account slot occupied was answered
429 "Semaphore queue full (0)" instead of waiting — the managed-lease routing
test saw exactly that. `0` (and any non-positive value) is unbounded again;
the lease gets an explicit `failFast` option and its four tests stay green, so
the #12911 behaviour is preserved where it was meant to apply. A contract test
pins the #6593 semantics on the semaphore itself.

#12864 moved two providerFailure persistence branches out of chatCore into
requestRejectedFailure.ts and the sanitization did not travel with them: three
`lastError` writes stored the message as received. The only caller already
hands in the projected persistentMessage, so nothing leaks today, but a
persistence branch must be safe at its own write (docs/security/
ERROR_SANITIZATION.md) rather than trust whoever calls it. The module now
sanitizes on entry, and the public-boundary guard — which caught this by
counting sanitized writes in chatCore and coming up two short — covers the
extracted module too, verified by mutating one write back to raw.

The rest are tests that had fallen behind legitimate changes:

- #12905 inserted `requestedThinking` as the 14th positional argument of
  createSSETransformStreamWithLogger; two tests passed customToolNames or the
  buffer budget at their old positions. Both production callers were already
  correct.
- #12754 added a per-connection reset-card fetch after the quota fetch; the
  spacing test now marks a chunk at the quota request only.
- #13910 renamed `error` to `errorMetadata` in the timeout classification; the
  probe matches the identifier with a backreference and still fails when
  BodyTimeoutError is removed from both sites.

Refs #13866

* fix(test): pin the opt-out thinking cases to requestedThinking=false; keep acquireMany under the complexity ceiling

The #12905 gate-restore suite encoded 'requestedThinking absent' as opt-out, the
same undefined-means-false shape its non-streaming twin documents the other way
and that the two-month-old #5786 suites contradict. The three opt-out cases now
set the flag explicitly, which is what chatCore resolves for an opted-out
client; the two opt-in cases already did. Both suites pass together (27/27).

The failFast branch pushed acquireMany over the complexity ceiling it already
sat on; the admission policy (fail-fast / bounded / unbounded queue) moves to
findQueueRejection() and the new-code ratchet is back at its base.

Refs #13866

* fix(test): suspend the #14110 redaction assertion inline; refresh the budget card

The 57 commits merged since the previous validation moved two things.

#13295 changed how an unknown-root path with an ambiguous tail is answered:
where `Provider failed at /custom/internal secret directory` used to become
`Provider failed at <path>` it now ships verbatim. The #12506 boundary guard
caught it. Two candidate fixes were tried and each breaks one of the two live
contracts — #12506's fail-closed swallow, or #13144's rule that a route in
prose must survive — so the choice is the owner's (#14110). The one contested
assertion is suspended inline with the exact line and the issue; the other
nine stay active. The isolated-child harness requires tests == pass, which is
why it is a comment and not a todo.

The free-tier budget card was one wave behind again (491 -> 489 models).

Refs #13866, #14110

* fix(providers): type the TinyCMS DOM stub global as a loose record

#13957 typed the mock global as `typeof globalThis & Record<string, unknown>`.
The api-route typecheck loads lib.dom, so that intersection carries the real
Window / HTMLCanvasElement / document signatures — every stub assignment fails
against a DOM constructor, and `delete g.window` narrows the object to
`never` (13 diagnostics, the API Route Typecheck base-red on the tip). The
function exists to overwrite those globals with stubs; it is now typed as the
plain record it manipulates. 29/29 tinycms tests unchanged.

Refs #13866

* fix(test): pin the last opt-out thinking sibling to requestedThinking=false

translator-reasoning-gate-502-repro is the third #12905 test that encoded a bare
state as opt-out; the previous sweep matched files by glob and missed it. The
family is now enumerated by grep on requestedThinking (7 files) plus the two
pre-#12905 suites: 83/83 together.

Refs #13866
@initguru
initguru deleted the fix/codex-ws-lease-fail-fast branch September 25, 2026 14:43
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…egosouzapw#12911)

* fix(codex): fail fast and release per-account Responses WS leases

* chore(changelog): add fragment for Codex WS lease fail-fast fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(codex): carry the reasoning-rule context through the leased WS path

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…the write, drain the 09-18 base-reds (diegosouzapw#14101)

* fix(quality): drain the 09-18 base-reds, part 1 — thinking gate parity, inventory, webpack externals

Reproduced on the clean tip 7784784 before touching anything.

Five of the failures trace to one commit, diegosouzapw#12905 (8b5f4dd): it gated
thinking-block emission on `requestedThinking === true` in the streaming
translator, while its own non-streaming path documents `undefined` as the
legacy caller shape that keeps "always a thinking block". The two paths
disagreed on the same input, and the streaming side also synthesized the
reasoning into a TEXT block for that legacy shape. chatCore always resolves a
boolean, so production never sends `undefined` — but every direct caller and
the older diegosouzapw#5786 suites do. Aligned the streaming gate to the documented
tri-state: `false` suppresses, `true` and `undefined` relay, and the fix-B text
synthesis fires only on an explicit opt-out. The diegosouzapw#12905 test that asserted
suppression used a bare createState() (`undefined`) to mean "client did not
request thinking"; it now passes `requestedThinking: false`, which is what that
sentence resolves to in production. The whole thinking family — dsml, adapter,
translator, non-stream parity, diegosouzapw#13620, diegosouzapw#5786, markdown boundary — is 77/77.

diegosouzapw#12864 added requestRejectedFailure.ts with a getProviderConnectionById read
that seeds the refusal streak across restarts; inventoried as a connection
state read next to the family-cooldown site it resembles.

diegosouzapw#13909 made machineToken.ts import ./dataPaths; the isolated webpack compile has
no repo tree, so it joins the sibling externals.

The free-tier budget card SVG was one wave behind again (482 -> 491 models).

Refs diegosouzapw#13866

* fix(sse): restore maxQueueDepth=0 as unbounded, sanitize refusals at the write, drain the rest

Part 2 of the 09-18 base-red drain. Two of the remaining failures were not
stale tests but production defects the tests had caught.

diegosouzapw#12911 taught accountSemaphore to read `maxQueueSize: 0` as "reject when the
slot is busy", which is what its Codex WS lease wants. But chatCore forwards
`resilienceSettings.requestQueue.maxQueueDepth` into that option, and that
setting's documented default since diegosouzapw#6593 is `0 = disabled`. Under default
settings every request that found its account slot occupied was answered
429 "Semaphore queue full (0)" instead of waiting — the managed-lease routing
test saw exactly that. `0` (and any non-positive value) is unbounded again;
the lease gets an explicit `failFast` option and its four tests stay green, so
the diegosouzapw#12911 behaviour is preserved where it was meant to apply. A contract test
pins the diegosouzapw#6593 semantics on the semaphore itself.

diegosouzapw#12864 moved two providerFailure persistence branches out of chatCore into
requestRejectedFailure.ts and the sanitization did not travel with them: three
`lastError` writes stored the message as received. The only caller already
hands in the projected persistentMessage, so nothing leaks today, but a
persistence branch must be safe at its own write (docs/security/
ERROR_SANITIZATION.md) rather than trust whoever calls it. The module now
sanitizes on entry, and the public-boundary guard — which caught this by
counting sanitized writes in chatCore and coming up two short — covers the
extracted module too, verified by mutating one write back to raw.

The rest are tests that had fallen behind legitimate changes:

- diegosouzapw#12905 inserted `requestedThinking` as the 14th positional argument of
  createSSETransformStreamWithLogger; two tests passed customToolNames or the
  buffer budget at their old positions. Both production callers were already
  correct.
- diegosouzapw#12754 added a per-connection reset-card fetch after the quota fetch; the
  spacing test now marks a chunk at the quota request only.
- diegosouzapw#13910 renamed `error` to `errorMetadata` in the timeout classification; the
  probe matches the identifier with a backreference and still fails when
  BodyTimeoutError is removed from both sites.

Refs diegosouzapw#13866

* fix(test): pin the opt-out thinking cases to requestedThinking=false; keep acquireMany under the complexity ceiling

The diegosouzapw#12905 gate-restore suite encoded 'requestedThinking absent' as opt-out, the
same undefined-means-false shape its non-streaming twin documents the other way
and that the two-month-old diegosouzapw#5786 suites contradict. The three opt-out cases now
set the flag explicitly, which is what chatCore resolves for an opted-out
client; the two opt-in cases already did. Both suites pass together (27/27).

The failFast branch pushed acquireMany over the complexity ceiling it already
sat on; the admission policy (fail-fast / bounded / unbounded queue) moves to
findQueueRejection() and the new-code ratchet is back at its base.

Refs diegosouzapw#13866

* fix(test): suspend the diegosouzapw#14110 redaction assertion inline; refresh the budget card

The 57 commits merged since the previous validation moved two things.

diegosouzapw#13295 changed how an unknown-root path with an ambiguous tail is answered:
where `Provider failed at /custom/internal secret directory` used to become
`Provider failed at <path>` it now ships verbatim. The diegosouzapw#12506 boundary guard
caught it. Two candidate fixes were tried and each breaks one of the two live
contracts — diegosouzapw#12506's fail-closed swallow, or diegosouzapw#13144's rule that a route in
prose must survive — so the choice is the owner's (diegosouzapw#14110). The one contested
assertion is suspended inline with the exact line and the issue; the other
nine stay active. The isolated-child harness requires tests == pass, which is
why it is a comment and not a todo.

The free-tier budget card was one wave behind again (491 -> 489 models).

Refs diegosouzapw#13866, diegosouzapw#14110

* fix(providers): type the TinyCMS DOM stub global as a loose record

diegosouzapw#13957 typed the mock global as `typeof globalThis & Record<string, unknown>`.
The api-route typecheck loads lib.dom, so that intersection carries the real
Window / HTMLCanvasElement / document signatures — every stub assignment fails
against a DOM constructor, and `delete g.window` narrows the object to
`never` (13 diagnostics, the API Route Typecheck base-red on the tip). The
function exists to overwrite those globals with stubs; it is now typed as the
plain record it manipulates. 29/29 tinycms tests unchanged.

Refs diegosouzapw#13866

* fix(test): pin the last opt-out thinking sibling to requestedThinking=false

translator-reasoning-gate-502-repro is the third diegosouzapw#12905 test that encoded a bare
state as opt-out; the previous sweep matched files by glob and missed it. The
family is now enumerated by grep on requestedThinking (7 files) plus the two
pre-diegosouzapw#12905 suites: 83/83 together.

Refs diegosouzapw#13866
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants