mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
feat(node-host): supervise durable worker launches (#122829)
* feat(node-host): add worker launch supervision * fix(node-host): harden worker lifecycle ownership * fix(node-host): harden worker execution boundary * fix(node-host): preserve worker trust settings * chore(plugin-sdk): refresh worker lifecycle baselines * docs(plan): track runner implementation slices * test(node-host): await runtime shutdown owner
This commit is contained in:
committed by
GitHub
parent
93f5e0f1f6
commit
6b9bea84f0
@@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process";
|
||||
import type { Writable } from "node:stream";
|
||||
import type { SpawnSecretInput } from "./supervisor/types.js";
|
||||
|
||||
export type SpawnStdioEntry = "ignore" | "inherit" | "overlapped" | "pipe";
|
||||
export type SpawnStdioEntry = "ignore" | "inherit" | "ipc" | "overlapped" | "pipe";
|
||||
|
||||
export function addSecretInputStdio(
|
||||
stdio: SpawnStdioEntry[],
|
||||
|
||||
@@ -57,8 +57,23 @@ function createStubChild(pid = 1234) {
|
||||
Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true });
|
||||
Object.defineProperty(child, "exitCode", { value: null, configurable: true, writable: true });
|
||||
Object.defineProperty(child, "signalCode", { value: null, configurable: true, writable: true });
|
||||
Object.defineProperty(child, "channel", { value: {}, configurable: true });
|
||||
Object.defineProperty(child, "connected", { value: true, configurable: true, writable: true });
|
||||
const killMock = vi.fn(() => true);
|
||||
const sendMock = vi.fn((_message: unknown, ...args: unknown[]) => {
|
||||
const callback = args.findLast((value) => typeof value === "function") as
|
||||
| ((error: Error | null) => void)
|
||||
| undefined;
|
||||
callback?.(null);
|
||||
return true;
|
||||
});
|
||||
const disconnectMock = vi.fn(() => {
|
||||
Object.defineProperty(child, "connected", { value: false, configurable: true, writable: true });
|
||||
child.emit("disconnect");
|
||||
});
|
||||
child.kill = killMock as ChildProcess["kill"];
|
||||
child.send = sendMock as ChildProcess["send"];
|
||||
child.disconnect = disconnectMock as ChildProcess["disconnect"];
|
||||
const emitClose = (code: number | null, signal: NodeJS.Signals | null = null) => {
|
||||
child.emit("close", code, signal);
|
||||
};
|
||||
@@ -71,7 +86,7 @@ function createStubChild(pid = 1234) {
|
||||
});
|
||||
child.emit("exit", code, signal);
|
||||
};
|
||||
return { child, killMock, emitClose, emitExit };
|
||||
return { child, disconnectMock, killMock, sendMock, emitClose, emitExit };
|
||||
}
|
||||
|
||||
async function createAdapterHarness(params?: {
|
||||
@@ -224,6 +239,30 @@ describe("createChildAdapter", () => {
|
||||
expect(killMock).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("creates owned worker trees in a dedicated POSIX process group without fallback", async () => {
|
||||
process.env.OPENCLAW_SERVICE_MARKER = "service-managed";
|
||||
const { child, disconnectMock, sendMock } = createStubChild();
|
||||
spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false });
|
||||
|
||||
const adapter = await createChildAdapter({
|
||||
argv: ["node", "worker"],
|
||||
ownedWorker: true,
|
||||
input: "{}",
|
||||
});
|
||||
|
||||
expect(firstSpawnWithFallbackParams().options?.detached).toBe(process.platform !== "win32");
|
||||
expect(firstSpawnWithFallbackParams().fallbacks).toEqual([]);
|
||||
expect(firstSpawnWithFallbackParams().options?.stdio).toEqual(["pipe", "pipe", "pipe", "ipc"]);
|
||||
|
||||
await adapter.openStartGate?.();
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
{ type: "openclaw-worker-start-v1" },
|
||||
expect.any(Function),
|
||||
);
|
||||
adapter.closeStartGate?.();
|
||||
expect(disconnectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes secret input to an extra descriptor and zeroes the transient buffer", async () => {
|
||||
const { child } = createStubChild();
|
||||
const secretStream = new PassThrough();
|
||||
@@ -788,6 +827,28 @@ describe("createChildAdapter", () => {
|
||||
expect(spawnArgs.options.env.CDPATH).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an exact Linux child environment out of the OOM shell wrapper", async () => {
|
||||
setPlatform("linux");
|
||||
const restoreLinuxShell = mockLinuxOomWrapperShell();
|
||||
const { child } = createStubChild(3335);
|
||||
spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false });
|
||||
try {
|
||||
const adapter = await createChildAdapter({
|
||||
argv: ["/usr/bin/node", "-e", "process.exit(0)"],
|
||||
env: { HOME: "/worker-home", PATH: "/usr/bin" },
|
||||
exactEnv: true,
|
||||
stdinMode: "pipe-open",
|
||||
});
|
||||
expect(adapter.oomScoreWrapperSelected).toBe(false);
|
||||
} finally {
|
||||
restoreLinuxShell();
|
||||
}
|
||||
|
||||
const spawnArgs = firstSpawnWithFallbackParams();
|
||||
expect(spawnArgs.argv).toEqual(["/usr/bin/node", "-e", "process.exit(0)"]);
|
||||
expect(spawnArgs.options?.env).toEqual({ HOME: "/worker-home", PATH: "/usr/bin" });
|
||||
});
|
||||
|
||||
it("passes explicit env overrides as strings", async () => {
|
||||
await createAdapterHarness({
|
||||
pid: 4444,
|
||||
|
||||
@@ -71,6 +71,12 @@ function resolveChildInvocation(params: {
|
||||
}
|
||||
|
||||
type ChildAdapter = SpawnProcessAdapter<NodeJS.Signals | null>;
|
||||
type WorkerChildAdapter = ChildAdapter & {
|
||||
closeStartGate?: () => void;
|
||||
openStartGate?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const WORKER_START_MESSAGE = { type: "openclaw-worker-start-v1" } as const;
|
||||
|
||||
function isServiceManagedRuntime(): boolean {
|
||||
return Boolean(process.env.OPENCLAW_SERVICE_MARKER?.trim());
|
||||
@@ -78,32 +84,40 @@ function isServiceManagedRuntime(): boolean {
|
||||
|
||||
export async function createChildAdapter(params: {
|
||||
argv: string[];
|
||||
/** Own a separately signalable tree whose private IPC channel gates worker startup. */
|
||||
ownedWorker?: true;
|
||||
/** Preserve the supplied environment exactly by skipping environment-mutating spawn wrappers. */
|
||||
exactEnv?: true;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
windowsVerbatimArguments?: boolean;
|
||||
input?: string;
|
||||
stdinMode?: "inherit" | "pipe-open" | "pipe-closed";
|
||||
secretInput?: SpawnSecretInput;
|
||||
}): Promise<ChildAdapter> {
|
||||
}): Promise<WorkerChildAdapter> {
|
||||
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
|
||||
const invocation = resolveChildInvocation({
|
||||
argv: params.argv,
|
||||
env: baseEnv,
|
||||
windowsVerbatimArguments: params.windowsVerbatimArguments,
|
||||
});
|
||||
const preparedSpawn = prepareOomScoreAdjustedSpawn(invocation.command, invocation.args, {
|
||||
env: baseEnv,
|
||||
});
|
||||
const preparedSpawn = params.exactEnv
|
||||
? { command: invocation.command, args: invocation.args, env: baseEnv, wrapped: false }
|
||||
: prepareOomScoreAdjustedSpawn(invocation.command, invocation.args, { env: baseEnv });
|
||||
|
||||
const stdinMode = params.stdinMode ?? (params.input !== undefined ? "pipe-closed" : "inherit");
|
||||
|
||||
// In service-managed mode keep children attached so systemd/launchd can
|
||||
// stop the full process tree reliably. Outside service mode preserve the
|
||||
// existing POSIX detached behavior.
|
||||
const useDetached = process.platform !== "win32" && !isServiceManagedRuntime();
|
||||
// A detached POSIX child is still a descendant in the service cgroup/job, but
|
||||
// owns a process group that can be killed without touching the node host.
|
||||
const useDetached =
|
||||
process.platform !== "win32" &&
|
||||
(params.ownedWorker !== undefined || !isServiceManagedRuntime());
|
||||
|
||||
const stdio: SpawnStdioEntry[] = [stdinMode === "inherit" ? "inherit" : "pipe", "pipe", "pipe"];
|
||||
addSecretInputStdio(stdio, params.secretInput);
|
||||
if (params.ownedWorker !== undefined) {
|
||||
stdio.push("ipc");
|
||||
}
|
||||
|
||||
const options: SpawnOptions = {
|
||||
cwd: params.cwd,
|
||||
@@ -117,17 +131,34 @@ export async function createChildAdapter(params: {
|
||||
const spawned = await spawnWithFallback({
|
||||
argv: [preparedSpawn.command, ...preparedSpawn.args],
|
||||
options,
|
||||
fallbacks: useDetached
|
||||
? [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
fallbacks:
|
||||
useDetached && params.ownedWorker === undefined
|
||||
? [
|
||||
{
|
||||
label: "no-detach",
|
||||
options: { detached: false },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
|
||||
const child = spawned.child as ChildProcessWithoutNullStreams;
|
||||
if (params.ownedWorker !== undefined && (!child.connected || !child.channel)) {
|
||||
spawned.child.kill("SIGKILL");
|
||||
throw new Error("worker lifecycle IPC channel was not created");
|
||||
}
|
||||
const disconnectWorkerIpc = () => {
|
||||
if (!child.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.disconnect();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_DISCONNECTED") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Pipe errors can arrive before output subscribers attach. Close remains
|
||||
// responsible for decoder flush and Windows drain completion.
|
||||
const ignoreOutputStreamError = () => {};
|
||||
@@ -489,9 +520,41 @@ export async function createChildAdapter(params: {
|
||||
const dispose = () => {
|
||||
clearForceKillWaitFallback();
|
||||
clearForcedWindowsCloseTimer();
|
||||
if (params.ownedWorker !== undefined) {
|
||||
disconnectWorkerIpc();
|
||||
}
|
||||
child.removeAllListeners();
|
||||
};
|
||||
|
||||
const closeStartGate = params.ownedWorker ? disconnectWorkerIpc : undefined;
|
||||
|
||||
let startGateOpened = false;
|
||||
const openStartGate = params.ownedWorker
|
||||
? async () => {
|
||||
if (startGateOpened) {
|
||||
return;
|
||||
}
|
||||
startGateOpened = true;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!child.connected) {
|
||||
reject(new Error("worker lifecycle IPC channel closed before startup"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.send(WORKER_START_MESSAGE, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} catch (error) {
|
||||
reject(toErrorObject(error, "worker lifecycle IPC send failed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
pid: child.pid ?? undefined,
|
||||
stdin,
|
||||
@@ -501,5 +564,7 @@ export async function createChildAdapter(params: {
|
||||
wait,
|
||||
kill,
|
||||
dispose,
|
||||
closeStartGate,
|
||||
openStartGate,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user