File size: 5,440 Bytes
d197cf3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | import { spawn } from "node:child_process";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { formatErrorMessage } from "../infra/errors.js";
type NativeStopDisposition = "accepted" | "refused" | "uncertain";
// The shell waits for a separate authorization line. EOF cancels before exec;
// preparing its script cannot issue a native stop. exec preserves the owned PID.
const STOP_EXECUTOR_SCRIPT =
'printf "%s\\n" "$$"; IFS= read -r action && [ "$action" = stop ] && exec "$@"';
/** A non-exiting executor, optionally placed outside the service's kill cgroup. */
export async function prepareHostedStopExecutor(params: {
command: string[];
scopeArgs?: string[];
env: NodeJS.ProcessEnv;
signal: AbortSignal;
assertCurrent: () => void;
verifyPlacement?: (pid: number) => Promise<void>;
}) {
params.assertCurrent();
params.signal.throwIfAborted();
// systemd versions differ on argv dollar expansion. Send the quoted command
// and handshake over stdin so neither old nor new managers can rewrite them.
const script = `set -- ${params.command.map(quoteCliArg).join(" ")}; ${STOP_EXECUTOR_SCRIPT}\n`;
const command = params.scopeArgs ? "systemd-run" : "/bin/sh";
const args = params.scopeArgs ? [...params.scopeArgs, "/bin/sh"] : [];
// A native group stop must reach the Gateway before it kills this client.
// Detached creates a separate process group; the private pipe and close join
// still keep the executor owned, with no unref or unattended continuation.
const child = spawn(command, args, {
env: params.env,
detached: true,
stdio: ["pipe", "pipe", "pipe"],
});
let output = "";
let diagnostic = "";
let committed = false;
let disposed = false;
let ready = false;
let resolveReady: (pid: number) => void;
let rejectReady: (error: Error) => void;
const readiness = new Promise<number>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
let resolveResult: (result: { disposition: NativeStopDisposition; detail: string }) => void;
const result = new Promise<{ disposition: NativeStopDisposition; detail: string }>((resolve) => {
resolveResult = resolve;
});
const closed = result.then(() => {});
let timeout: ReturnType<typeof setTimeout>;
const dispose = () => {
if (disposed) {
return closed;
}
disposed = true;
clearTimeout(timeout);
params.signal.removeEventListener("abort", cancel);
child.stdin.end();
// Kill only this owned executor, then join close (including its stdio).
// A cancelled command is uncertain even if it happened to exit successfully.
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
return closed;
};
const cancel = () => {
void dispose();
};
params.signal.addEventListener("abort", cancel, { once: true });
child.stdin.on("error", (error) => {
diagnostic = formatErrorMessage(error);
cancel();
});
child.stderr.on("data", (data: Buffer) => {
diagnostic = (diagnostic + data.toString()).slice(-1000);
});
child.stdout.on("data", (data: Buffer) => {
if (ready) {
return;
}
output += data.toString();
if (output.length > 128) {
rejectReady(new Error("Native stop executor returned invalid readiness"));
cancel();
return;
}
if (output.endsWith("\n")) {
if (!/^[1-9]\d*\n$/.test(output) || Number(output.trim()) !== child.pid) {
rejectReady(new Error("Native stop executor returned invalid readiness"));
cancel();
return;
}
ready = true;
clearTimeout(timeout);
resolveReady(Number(output.trim()));
}
});
child.once("error", (error) => {
diagnostic = formatErrorMessage(error);
rejectReady(new Error(`Native stop executor unavailable: ${diagnostic}`));
});
child.once("close", (code, signal) => {
clearTimeout(timeout);
params.signal.removeEventListener("abort", cancel);
rejectReady(new Error(`Native stop executor unavailable: ${diagnostic || "closed"}`));
resolveResult({
disposition:
!disposed && committed && code === 0 && !signal
? "accepted"
: !disposed && committed && typeof code === "number" && !signal
? "refused"
: "uncertain",
detail: diagnostic,
});
});
timeout = setTimeout(() => {
rejectReady(new Error("Native stop executor preparation timed out"));
cancel();
}, 5_000);
try {
child.stdin.write(script);
const pid = await readiness;
params.signal.throwIfAborted();
params.assertCurrent();
await params.verifyPlacement?.(pid);
params.signal.throwIfAborted();
params.assertCurrent();
return {
dispose,
execute(assertCurrent: () => void) {
assertCurrent();
params.signal.throwIfAborted();
if (committed) {
throw new Error("Native stop executor is no longer available");
}
if (disposed || child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve({
disposition: "refused" as const,
detail: "Native executor closed before stop was requested",
});
}
committed = true;
child.stdin.write("stop\n");
timeout = setTimeout(cancel, 5_000);
return result;
},
};
} catch (error) {
await dispose();
throw error;
}
}
|