openclaw/test/codex-node-cancellation.integration.test.ts
Peter Steinberger 6b16949e8c
fix(codex): keep catalog actions bound to their source (#151854)
* fix(codex): keep catalog actions on the selected native session

Keep stop and steer on the active client, settle native fork ownership under
the binding lease, and retain canceled resume reservations until cleanup has
settled. Preserve ordered inbound images and confirm native thread absence
through an authoritative read.

Require current source support for node catalog continuation, scope resume
reservations by canonical store and thread, and preserve source identity
through terminal routing. Older nodes retain readable catalogs and unqualified
slash-command bindings; catalog continuation requires the upgraded capability.

Production delta: +95 net lines. Focused proof: 1,200 passing cases across 27
files with one Windows-specific skip. Official P2 review 5 covers the original
67-file snapshot; review 7 covers the exact final five-file follow-up. Both are
scoped-clean. The unchanged CLI test-support core-lint error is recorded in the
handoff; final composed CI, live proof, and screenshot publication remain with
the integration owner.

* fix(codex): keep captured homes stable across alias changes

Capture the physical agent directory and Codex home once per config
generation, including missing home leaves, and carry that prepared path
through native startup and bound CLI resume. A client restart cannot
follow a retargeted alias under an older source-home identity.

Preserve requested home ownership while aligning cache and fence keys,
native config reads, skill isolation, and binding rotation with the
captured path. Configuration reload remains the invalidation boundary.

Validation: four intended original-code failures; 503 focused and
sibling cases pass across ten files. Extension types, changed lint,
formatting, line caps, and independent scoped P2 review pass.

* test(codex): align integration fixtures with runtime contracts

Load Codex metadata through the core manifest owner in the broker
cancellation integration test, preserving the core/plugin boundary.

Match refreshed quota-fixture lifetime to its existing fixed JWT expiry
so the healthy-status assertion respects the CLI warning window.
Resolve the Swift TLS retry endpoint from its updated stored pin.

Preserve behavioral assertions, production thresholds, and timeouts.
Validation: 11 boundary/broker cases, test-root types/lint, formatting,
line caps, and scoped P2 review pass. All three quota UI replay cases
pass on the verified integration runtime; native Swift CI remains the
execution proof for the final candidate.

* fix(plugins): preserve reactivated registries during retirement

Recheck registry liveness after asynchronous command draining before
marking a no-host-cleanup registry retired. A temporary registry can
restore the previous one while displaced cleanup is still pending.

Also collect widget dropdown rows and computed fonts in one browser
evaluation so a removed loading row cannot corrupt the typography
assertion. Keep the existing full-font equality assertion unchanged.

Validation: scoped P2 reviews, physical core production types, targeted
core test graph types, 28 catalog registry/broker cases, 37 registry
owner/sibling cases, 43 original-order QA/prompt/Copilot cases, and
4 normal plus 15 bounded timing browser cases pass.

* refactor(ui): deduplicate catalog key parsing

Normalize catalog URL fields together and keep empty-component rejection in the decoded key validation. Preserve routing and source-home identity while reducing the startup bundle without changing performance limits.
2026-09-19 02:32:25 -07:00

243 lines
9 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import {
createPluginRecord,
createPluginRegistry,
createPluginRuntimeMock,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import codexPlugin from "../extensions/codex/index.js";
import { invokeRegisteredNodeHostCommand } from "../src/node-host/plugin-node-host.js";
import { loadPluginManifest } from "../src/plugins/manifest.js";
import { runWithSpawnBroker } from "../src/process/spawn-broker/context.js";
import { createSpawnBrokerHost, type SpawnBrokerHost } from "../src/process/spawn-broker/host.js";
import { isPidDefinitelyDead } from "../src/shared/pid-alive.js";
import { resolveBundledPluginPublicModulePath } from "../src/test-utils/bundled-plugin-public-surface.js";
import { createDeferred, withTestTimeout } from "./helpers/promise.js";
const bufferedObservation = vi.hoisted(() => ({ returned: vi.fn() }));
vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/process-runtime")>();
return {
...actual,
async runCommandBuffered(...args: Parameters<typeof actual.runCommandBuffered>) {
const result = await actual.runCommandBuffered(...args);
bufferedObservation.returned(result);
return result;
},
};
});
const tempDirs: string[] = [];
afterEach(async () => {
resetPluginRuntimeStateForTest();
bufferedObservation.returned.mockReset();
vi.restoreAllMocks();
vi.unstubAllEnvs();
await Promise.all(
tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })),
);
});
const skipBrokerTests = process.platform === "win32" || Boolean(process.versions.bun);
describe.skipIf(skipBrokerTests)("registered Codex node cancellation settlement", () => {
it("keeps the session reserved until canceled late-PID work has closed", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-node-cleanup-"));
tempDirs.push(root);
const codexHome = path.join(root, "codex-home");
await fs.mkdir(codexHome);
vi.stubEnv("CODEX_HOME", codexHome);
vi.stubEnv("OPENCLAW_STATE_DIR", root);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(root, "openclaw.json"));
const pluginConfig = { sessionCatalog: { enabled: false } };
const config = {
plugins: { entries: { codex: { enabled: true, config: pluginConfig } } },
};
const registry = createPluginRegistry({
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
logger: { info() {}, warn() {}, error() {}, debug() {} },
activateGlobalSideEffects: false,
});
const codexManifest = loadPluginManifest(
path.dirname(
resolveBundledPluginPublicModulePath({
pluginId: "codex",
artifactBasename: "openclaw.plugin.json",
}),
),
);
if (!codexManifest.ok) {
throw new Error(codexManifest.error);
}
const record = createPluginRecord({
id: "codex",
source: path.join(root, "index.js"),
nativeSessionCatalog: codexManifest.manifest.setup?.nativeSessionCatalog,
});
registry.registry.plugins.push(record);
codexPlugin.register(registry.createApi(record, { config, pluginConfig }));
setActivePluginRegistry(registry.registry);
const broker = createSpawnBrokerHost();
const controller = new AbortController();
const remoteRequested = createDeferred<ReturnType<SpawnBrokerHost["spawnExeca"]>>();
const bufferedReturned = createDeferred();
const remotes: Array<ReturnType<SpawnBrokerHost["spawnExeca"]>> = [];
const invocations: Promise<unknown>[] = [];
const failures: unknown[] = [];
let brokerStopped = false;
const resumeBroker = () => {
if (brokerStopped && broker.pid !== undefined) {
process.kill(broker.pid, "SIGCONT");
brokerStopped = false;
}
};
try {
await broker.ready();
if (broker.pid === undefined) {
throw new Error("spawn broker did not report its PID");
}
const spawnExeca = broker.spawnExeca.bind(broker);
vi.spyOn(broker, "spawnExeca").mockImplementation((argv, options) => {
const outputIndex = argv.indexOf("--output-last-message");
const outputPath = argv[outputIndex + 1];
if (outputIndex === -1 || !outputPath) {
throw new Error("fixture received an unexpected process request");
}
const source = `
const fs = require("node:fs");
let prompt = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => { prompt += chunk; });
process.stdin.on("end", () => {
if (prompt === "retry") {
fs.writeFileSync(process.argv[1], "retry completed");
} else {
setInterval(() => {}, 1_000);
}
});
`;
const remote = spawnExeca([process.execPath, "-e", source, outputPath], options);
remotes.push(remote);
remoteRequested.resolve(remote);
return remote;
});
bufferedObservation.returned.mockImplementation(() => bufferedReturned.resolve());
const invoke = (prompt: string, signal?: AbortSignal) => {
const invocation = runWithSpawnBroker(broker, () =>
invokeRegisteredNodeHostCommand(
"codex.cli.session.resume",
JSON.stringify({
sessionId: "cleanup-fixture-session",
prompt,
cwd: root,
timeoutMs: 10_000,
}),
undefined,
{ signal, sendNodeEvent: async () => undefined },
),
);
invocations.push(invocation);
void invocation.catch(() => {});
return invocation;
};
process.kill(broker.pid, "SIGSTOP");
brokerStopped = true;
const first = invoke("hold", controller.signal);
const outcome = first.then(
(value) => ({ kind: "resolved" as const, value }),
(error: unknown) => ({ kind: "rejected" as const, error }),
);
const remote = await withTestTimeout(
remoteRequested.promise,
5_000,
"registered command did not reach the broker",
);
expect(remote.child.pid).toBeUndefined();
controller.abort(new Error("node invocation canceled"));
await withTestTimeout(
bufferedReturned.promise,
5_000,
"buffered execution did not expose its bounded cancellation result",
);
expect(
await Promise.race([outcome, delay(250).then(() => ({ kind: "pending" as const }))]),
).toEqual({ kind: "pending" });
await expect(invoke("retry")).rejects.toThrow("already has an active resume turn");
resumeBroker();
await withTestTimeout(remote.child.waitForClose(), 5_000, "late native child did not close");
expect(remote.child.pid).toBeDefined();
expect(isPidDefinitelyDead(remote.child.pid!)).toBe(true);
expect(
await withTestTimeout(outcome, 5_000, "registered cancellation did not settle"),
).toMatchObject({
kind: "rejected",
error: { message: "node invocation canceled" },
});
const retry = await withTestTimeout(
invoke("retry"),
5_000,
"session remained reserved after cleanup",
);
expect(JSON.parse(retry ?? "null")).toMatchObject({
ok: true,
sessionId: "cleanup-fixture-session",
text: "retry completed",
});
} catch (error) {
failures.push(error);
} finally {
controller.abort();
try {
resumeBroker();
const cleanup = await Promise.allSettled(
remotes.map(async (remote) => {
await withTestTimeout(
remote.child.ready(),
5_000,
"cleanup could not obtain late PID",
).catch(() => undefined);
remote.child.kill("SIGKILL");
await withTestTimeout(remote.result, 5_000, "fixture process did not settle").catch(
() => undefined,
);
await withTestTimeout(
remote.child.waitForClose(),
5_000,
"fixture child did not close",
);
}),
);
failures.push(
...cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])),
);
} catch (error) {
failures.push(error);
}
try {
await broker.close();
} catch (error) {
failures.push(error);
}
try {
await withTestTimeout(
Promise.allSettled(invocations),
5_000,
"registered invocations did not settle",
);
} catch (error) {
failures.push(error);
}
}
if (failures.length > 0) {
throw new AggregateError(failures, "registered cancellation fixture failed");
}
});
});