Skip to content

fix(app-server): bound request() so a wedged spawned app-server can't hang the client (closes #31) - #32

Merged
axisrow merged 3 commits into
mainfrom
fix/spawned-initialize-unbounded-31
Jul 21, 2026
Merged

axisrow merged 3 commits into
mainfrom
fix/spawned-initialize-unbounded-31

Conversation

@axisrow

@axisrow axisrow commented Jul 20, 2026

Copy link
Copy Markdown
Owner

What

SpawnedCodexAppServerClient.initialize() had the same unbounded handshake shape PR #30 fixed only on the broker path: request("initialize") had no deadline, so a spawned codex app-server that accepted stdin but never answered hung the client forever. This is the "identical twin" Codex flagged on #30, filed as #31.

Reproduced first (not guessed)

A new stalled-initialize fake-codex behavior (accepts stdin, never answers initialize) reproduces the hang end-to-end: CodexAppServerClient.connect({ disableBroker: true }) never settles within 2s. The bug is real — the spawned child does not self-resolve via exit/close (it holds stdin open through readline).

Fix (deeper, as promised in #31 / upstream openai#509)

Instead of a third copy of the bounded-handshake pattern, the deadline moves onto the shared RPC primitive:

  • AppServerClientBase.request(method, params, options) gains an optional timeoutMs (+ onTimeout for transport cleanup). Optional / undefined-default → all existing 2-arg call sites are unchanged; the 12 hot-path call sites never allocate a timer.
  • SpawnedCodexAppServerClient.initialize passes timeoutMs (10s default, spawnedInitializeTimeoutMs override) and kills the child on handshake failure so a timeout doesn't orphan the spawned process.
  • BrokerCodexAppServerClient.initialize drops its inline Promise.race and uses request(timeoutMs, onTimeout=socket.destroy) — one mechanism, both transports.
  • CodexAppServerClient.connect tags broker-transport init failures with brokerFatal; withAppServer's fallback OR-chain collapses from 3 branches to (broker-busy) || (brokerFatal).

Shared killChildNow() extracted from the duplicated platform-branched kill in initialize catch + close. Renamed BROKER_HANDSHAKE_TIMEOUT_CODE → REQUEST_TIMEOUT_CODE (value "EBROKERTIMEOUT" unchanged) since the code is now transport-agnostic.

Verification

Scope

Closes #31. The spawned-path handshake is now bounded; the broker path keeps its #30 behavior via the shared primitive. captureTurn's turn-level deadline is intentionally separate (lifecycle bound, not per-RPC).

🤖 Generated with Claude Code

axisrow and others added 3 commits July 21, 2026 03:01
… hang the client (closes #31)

SpawnedCodexAppServerClient.initialize had the same unbounded handshake shape
PR #30 fixed only on the broker path: request("initialize") had no deadline,
so a spawned codex app-server that accepted stdin but never answered hung the
client forever. RED-first: a stalled-initialize fake-codex behavior reproduces
the hang (connect() never settles within 2s).

Deeper fix (as promised in #31 / upstream openai#509), not a third copy of the
bounded-handshake pattern:

- AppServerClientBase.request(method, params, options) gains an optional
  timeoutMs (+ onTimeout for transport cleanup). Optional/undefined-default, so
  all existing 2-arg call sites are unchanged. Timer is unref'd, cleared in
  handleLine/handleExit. Timeout rejects with BROKER_HANDSHAKE_TIMEOUT_CODE and
  removes the pending entry; a late answer for that id is a no-op in handleLine.
- SpawnedCodexAppServerClient.initialize now passes timeoutMs (10s default,
  spawnedInitializeTimeoutMs override), and kills the child on handshake
  failure so a timeout doesn't orphan the spawned process.
- BrokerCodexAppServerClient.initialize drops its inline Promise.race and uses
  request(timeoutMs, onTimeout=socket.destroy) — one mechanism, both transports.
- CodexAppServerClient.connect tags broker-transport init failures with
  brokerFatal; withAppServer's fallback OR-chain collapses to
  (broker-busy) || (brokerFatal).

Verified: RED→GREEN (304ms, EBROKERTIMEOUT); broker suite 11/11 green; npm test
unchanged (4 pre-existing failures).

Co-Authored-By: Claude <[email protected]>
…e-review critical)

Codex adversarial review caught that collapsing withAppServer's fallback onto
error.transport broke the #29 path: broker/busy (-32001) arrives AFTER a
successful handshake, while the error is created by createProtocolError with
rpcCode but NO transport (transport is tagged only on handshake failure in
connect()'s catch). So every overlapping /codex:* command failed for the
duration of a long-running broker turn instead of falling back to direct.

Restore client?.transport === "broker" for the busy branch (post-handshake,
client is assigned); keep error.brokerFatal for handshake failures (client is
null, tagged in connect). Adds spawnBusyBroker helper + an end-to-end test
that drives a successful initialize, then -32001 on the first RPC, and
verifies the operation completes via the direct fallback.

Co-Authored-By: Claude <[email protected]>
…-review critical)

Codex adversarial review caught that `error.code = REQUEST_TIMEOUT_CODE` on a
plain `new Error(...)` fails the repo's mandatory `npm run build`
(TS2339: Property 'code' does not exist on type 'Error'). CI runs this build,
so the head could not pass its shipping gate. The same pre-existing breakage
in BrokerCodexAppServerClient's handshakeError (landed in #30) is fixed too.

Cast the errors to ProtocolError (extended to include `code?: string`), the
same pattern createProtocolError already uses.

Co-Authored-By: Claude <[email protected]>
@axisrow

axisrow commented Jul 21, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Cycle Reviewer Finding Verdict Resolution
1 codex Collapsing OR-chain onto error.transport broke the #29 path — broker/busy (-32001) arrives post-handshake with rpcCode but no transport FIX Fixed in ddabf26 — restored client?.transport for busy branch, brokerFatal for handshake failures; spawnBusyBroker + e2e test RED→GREEN
1 claude (/review) (clean — approved the deeper request(timeoutMs) primitive) approve —
2 codex error.code = REQUEST_TIMEOUT_CODE on plain Error fails npm run build (TS2339); CI runs the build. Same pre-existing breakage from #30 at line 337 FIX Fixed in b5490fb — cast to ProtocolError (typedef extended with code?: string), both sites; npm run build green
2 claude (/review) (clean — approved the fallback fix) approve —
3 codex (no findings) approve —
3 claude (/review) (clean) approve —

Totals: 2 FIX (both resolved), 0 SKIP, 0 UNVERIFIED.
Verdict: both reviewers approve at head b5490fb. Ready to merge (local mode — merge is yours to trigger).

@axisrow
axisrow merged commit 7d9ad2f into main Jul 21, 2026
@axisrow
axisrow deleted the fix/spawned-initialize-unbounded-31 branch July 21, 2026 01:45
axisrow added a commit that referenced this pull request Jul 21, 2026
Ships accumulated fixes since fork.4:
- #29 broker-busy hang (PR #30): bound broker handshake
- #31 spawned-app-server handshake (PR #32): bound request() primitive
- openai#508 config-driven sandbox + --read-only (PR #33) + resume-sandbox fail-closed
- #34 model/effort in /codex:rescue launch line (PR #35)
- #28 turn-timeout gaps (already in fork.4 base)

Co-Authored-By: Claude <[email protected]>
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.

fix(app-server): SpawnedCodexAppServerClient.initialize is unbounded (same hang class as #29)

1 participant