Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/development/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Policy evaluation (`lib/policy/runtime-policy.ts`) can block paused/drained acco
| Branch | Predicate | Transport |
| --- | --- | --- |
| Interactive TUI | `isCodexInteractiveTuiCommand` — no forwarded subcommand at all | App runtime helper with `useCanonicalHome: true` and `detachOnExit: true`. Runs against the **canonical** `CODEX_HOME`; the provider is passed as ephemeral `-c model_providers.*` overrides. No shadow copy and no state sync-back. Nothing **provider- or transport-related** is written into `config.toml` on this path — the only top-level key the wrapper still reconciles there is `cli_auth_credentials_store`, which is transport-independent (see step 4 note). |
| Interactive `resume` / `fork` | `isCodexInteractiveResumeCommand` — forwarded command is `resume` or `fork` | Same transport and options as the interactive TUI above. These open a TUI against an existing thread, so they must see the canonical thread index. |
| `codex app` | `isCodexAppCommand` — forwarded command is `app` | App runtime helper process with a shadow `CODEX_HOME`. |
| Everything else | request-bearing forwarded command | Shadow `CODEX_HOME` created inline by the wrapper process. |

Expand All @@ -180,6 +181,10 @@ Policy evaluation (`lib/policy/runtime-policy.ts`) can block paused/drained acco

Why the interactive branch is different: copying the Codex home into a shadow made the official CLI reindex its thread history and SQLite state on every TUI launch. Running interactive sessions against the canonical home keeps that state reusable.

`resume` and `fork` belong to that same branch for a stronger reason. The shadow mirror deliberately omits the runtime SQLite state (`isRuntimeRotationShadowHomeOmittedEntry`), so a shadow home only ever holds a partial thread index rebuilt from the linked `sessions` directory. Resuming a thread that the shadow index does not contain left the TUI on a blank screen forever, while the same command worked under the official CLI and with the proxy disabled. Routing both commands to the canonical home is what makes the thread visible (#647).

Helper shutdown is bounded rather than best-effort. `stopRuntimeRotationAppHelper` sends `SIGTERM`, waits out the graceful window, escalates to `SIGKILL` if the helper is still running, and then unconditionally destroys the helper's stdio streams and unrefs the child. That last step is the load-bearing one: the helper is spawned with piped stdio, so a helper that outlives the window — or any process that inherited those pipes — keeps the wrapper's event loop referenced and the shell prompt never returns. On Windows the signals are emulated as unconditional termination, so the stream teardown is the only part that reliably frees the wrapper there.

Two interactive sessions can therefore run concurrently against the same home — the same as running the official CLI twice — and **no lock is taken over session state**: neither session copies or syncs it, so there is nothing to clobber. Regression coverage lives in `test/codex-bin-wrapper.test.ts`.

Scope that guarantee to session state only. It does **not** extend to `config.toml`: `ensureCodexCliFileAuthStore` (`lib/codex-cli/writer.ts`) still read-modify-writes the canonical file when the store is not already `"file"`, and the atomic write does not serialize cross-process writers. That is safe in practice rather than by locking — the operation is idempotent, converges on a single value, and lands via atomic rename, so concurrent invocations agree instead of interleaving. Anything added to that write path that is *not* idempotent would need a real lock.
Expand Down
1 change: 1 addition & 0 deletions docs/development/CONFIG_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ For dashboard display values:
3. If enabled, start a loopback Responses proxy with a per-process client token.
4. Select a transport from the forwarded argv:
- **No forwarded subcommand (interactive TUI)** — keep the canonical `CODEX_HOME` and pass `codex-multi-auth-runtime-proxy` as ephemeral `-c model_providers.*` overrides. Nothing is copied, no provider or transport config is written into `config.toml`, and the helper detaches on exit. The transport-independent `cli_auth_credentials_store` reconcile below still applies.
- **`resume` / `fork`** — the same canonical-home transport as the interactive TUI. These resume an existing thread, and the shadow home omits the runtime SQLite state, so the shadow transport could not see the requested thread (#647).
- **`codex app`** — run the app runtime helper against a shadow `CODEX_HOME`.
- **Any other request-bearing command** — create a temporary shadow `CODEX_HOME` and rewrite its `config.toml` to use `codex-multi-auth-runtime-proxy`.
5. Forward official Codex with the selected home.
Expand Down
78 changes: 69 additions & 9 deletions scripts/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -4003,23 +4003,59 @@ function waitForRuntimeRotationAppHelperExit(helper, timeoutMs = 2_000) {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
// Drop the listener when the timeout wins, so a helper that outlives the
// graceful window does not keep a live reference back into this process.
helper.off?.("close", finish);
resolve();
};
timer = setTimeout(finish, timeoutMs);
helper.once("close", finish);
});
}

function stopRuntimeRotationAppHelper(helper) {
if (!helper || helper.killed) {
return Promise.resolve();
}
function hasRuntimeRotationAppHelperExited(helper) {
return helper.exitCode !== null || helper.signalCode !== null;
}

// Releases every handle the helper still holds in this process. The helper is
// spawned with piped stdio, so those pipes keep the parent event loop alive on
// their own — destroying and unref-ing them is what actually lets `mcodex` return
// to the shell when the child ignores or outlives SIGTERM (#647).
function releaseRuntimeRotationAppHelperResources(helper) {
helper.stdout?.destroy();
helper.stderr?.destroy();
try {
helper.kill("SIGTERM");
helper.unref();
} catch {
return Promise.resolve();
// Best-effort only.
}
}

async function stopRuntimeRotationAppHelper(helper) {
if (!helper) {
return;
}
if (hasRuntimeRotationAppHelperExited(helper)) {
releaseRuntimeRotationAppHelperResources(helper);
return;
}
if (!helper.killed) {
try {
helper.kill("SIGTERM");
} catch {
releaseRuntimeRotationAppHelperResources(helper);
return;
}
}
await waitForRuntimeRotationAppHelperExit(helper);
if (!hasRuntimeRotationAppHelperExited(helper)) {
try {
helper.kill("SIGKILL");
} catch {
// Best-effort force-stop once the graceful shutdown window has elapsed.
}
}
return waitForRuntimeRotationAppHelperExit(helper);
releaseRuntimeRotationAppHelperResources(helper);
}

function startRuntimeRotationAppHelper(baseContext, options = {}) {
Expand Down Expand Up @@ -4171,7 +4207,10 @@ async function createRuntimeRotationProxyContextIfEnabled(
if (isCodexAppCommand(rawArgs)) {
return createRuntimeRotationAppHelperContext(baseContext, configTomlModule);
}
if (isCodexInteractiveTuiCommand(rawArgs)) {
if (
isCodexInteractiveTuiCommand(rawArgs) ||
isCodexInteractiveResumeCommand(rawArgs)
) {
return createRuntimeRotationAppHelperContext(baseContext, configTomlModule, {
detachOnExit: true,
useCanonicalHome: true,
Expand Down Expand Up @@ -4375,6 +4414,17 @@ function isCodexInteractiveTuiCommand(rawArgs) {
return findForwardedCommand(rawArgs) === null;
}

// `resume` and `fork` are interactive TUI entry points that happen to carry a
// forwarded subcommand, so the bare-invocation predicate above misses them. They
// must not take the shadow-home transport: the shadow mirror deliberately omits the
// runtime SQLite state (`isRuntimeRotationShadowHomeOmittedEntry`), so the requested
// thread is absent from the shadow session index and the resumed TUI hangs on a
// blank screen (#647).
function isCodexInteractiveResumeCommand(rawArgs) {
const command = findForwardedCommand(rawArgs)?.command;
return command === "resume" || command === "fork";
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

function shouldUseRuntimeRoutingForForwardedArgs(rawArgs) {
if (!Array.isArray(rawArgs) || rawArgs.length === 0) {
return true;
Expand Down Expand Up @@ -4414,7 +4464,17 @@ function shouldUseRuntimeRoutingForForwardedArgs(rawArgs) {
);
}

if (command.command === "app" && hasHelpFlagAfterCommand(rawArgs, command.index)) {
// Request commands still print their help without making a single model
// request, so the help form skips the transport entirely: no proxy, no shadow
// home, no detached helper. This matters most for the interactive commands,
// which detach their helper on a clean exit — help always exits clean, so
// `resume --help` would otherwise strand a helper until its idle timeout. It is
// keyed off the help flag rather than the command, so a real run still routes
// through rotation, and it matches how `app-server` help is handled above (#647).
if (
requestCommands.has(command.command) &&
hasHelpFlagAfterCommand(rawArgs, command.index)
) {
return false;
}
if (requestCommands.has(command.command)) {
Expand Down
Loading