fix: make codex app-server cleanup ownership-aware

This commit is contained in:
Peter Steinberger
2026-05-02 09:45:26 +01:00
parent 39a931f1bf
commit 089a3063ee
4 changed files with 59 additions and 3 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
- Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.
- Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq.
- Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc.
- Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427.
- Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah.
- CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840.

View File

@@ -78,7 +78,7 @@ import {
} from "./protocol.js";
import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import { clearSharedCodexAppServerClient } from "./shared-client.js";
import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
import {
buildDeveloperInstructions,
buildTurnStartParams,
@@ -489,6 +489,7 @@ export async function runCodexAppServerAttempt(
let thread: CodexAppServerThreadBinding;
let trajectoryEndRecorded = false;
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
let startupClientForCleanup: CodexAppServerClient | undefined;
try {
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
@@ -516,12 +517,15 @@ export async function runCodexAppServerAttempt(
timeoutFloorMs: options.startupTimeoutFloorMs,
signal: runAbortController.signal,
operation: async () => {
let attemptedClient: CodexAppServerClient | undefined;
const startupAttempt = async () => {
const startupClient = await clientFactory(
appServer.start,
startupAuthProfileId,
agentDir,
);
attemptedClient = startupClient;
startupClientForCleanup = startupClient;
await ensureCodexComputerUse({
client: startupClient,
pluginConfig: options.pluginConfig,
@@ -549,18 +553,24 @@ export async function runCodexAppServerAttempt(
"codex app-server connection closed during startup; restarting app-server and retrying",
{ error },
);
clearSharedCodexAppServerClient();
const failedClient = attemptedClient;
clearSharedCodexAppServerClientIfCurrent(failedClient);
if (startupClientForCleanup === failedClient) {
startupClientForCleanup = undefined;
}
attemptedClient = undefined;
return await startupAttempt();
}
},
}));
startupClientForCleanup = undefined;
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
data: { phase: "thread_ready", threadId: thread.threadId },
});
} catch (error) {
nativeHookRelay?.unregister();
clearSharedCodexAppServerClient();
clearSharedCodexAppServerClientIfCurrent(startupClientForCleanup);
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
throw error;
}

View File

@@ -31,6 +31,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
@@ -54,6 +55,7 @@ describe("shared Codex app-server client", () => {
({ listCodexAppServerModels } = await import("./models.js"));
({
clearSharedCodexAppServerClient,
clearSharedCodexAppServerClientIfCurrent,
createIsolatedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
@@ -293,6 +295,32 @@ describe("shared Codex app-server client", () => {
expect(second.process.kill).not.toHaveBeenCalled();
});
it("only clears the shared client that is still current", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
expect(second.process.kill).not.toHaveBeenCalled();
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
});
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const authHeaders: Array<string | undefined> = [];

View File

@@ -134,6 +134,23 @@ export function clearSharedCodexAppServerClient(): void {
client?.close();
}
export function clearSharedCodexAppServerClientIfCurrent(
client: CodexAppServerClient | undefined,
): boolean {
if (!client) {
return false;
}
const state = getSharedCodexAppServerClientState();
if (state.client !== client) {
return false;
}
state.client = undefined;
state.promise = undefined;
state.key = undefined;
client.close();
return true;
}
export async function clearSharedCodexAppServerClientAndWait(options?: {
exitTimeoutMs?: number;
forceKillDelayMs?: number;