fix(acpx): stop/start races cannot resurrect a stopped embedded runtime (#120029)

* fix(acpx): stop/start races cannot resurrect a stopped runtime backend

The deferred acpx runtime service published its lazily started backend without
lifecycle ownership checks: a stop during in-flight activation let the stale
service become the active runtime, and stop cleared shared state while startup
still used it. Start/stop now use a lifecycle revision; only the current
revision may publish a runtime, stop invalidates every deferred proxy before
awaiting in-flight startup, and restart waits for the previous stop to settle.

* chore: re-fire CI

* fix(acpx): fence deferred runtime ownership

* refactor(acpx): centralize backend lifecycle ownership

* fix(acpx): annotate deferred runtime cycle
This commit is contained in:
Peter Steinberger
2026-08-07 01:09:23 -07:00
committed by GitHub
parent 40ef8c5e68
commit 5a913fa453
4 changed files with 359 additions and 48 deletions
+178 -10
View File
@@ -5,6 +5,11 @@ const { runtimeRegistry } = vi.hoisted(() => ({
runtimeRegistry: new Map<string, { runtime: unknown }>(),
}));
type BackendLifecycle = {
publish: (backend: { runtime: unknown }) => void;
retract: (runtime: unknown) => void;
};
const { realRuntime, realServiceStartMock, realServiceStopMock, createRealServiceMock } =
vi.hoisted(() => {
const runtime = {
@@ -21,17 +26,29 @@ const { realRuntime, realServiceStartMock, realServiceStopMock, createRealServic
isHealthy: vi.fn(() => true),
probeAvailability: vi.fn(async () => {}),
};
const start = vi.fn(async () => {
runtimeRegistry.set("acpx", { runtime });
const start = vi.fn(async (_ctx: unknown, backendLifecycle?: BackendLifecycle) => {
if (backendLifecycle) {
backendLifecycle.publish({ runtime });
} else {
runtimeRegistry.set("acpx", { runtime });
}
});
const stop = vi.fn(async () => {
runtimeRegistry.delete("acpx");
const stop = vi.fn(async (_ctx: unknown, backendLifecycle?: BackendLifecycle) => {
if (backendLifecycle) {
backendLifecycle.retract(runtime);
} else {
runtimeRegistry.delete("acpx");
}
});
return {
realRuntime: runtime,
realServiceStartMock: start,
realServiceStopMock: stop,
createRealServiceMock: vi.fn(() => ({ id: "real-acpx-runtime", start, stop })),
createRealServiceMock: vi.fn((params: { backendLifecycle?: BackendLifecycle } = {}) => ({
id: "real-acpx-runtime",
start: (ctx: unknown) => start(ctx, params.backendLifecycle),
stop: (ctx: unknown) => stop(ctx, params.backendLifecycle),
})),
};
});
@@ -61,6 +78,14 @@ function restoreEnv(): void {
}
}
function createDeferred() {
let resolve: () => void = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createServiceContext() {
return {
workspaceDir: "/tmp/openclaw-acpx-register-test",
@@ -121,10 +146,16 @@ describe("acpx register runtime service", () => {
sessionKey: "agent:codex:acp:test",
});
expect(createRealServiceMock).toHaveBeenCalledWith({
pluginConfig: { timeoutSeconds: 10 },
});
expect(realServiceStartMock).toHaveBeenCalledWith(ctx);
expect(createRealServiceMock).toHaveBeenCalledWith(
expect.objectContaining({
backendLifecycle: expect.objectContaining({
publish: expect.any(Function),
retract: expect.any(Function),
}),
pluginConfig: { timeoutSeconds: 10 },
}),
);
expect(realServiceStartMock).toHaveBeenCalledWith(ctx, expect.any(Object));
expect(runtimeRegistry.get("acpx")?.runtime).toBe(realRuntime);
expect(ctx.logger.info).toHaveBeenCalledWith("embedded acpx runtime backend registered lazily");
@@ -148,7 +179,144 @@ describe("acpx register runtime service", () => {
await service.stop?.(ctx as never);
expect(realServiceStopMock).toHaveBeenCalledWith(ctx);
expect(realServiceStopMock).toHaveBeenCalledWith(ctx, expect.any(Object));
expect(runtimeRegistry.get("acpx")).toBeUndefined();
});
it("rejects stale publication after stop invalidates the deferred backend", async () => {
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
const startEntered = createDeferred();
const releasePublication = createDeferred();
realServiceStartMock.mockImplementationOnce(async (_ctx, backendLifecycle) => {
startEntered.resolve();
await releasePublication.promise;
backendLifecycle?.publish({ runtime: realRuntime });
});
const ctx = createServiceContext();
const service = createAcpxRuntimeService();
await service.start(ctx as never);
const deferredRuntime = runtimeRegistry.get("acpx")?.runtime as {
ensureSession(input: { sessionKey: string; agent: string; mode: string }): Promise<unknown>;
};
const activation = deferredRuntime.ensureSession({
sessionKey: "agent:codex:acp:shutdown-race",
agent: "codex",
mode: "oneshot",
});
const activationResult = activation.then(
() => null,
(error: unknown) => error,
);
await startEntered.promise;
const stopping = service.stop?.(ctx as never);
await Promise.resolve();
expect(realServiceStopMock).not.toHaveBeenCalled();
releasePublication.resolve();
await stopping;
expect(await activationResult).toEqual(
expect.objectContaining({
message: "ACPX runtime service stopped during activation",
}),
);
expect(realServiceStopMock).toHaveBeenCalledOnce();
expect(realServiceStopMock).toHaveBeenCalledWith(ctx, expect.any(Object));
expect(runtimeRegistry.get("acpx")).toBeUndefined();
await expect(
deferredRuntime.ensureSession({
sessionKey: "agent:codex:acp:stale-proxy",
agent: "codex",
mode: "oneshot",
}),
).rejects.toThrow("ACPX runtime service is not started");
});
it("keeps a successor generation registered when old cleanup finishes late", async () => {
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
const published = createDeferred();
const releaseProbe = createDeferred();
realServiceStartMock.mockImplementationOnce(async (_ctx, backendLifecycle) => {
if (!backendLifecycle) {
throw new Error("expected outer backend lifecycle");
}
backendLifecycle.publish({ runtime: realRuntime });
published.resolve();
await releaseProbe.promise;
});
const ctx = createServiceContext();
const generationA = createAcpxRuntimeService();
await generationA.start(ctx as never);
const deferredRuntimeA = runtimeRegistry.get("acpx")?.runtime as {
ensureSession(input: { sessionKey: string; agent: string; mode: string }): Promise<unknown>;
};
const activation = deferredRuntimeA.ensureSession({
sessionKey: "agent:codex:acp:generation-a",
agent: "codex",
mode: "oneshot",
});
const activationResult = activation.then(
() => null,
(error: unknown) => error,
);
await published.promise;
expect(runtimeRegistry.get("acpx")?.runtime).toBe(realRuntime);
let concurrentCallSettled = false;
const concurrentCallResult = deferredRuntimeA
.ensureSession({
sessionKey: "agent:codex:acp:generation-a-concurrent",
agent: "codex",
mode: "oneshot",
})
.then(
() => {
concurrentCallSettled = true;
return null;
},
(error: unknown) => {
concurrentCallSettled = true;
return error;
},
);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(concurrentCallSettled).toBe(false);
const stoppingA = generationA.stop?.(ctx as never);
expect(runtimeRegistry.get("acpx")).toBeUndefined();
const generationB = createAcpxRuntimeService();
await generationB.start(ctx as never);
const deferredRuntimeB = runtimeRegistry.get("acpx")?.runtime;
expect(deferredRuntimeB).toBeTruthy();
expect(deferredRuntimeB).not.toBe(deferredRuntimeA);
expect(deferredRuntimeB).not.toBe(realRuntime);
expect(concurrentCallSettled).toBe(false);
releaseProbe.resolve();
await stoppingA;
expect(await activationResult).toEqual(
expect.objectContaining({ message: "ACPX runtime service stopped during activation" }),
);
expect(await concurrentCallResult).toEqual(
expect.objectContaining({ message: "ACPX runtime service stopped during activation" }),
);
expect(runtimeRegistry.get("acpx")?.runtime).toBe(deferredRuntimeB);
await expect(
deferredRuntimeA.ensureSession({
sessionKey: "agent:codex:acp:stale-generation-a",
agent: "codex",
mode: "oneshot",
}),
).rejects.toThrow("ACPX runtime service is not started");
await generationB.stop?.(ctx as never);
expect(runtimeRegistry.get("acpx")).toBeUndefined();
});
+103 -24
View File
@@ -15,51 +15,101 @@ import { createLazyAcpRuntimeProxy } from "./src/runtime-proxy.js";
const ACPX_BACKEND_ID = "acpx";
type RealAcpxServiceModule = typeof import("./src/service.js");
type CreateAcpxRuntimeServiceParams = NonNullable<
type InnerAcpxRuntimeServiceParams = NonNullable<
Parameters<RealAcpxServiceModule["createAcpxRuntimeService"]>[0]
>;
type CreateAcpxRuntimeServiceParams = Omit<InnerAcpxRuntimeServiceParams, "backendLifecycle">;
type DeferredServiceState = {
ctx: OpenClawPluginServiceContext | null;
lifecycleRevision: number;
ownedRuntime: AcpRuntime | null;
params: CreateAcpxRuntimeServiceParams;
realRuntime: AcpRuntime | null;
realService: OpenClawPluginService | null;
startPromise: Promise<AcpRuntime> | null;
stopPromise: Promise<void> | null;
};
const loadServiceModule = createLazyRuntimeModule(() => import("./src/service.js"));
async function startRealService(state: DeferredServiceState): Promise<AcpRuntime> {
function unregisterOwnedRuntime(runtime: AcpRuntime | null): void {
if (runtime && getAcpRuntimeBackend(ACPX_BACKEND_ID)?.runtime === runtime) {
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
}
}
async function startRealService(
state: DeferredServiceState,
lifecycleRevision: number,
deferredRuntime: AcpRuntime,
): Promise<AcpRuntime> {
if (state.lifecycleRevision !== lifecycleRevision || !state.ctx) {
throw new Error("ACPX runtime service is not started");
}
if (state.realRuntime) {
return state.realRuntime;
}
if (!state.ctx) {
throw new Error("ACPX runtime service is not started");
if (state.startPromise) {
return await state.startPromise;
}
state.startPromise ??= (async () => {
const ctx = state.ctx;
state.startPromise = (async () => {
let publishedRuntime: AcpRuntime | null = null;
const { createAcpxRuntimeService: createAcpxRuntimeServiceLocal } = await loadServiceModule();
const service = createAcpxRuntimeServiceLocal(state.params);
const service = createAcpxRuntimeServiceLocal({
...state.params,
backendLifecycle: {
publish(backend) {
if (state.lifecycleRevision !== lifecycleRevision || state.ctx !== ctx) {
throw new Error("ACPX runtime service stopped during activation");
}
if (getAcpRuntimeBackend(ACPX_BACKEND_ID)?.runtime !== deferredRuntime) {
throw new Error("ACPX runtime service lost registry ownership during activation");
}
// Publication is a synchronous compare-and-replace: another plugin
// generation cannot be adopted between the ownership check and write.
registerAcpRuntimeBackend({ id: ACPX_BACKEND_ID, ...backend });
publishedRuntime = backend.runtime;
state.ownedRuntime = backend.runtime;
},
retract(runtime) {
unregisterOwnedRuntime(runtime);
},
},
});
state.realService = service;
await service.start(state.ctx as OpenClawPluginServiceContext);
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);
if (!backend?.runtime) {
await service.start(ctx);
if (state.lifecycleRevision !== lifecycleRevision || state.ctx !== ctx) {
throw new Error("ACPX runtime service stopped during activation");
}
if (!publishedRuntime) {
throw new Error("ACPX runtime service did not register an ACP backend");
}
state.realRuntime = backend.runtime;
return state.realRuntime;
if (getAcpRuntimeBackend(ACPX_BACKEND_ID)?.runtime !== publishedRuntime) {
throw new Error("ACPX runtime service lost registry ownership during activation");
}
// Registry publication intentionally precedes the startup probe, but callers
// must keep sharing the start promise until the inner service is fully ready.
state.realRuntime = publishedRuntime;
return publishedRuntime;
})();
try {
return await state.startPromise;
} catch (error) {
state.startPromise = null;
state.realService = null;
if (state.lifecycleRevision === lifecycleRevision) {
state.startPromise = null;
state.realService = null;
}
throw error;
}
}
function createDeferredRuntime(state: DeferredServiceState): AcpRuntime {
const resolveRuntime = () => startRealService(state);
return createLazyAcpRuntimeProxy(resolveRuntime);
function createDeferredRuntime(state: DeferredServiceState, lifecycleRevision: number): AcpRuntime {
const deferredRuntime: AcpRuntime = createLazyAcpRuntimeProxy(
(): Promise<AcpRuntime> => startRealService(state, lifecycleRevision, deferredRuntime),
);
return deferredRuntime;
}
/** Creates the plugin service that registers ACPX as an ACP runtime backend. */
@@ -68,10 +118,13 @@ export function createAcpxRuntimeService(
): OpenClawPluginService {
const state: DeferredServiceState = {
ctx: null,
lifecycleRevision: 0,
ownedRuntime: null,
params,
realRuntime: null,
realService: null,
startPromise: null,
stopPromise: null,
};
return {
@@ -81,24 +134,50 @@ export function createAcpxRuntimeService(
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
if (state.stopPromise) {
await state.stopPromise;
}
state.lifecycleRevision += 1;
const lifecycleRevision = state.lifecycleRevision;
state.ctx = ctx;
const deferredRuntime = createDeferredRuntime(state, lifecycleRevision);
state.ownedRuntime = deferredRuntime;
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime: createDeferredRuntime(state),
runtime: deferredRuntime,
});
ctx.logger.info("embedded acpx runtime backend registered lazily");
},
async stop(ctx) {
if (state.realService) {
await state.realService.stop?.(ctx);
} else {
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
if (state.stopPromise) {
return await state.stopPromise;
}
// Invalidate every deferred proxy before waiting for startup. The in-flight
// service still owns cleanup, but it can no longer become the active runtime.
state.lifecycleRevision += 1;
state.ctx = null;
state.realRuntime = null;
state.realService = null;
state.startPromise = null;
const ownedRuntime = state.ownedRuntime;
unregisterOwnedRuntime(ownedRuntime);
const startPromise = state.startPromise;
state.stopPromise = (async () => {
await startPromise?.catch(() => undefined);
try {
await state.realService?.stop?.(ctx);
} finally {
unregisterOwnedRuntime(ownedRuntime);
state.ownedRuntime = null;
state.realRuntime = null;
state.realService = null;
state.startPromise = null;
}
})();
try {
await state.stopPromise;
} finally {
state.stopPromise = null;
}
},
};
}
+65 -7
View File
@@ -71,12 +71,6 @@ const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionSt
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
runtimeRegistry.set(entry.id, entry);
},
unregisterAcpRuntimeBackend: (id: string) => {
runtimeRegistry.delete(id);
},
}));
vi.mock("./runtime.js", () => ({
@@ -173,10 +167,23 @@ function createOpenKeyedStore(ctx: OpenClawPluginServiceContext) {
function createAcpxRuntimeService(
ctx: OpenClawPluginServiceContext,
params: Parameters<typeof createRealAcpxRuntimeService>[0] = {},
params: Omit<Parameters<typeof createRealAcpxRuntimeService>[0], "backendLifecycle"> & {
backendLifecycle?: Parameters<typeof createRealAcpxRuntimeService>[0]["backendLifecycle"];
} = {},
) {
const backendLifecycle = params.backendLifecycle ?? {
publish(backend: { runtime: unknown; healthy?: () => boolean }) {
runtimeRegistry.set("acpx", backend);
},
retract(runtime: unknown) {
if (runtimeRegistry.get("acpx")?.runtime === runtime) {
runtimeRegistry.delete("acpx");
}
},
};
return createRealAcpxRuntimeService({
...params,
backendLifecycle,
openKeyedStore: params.openKeyedStore ?? createOpenKeyedStore(ctx),
});
}
@@ -205,6 +212,14 @@ function createMockRuntime(overrides: Record<string, unknown> = {}) {
};
}
function createDeferred() {
let resolve: () => void = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createStartupTraceRecorder() {
const measured: string[] = [];
const details: Array<{
@@ -266,6 +281,49 @@ describe("createAcpxRuntimeService", () => {
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
});
it("publishes before probing and retracts the exact runtime through the injected lifecycle", async () => {
delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE;
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const probeStarted = createDeferred();
const releaseProbe = createDeferred();
const events: string[] = [];
const runtime = createMockRuntime({
probeAvailability: vi.fn(async () => {
events.push("probe");
probeStarted.resolve();
await releaseProbe.promise;
}),
});
const publish = vi.fn((backend: { runtime: unknown; healthy?: () => boolean }) => {
events.push("publish");
expect(backend.runtime).toBe(runtime);
expect(backend.healthy?.()).toBe(true);
});
const retract = vi.fn((ownedRuntime: unknown) => {
events.push("retract");
expect(ownedRuntime).toBe(runtime);
});
const service = createAcpxRuntimeService(ctx, {
backendLifecycle: { publish, retract },
runtimeFactory: () => runtime as never,
});
const starting = service.start(ctx) as Promise<void>;
await probeStarted.promise;
expect(events).toEqual(["publish", "probe"]);
expect(publish).toHaveBeenCalledOnce();
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
await service.stop?.(ctx);
expect(retract).toHaveBeenCalledWith(runtime);
expect(events).toEqual(["publish", "probe", "retract"]);
releaseProbe.resolve();
await starting;
});
it("skips the startup probe and does not advertise backend health when explicitly disabled", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
+13 -7
View File
@@ -20,7 +20,6 @@ import type {
OpenClawPluginServiceContext,
PluginLogger,
} from "../runtime-api.js";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
import { prepareAcpxCodexAuthConfig } from "./codex-auth-bridge.js";
import { DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
import {
@@ -58,7 +57,6 @@ type AcpxRuntimeLike = AcpRuntime & {
};
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
const SKIP_RUNTIME_PROBE_ENV = "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE";
const ACPX_BACKEND_ID = "acpx";
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
@@ -68,7 +66,13 @@ type AcpxRuntimeFactoryParams = {
logger?: PluginLogger;
};
type AcpxBackendLifecycle = {
publish: (backend: { runtime: AcpRuntime; healthy?: () => boolean }) => void;
retract: (runtime: AcpRuntime) => void;
};
type CreateAcpxRuntimeServiceParams = {
backendLifecycle: AcpxBackendLifecycle;
pluginConfig?: unknown;
openKeyedStore?: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T>;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise<AcpxRuntimeLike>;
@@ -324,7 +328,7 @@ async function reapOpenAcpxProcessLeases(params: {
/** Create the ACPX plugin service that owns runtime registration and cleanup. */
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
params: CreateAcpxRuntimeServiceParams,
): OpenClawPluginService {
let runtime: AcpxRuntimeLike | null = null;
let lifecycleRevision = 0;
@@ -411,11 +415,11 @@ export function createAcpxRuntimeService(
["probeAgent", pluginConfig.probeAgent ?? "default"],
]);
await measureAcpxStartup(ctx, "backend.register", () => {
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
const backend = {
runtime: startedRuntime,
...(shouldProbeRuntime ? { healthy: () => runtime?.isHealthy() ?? false } : {}),
});
};
params.backendLifecycle.publish(backend);
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
});
@@ -460,7 +464,9 @@ export function createAcpxRuntimeService(
},
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
lifecycleRevision += 1;
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
if (runtime) {
params.backendLifecycle.retract(runtime);
}
runtime = null;
},
};