Skip to content

fix: bound app-server waits, identify processes before killing them, and make the stop gate converge - #1

Open
YmSaki wants to merge 5 commits into
mainfrom
claude/claude-code-fork-fixes-wa4lux
Open

fix: bound app-server waits, identify processes before killing them, and make the stop gate converge#1
YmSaki wants to merge 5 commits into
mainfrom
claude/claude-code-fork-fixes-wa4lux

Conversation

@YmSaki

@YmSaki YmSaki commented Aug 23, 2026

Copy link
Copy Markdown
Owner

概要

Windows (Git Bash) で発生した「初回 codex app-server 起動時に出力ゼロ・エラーゼロで12分以上ハングし、/codex:status にも載らない」事象を起点に、プロセスライフサイクルまわりの不具合をまとめて修正しています。

1. 無音ハング(2 経路)

  • RPC タイムアウト欠如 — JSON-RPC の request()timeoutMs を追加。initialize を 60 秒(CODEX_COMPANION_HANDSHAKE_TIMEOUT_MS で変更可)、制御系リクエストを 2 分で有界化。ストリーミング系(turn/startreview/start)は無制限のまま。タイムアウト時は収集済み stderr をエラーに添付し、wedged なプロセス/ソケットを破棄
  • ターン実行中の切断turn/start 応答後は保留中リクエストが 0 なので、app-server が死んでも何も reject されず await state.completion が永久に返らなかった(rejectCompletion は呼び出し箇所のない dead code)。接続断を監視して失敗させるように
  • broker が backend の死を検知しない — 背後の app-server が死んでも健全なエンドポイントを advertise し続けていた。app-server 終了時に broker も終了

2. プロセス識別(レビュー2巡目 P1)

broker.json の pid と pid ファイルの突き合わせは同時に書かれた同じ古い情報の比較で、pid 再利用後も一致したままでした。isProcessAlive も同様に「今その番号を持つプロセス」しか見ていません。

pid とプロセス開始時刻の組で識別するようにしました(Linux /proc/<pid>/stat、Windows Get-Process の Ticks、その他 ps -o lstart=)。terminateProcessTree はプロセスグループごと殺すため、識別できない pid には一切シグナルを送りません。identity 未記録の古いレコードは従来の生存確認にフォールバックします(拒否すると永久にキャンセル不能な孤児が残るため)。保証がプラットフォームの開始時刻分解能に依存する点はコード内に明記しています。

また、起動待ちに失敗した broker はハンドルが実行中を示す場合のみシグナルします。

3. Stop ゲートの収束(レビュー2巡目 P1)

stop_hook_active で無条件 skip していたため、Codex の指摘を Claude が直したか検証されず 2 回目は素通りでした。レビュー対象(HEAD + staged/unstaged diff + status)をフィンガープリント化し、変更なしなら保存済み判定を再利用(Codex を再実行せず block は block のまま)、変更ありなら再レビュー、3 ラウンド収束しなければユーザーに制御を返します。

block 出力はドキュメントどおりのトップレベル decision / reason のみです。

4. Windows

  • taskkill を shell: false — Git Bash/MSYS 下では /PID/T がパスに書き換えられ、kill が黙って失敗してプロセスが孤児化していた
  • exit 128 を「プロセス不在」として扱う — 英語メッセージ判定のみだったため日本語 Windows では例外になり、handleCancel が catch していないのでキャンセル状態が記録されないまま落ちていた。kill は best-effort にし、cancel 状態は必ず永続化
  • spawn ラッパを SystemRoot から解決SHELL / ComSpec を信用しない。SHELL は環境変数として Codex に渡るため Codex 自身のコマンド実行には影響しない
  • shell 使用時は単一のクオート済みコマンド文字列で spawn(DEP0190 解消)

5. ジョブの可視性と状態

  • sessionId 無しのジョブがセッションスコープの status から不可視かつキャンセル不能だった問題を修正。/codex:status --all でフィルタを完全バイパス
  • pid が消えたジョブを自動回収 — ワーカーが kill/クラッシュすると status は永遠に「実行中」、/codex:result も拒否していた
  • load → mutate → save をクロスプロセスロックで保護(アトミック書き込みだけでは torn read しか防げず、read-modify-write の交錯でジョブが消えていた)。state.json・ジョブファイル・broker.json は tmp+rename
  • バックグラウンドジョブの起動レース — ワーカー spawn がジョブファイル書き込みより先で、速いワーカーが payload 不在で落ち queued のまま残っていた
  • 接続/リトライを progress イベント化し、30 秒間隔のハートビートで待機中か死んでいるかを判別可能に

6. $ARGUMENTS のプロンプト破壊

アポストロフィが shell クオートとして扱われ、what's wrong here --background1 トークンに潰れてフラグが消えC:\Users\meC:Usersme になっていました。散文用のトークナイザに変更しています。

テスト

npm test 131 件成功。CI を ubuntu-latest + windows-latest のマトリクスに変更し、タイムアウトテストは named pipe 対応フィクスチャで Windows でも実行されます(従来は全 skip でした)。

新規テストはいずれも修正前のコードでは失敗することを確認済みです(mid-turn 切断は 20 秒のデッドラインまでハング、stop-gate は 4 ケース中 3 ケース失敗、broker 回収と並行 lost update も同様)。

upstream PR について(前回記述の訂正)

PR openai#484 / openai#577 / openai#580 は実在する open PR でした。 refs/pull/N/head を直接 fetch して内容も確認し、本 PR の修正はいずれも同じ方針を採っています。前回「存在を確認できなかった」と書いたのは upstream main の履歴のみを見たためで、未マージの PR が main に含まれないのは当然でした。確認方法が誤っていました。

なお f17e7f8 "fix: respect SHELL on Windows for Git Bash (openai#178)" は main に実在しますが、openai#484 の分析どおり app-server を包む外側のシェルと Codex 自身が使うシェルは別物であり、SHELL は env 経由で Codex に渡るため、外側を cmd.exe に固定しても Git Bash 利用者の体験は変わりません。

🤖 Generated with Claude Code

https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko

Fixes a family of first-start hangs and blind spots observed on Windows
(Git Bash) where a cold `codex app-server` start could stall forever
with no output, no error, and no entry in /codex:status:

- app-server: add per-request timeout support to the JSON-RPC client.
  The initialize handshake is now bounded (60s default, override via
  CODEX_COMPANION_HANDSHAKE_TIMEOUT_MS) on both direct and broker
  transports, and control-plane requests (thread/start, thread/resume,
  thread/list, account/read, config/read, turn/interrupt, name/set,
  external-agent import) are bounded at 2 minutes. Streaming requests
  (turn/start, review/start) stay unbounded. On timeout the captured
  codex stderr is attached to the error, the wedged process/socket is
  torn down so the CLI can exit, and broker timeouts fall back to a
  direct runtime.
- broker: add a 10s connect timeout, abort the startup wait as soon as
  the broker child exits, and raise the cold-start wait from 2s to 10s
  so slow starts stop silently falling back to per-call direct spawns.
- status: jobs recorded without a session id are no longer hidden from
  every session-scoped view (they were invisible and uncancellable);
  /codex:status --all now bypasses the session filter entirely.
- progress: connect/retry phases now emit progress events with a 30s
  heartbeat, so a stalled startup is distinguishable from a dead one in
  the job log and status phase.
- windows: spawn shell commands as a single quoted string instead of an
  args array (silences DEP0190, avoids unquoted joining), and fix the
  stale cmd.exe assumption comment in close().
- state: write state.json, job files, and broker.json atomically
  (tmp+rename) to stop concurrent writers corrupting job records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko
@YmSaki
YmSaki marked this pull request as ready for review August 23, 2026 17:20
claude added 3 commits August 23, 2026 17:34
Addresses review feedback on the timeout work: bounding the waits made
failures visible, but three paths still leaked or lost work.

- process: taskkill now runs with shell:false. It was inheriting the
  Windows default shell, so under Git Bash/MSYS the `/PID` and `/T`
  switches were rewritten into filesystem paths, every kill silently
  failed, and the process we meant to terminate was orphaned.
- process: add resolveDefaultShell(). SHELL is still honored on Windows
  (upstream added that for Git Bash), but a POSIX-style value such as
  `/bin/bash` cannot be spawned on Windows and made every command fail
  with ENOENT; those now fall back to ComSpec/System32 cmd.exe.
- broker: ensureBrokerSession defaults killProcess to
  terminateProcessTree. A broker that never became reachable had its
  files deleted but its process left running -- and since the session
  record was cleared, nothing could find it again. Stale sessions
  recovered from disk are only killed when the broker's pid file still
  claims that pid, so a reused pid is never targeted.
- app-server: connect() tags failures with brokerAttempted/brokerEndpoint.
  Once connect() rejected there was no client left to inspect, so a
  broker that failed to connect or handshake looked like a direct-runtime
  failure and never fell back to a direct runtime.
- codex: fall back to a direct runtime on any broker connect failure, and
  reclaim the unreachable broker (kill + clear its session) first. A busy
  broker is healthy and is left alone.
- state: guard load-mutate-save with a cross-process lock (atomic mkdir,
  stale-lock reclaim, and a bounded wait that degrades instead of
  blocking). Atomic writes alone only prevented torn reads; interleaved
  read-modify-write cycles still dropped jobs.

Tests: named-pipe-aware broker fixture so the timeout tests run on
Windows too (they were all skipped there, on a Windows-reported bug),
plus new coverage for broker reclamation, pid-file verification, shell
resolution, taskkill invocation, and concurrent job registration. CI now
runs on ubuntu-latest and windows-latest. Each new test was confirmed to
fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko
An adversarially-verified sweep of the plugin surfaced five more defects
in the same reliability family as the RPC timeouts, including a second
infinite-hang path the timeouts could not cover.

- codex: captureTurn now settles when the runtime disconnects mid-turn.
  Once turn/start answered there were no pending requests left, so a
  crashed app-server or killed broker rejected nothing: `await
  state.completion` waited forever (rejectCompletion was dead code), the
  job stayed "running", and the user saw no output and no error. A
  reproduction hung for the full 20s test deadline before this change.
- broker: shut down when the backing codex app-server exits. The broker
  kept advertising a healthy endpoint that clients connected to and then
  waited on forever -- the other half of the same hang.
- job-control: reconcile jobs whose worker pid is gone. A worker killed
  or crashed mid-run left its job pinned to "running" forever, so
  /codex:status reported progress that would never arrive and
  /codex:result refused to show anything.
- cancel: only signal a pid that is actually running. terminateProcessTree
  signals a process group, so cancelling a long-dead worker could kill
  whatever unrelated process had since inherited the recycled pid.
- stop gate: honor stop_hook_active. Nothing stopped the gate from
  reviewing again on the very turn it had just forced, which is how the
  Claude/Codex loop the README warns about starts. Infrastructure
  failures (timeout, bad JSON, spawn failure) now fail open with a note
  instead of holding the session hostage when Codex is unavailable, and
  the block decision is also emitted via hookSpecificOutput for current
  Claude Code.
- args: stop shell-tokenizing prose. Every apostrophe opened a quote that
  swallowed the rest of the prompt (silently absorbing any flag after
  it) and every backslash was dropped, so "what's wrong here
  --background" became one mangled token and C:\Users\me became
  C:Usersme before Codex ever saw it.
- background tasks: write the job file before spawning the worker. The
  worker reads its request from that file, so a fast worker could fail
  with "missing task request payload" and strand the job in "queued".

Tests: new coverage for mid-turn disconnect (end-to-end, via a fake
codex that dies after answering turn/start), stale-job reconciliation,
pid liveness, and prose tokenization. Each new test was confirmed to
fail against the pre-fix code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko
…nverge

Second review round: the previous fixes closed the reported paths but
introduced two of their own, and left one Windows failure standing.

- process: identify a process by pid AND start time. Comparing broker.json's
  pid against the pid file proved nothing -- both are stale artifacts written
  together, so after the broker died and its pid was recycled they still
  agreed while naming an unrelated process. isProcessAlive had the mirror
  problem: it answers "yes" about whatever now holds the number. Cancel and
  broker teardown now require the recorded start time to match before
  signalling, which matters because terminateProcessTree kills a whole
  process group. Records written before identities existed fall back to the
  old liveness check rather than becoming impossible to cancel.
- broker: only signal the freshly spawned child when its handle still shows
  it running; an exited child's pid may already belong to something else.
- stop gate: converge instead of skipping. Treating stop_hook_active as
  "allow" meant Codex's findings were never re-checked, so the gate passed
  anything on its second pass. The review target is now fingerprinted: an
  unchanged tree re-uses the stored verdict (no extra Codex run, and a block
  stays a block), a changed tree earns a fresh review, and the gate gives up
  after 3 rounds so a disagreement ends with the user instead of more usage.
- stop gate: block via the documented top-level decision/reason pair only.
  The previous commit also emitted hookSpecificOutput and described it as the
  current format, which is backwards.
- process: treat taskkill exit 128 as "already gone". The message-text check
  is English-only, so on a Japanese Windows an already-exited pid threw --
  and handleCancel never caught it, so the job was left un-cancelled. The
  kill is now best-effort and the cancelled state is always recorded.
- process: resolve the Windows spawn wrapper from SystemRoot instead of
  trusting SHELL or ComSpec. Wrapping a JSON-RPC stdio pipe in an arbitrary
  user shell is what hangs the handshake with no surfaced error; SHELL still
  reaches Codex through the inherited env, so its own command execution is
  unchanged.

Verified against upstream: PRs openai#484, openai#577 and openai#580 do exist as open pull
requests (fetched via refs/pull/N/head) and take these same approaches. The
earlier claim that they could not be found was checked against upstream main,
which by definition cannot contain unmerged work.

Tests: new coverage for pid-reuse rejection, the legacy-record fallback, the
taskkill exit code, the Windows shell resolution, and all four stop-gate
outcomes (block, unchanged-reuse, fixed-and-cleared, give-up). The stop-gate
tests fail against the pre-fix hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko
@YmSaki YmSaki changed the title fix: bound app-server RPC waits and keep wedged jobs visible fix: bound app-server waits, identify processes before killing them, and make the stop gate converge Aug 24, 2026
Adding a windows-latest CI matrix surfaced 19 failures on real Windows.
Verified on the actual dev box (Git Bash SHELL, real Codex): the runtime
itself works -- cold-start handshake ~164ms, broker start ~514ms recording
a process start-time, warm reuse ~31ms, PowerShell process-identity
rejecting a tampered start-time. The failures were harness portability plus
two genuine runtime gaps.

Runtime fixes:
- broker-endpoint: build the Unix-socket path with path.posix.join, not the
  platform default. On a Windows host path.join spliced in a backslash,
  producing an endpoint the socket layer cannot bind.
- claude-session-transfer: honor CLAUDE_CONFIG_DIR for the import
  containment check instead of hardcoding os.homedir()/.claude. Claude Code
  relocates its config dir with that variable, and os.homedir() ignores HOME
  on Windows (it uses USERPROFILE), so the check rejected valid transcripts.

Test-harness fixes (POSIX behavior unchanged):
- stop-gate: resolve the hook path with fileURLToPath, not
  new URL().pathname -- the latter yields "/C:/..." on Windows, which
  path.resolve turns into "C:\C:\...", so the spawned hook module was never
  found. This alone broke all four stop-gate tests; the broker/app-server
  chain they exercise works on Windows.
- job-visibility: use a file:// URL (not a bare Windows path) as a dynamic
  import specifier, and clear an ambient CODEX_COMPANION_SESSION_ID so the
  "no current session" case is genuine.
- helpers: linkNodeExecutable (node.cmd shim where symlinks need privilege)
  and canCreateSymlinks (skip guard); isolate initGitRepo from the
  developer's global gitignore via an empty core.excludesFile (many exclude
  .claude/ globally, which hid the untracked paths a review-context test
  creates).
- state / runtime: clear ambient CLAUDE_PLUGIN_DATA where a test asserts the
  os.tmpdir fallback, and set CLAUDE_CONFIG_DIR in the transfer tests.
- fixture: resolve the Codex home via os.homedir() to match production
  rather than process.env.HOME (unset on Windows).

Result on real Windows: 129 pass, 0 fail, 2 skipped (broken-symlink cases
that require symlink privilege). The two runtime changes are POSIX-equivalent
so the Linux suite is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017i6xinUm8V9Hfe55HYRwyr
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