File size: 9,738 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 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { formatExternalSupervisorActionRequired } from "../infra/gateway-supervision.js";
import type { detectGatewayRespawnSupervisor } from "../infra/supervisor-markers.js";
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
import { prepareHostedStopExecutor } from "./hosted-stop-executor.js";
import { resolveLaunchAgentLabel } from "./launchd-label.js";
import { probeLaunchAgentState, resolveLaunchAgentGuiDomain } from "./launchd-runtime.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import { execSystemctl } from "./systemd-exec.js";
type HostedGatewayStopResult =
| { outcome: "accepted" }
| { outcome: "exit" }
| { outcome: "refused"; detail: string }
| { outcome: "uncertain"; detail: string };
export type HostedGatewayStop = {
execute(assertCurrent: () => void): Promise<HostedGatewayStopResult>;
dispose(): Promise<void>;
};
export type GatewayProcessOwner = {
ownsProcessLifecycle: boolean;
supervisor: ReturnType<typeof detectGatewayRespawnSupervisor>;
};
async function readProcessCgroup(pid: number): Promise<string> {
const cgroups = await fs.readFile(`/proc/${pid}/cgroup`, "utf8");
const cgroup = cgroups.split("\n").find((line) => /^(?:0:|\d+:name=systemd):/.test(line));
if (!cgroup) {
throw new Error(
"Cannot verify the Gateway's native systemd cgroup. Stop it from an external shell.",
);
}
return cgroup.slice(cgroup.indexOf(":", cgroup.indexOf(":") + 1) + 1);
}
/** Discovery is request-bound; only the returned executor can cross its planned kernel teardown. */
export async function prepareHostedGatewayStop(
owner: GatewayProcessOwner,
assertCaller: () => void,
signal: AbortSignal,
): Promise<HostedGatewayStop> {
const env = { ...process.env };
signal.throwIfAborted();
assertCaller();
if (!owner.ownsProcessLifecycle) {
throw new Error("This Gateway host does not own the process lifecycle.");
}
if (owner.supervisor === "external") {
throw new Error(formatExternalSupervisorActionRequired("stop"));
}
if (
owner.supervisor === null ||
(process.platform === "win32" && owner.supervisor === "schtasks")
) {
// Exit only the process this run loop owns, never a task selected by name.
// The generated Windows task supervisor propagates clean exit after extinction;
// its RestartOnFailure policy does not restart a successful task.
return {
async execute(assertCurrent) {
signal.throwIfAborted();
assertCurrent();
return { outcome: "exit" };
},
async dispose() {},
};
}
const pid = process.pid;
const start = getFileLockProcessStartTime(pid);
if (start === null) {
throw new Error("Cannot verify the Gateway process identity. Stop it from an external shell.");
}
const assertProcess = (assertCurrent: () => void) => {
signal.throwIfAborted();
assertCurrent();
if (getFileLockProcessStartTime(pid) !== start) {
throw new Error("Gateway process identity changed before native stop.");
}
};
const assertPreparing = () => assertProcess(assertCaller);
assertPreparing();
if (process.platform === "darwin") {
const target = `${resolveLaunchAgentGuiDomain()}/${resolveLaunchAgentLabel(env)}`;
const inspect = async (assertCurrent: () => void) => {
const current = await probeLaunchAgentState(target, 5_000);
assertProcess(assertCurrent);
// launchctl also prints nested coalition states. The probe's canonical
// status, not its raw diagnostic state, describes the running job.
if (current.state !== "running" || current.runtime.pid !== pid) {
throw new Error("LaunchAgent no longer owns this exact Gateway process.");
}
};
await inspect(assertPreparing);
const executor = await prepareHostedStopExecutor({
command: ["/bin/launchctl", "bootout", target],
env,
signal,
assertCurrent: assertPreparing,
});
try {
await inspect(assertPreparing);
assertPreparing();
} catch (error) {
await executor.dispose();
throw error;
}
return {
dispose: executor.dispose,
async execute(assertCurrent) {
await inspect(assertCurrent);
const result = await executor.execute(() => assertProcess(assertCurrent));
assertProcess(assertCurrent);
if (result.disposition === "accepted") {
return { outcome: "accepted" };
}
// Neither child termination nor a still-visible job proves that bootout
// was rejected. Never resurrect a generation on an ambiguous native result.
return {
outcome: "uncertain",
detail: `launchctl bootout acceptance unconfirmed: ${result.detail}`,
};
},
};
}
if (process.platform !== "linux") {
throw new Error(
"Hosted native Gateway stop is unavailable on this platform. Use the host's service manager.",
);
}
const cgroup = await readProcessCgroup(pid);
assertPreparing();
const unit = cgroup.split("/").at(-1);
if (!unit || !/^[A-Za-z0-9_.:@\\-]+\.service$/.test(unit)) {
throw new Error(
"This Gateway is not the main process of a systemd service. Stop it through the host that started it.",
);
}
const uid = process.getuid?.();
const scope =
uid !== undefined && cgroup.includes(`/user@${uid}.service/`) ? "--user" : "--system";
// Freeze one manager route. Do not use installed-service discovery's user-first
// fallback, which can select a different Gateway when both scopes exist.
const readIdentity = async (assertCurrent: () => void) => {
const current = await execSystemctl(
[
scope,
"show",
"--all",
unit,
"--property=Id,LoadState,ActiveState,SubState,MainPID,ControlGroup,InvocationID,ExecMainStartTimestampMonotonic,Job,CanStop,RefuseManualStop",
],
env,
5_000,
);
assertProcess(assertCurrent);
const fields = parseKeyValueOutput(current.stdout, "=");
if (
current.code !== 0 ||
fields.id !== unit ||
fields.loadstate !== "loaded" ||
fields.activestate !== "active" ||
fields.substate !== "running" ||
fields.mainpid !== String(pid) ||
fields.controlgroup !== cgroup ||
!/^[a-f0-9]{32}$/i.test(fields.invocationid ?? "") ||
!/^[1-9]\d*$/.test(fields.execmainstarttimestampmonotonic ?? "") ||
!/^(?:0|)$/.test(fields.job ?? "missing")
) {
throw new Error(
"systemd no longer identifies this exact active Gateway without a pending job.",
);
}
// The unit/PID/cgroup and execution generation identify this Gateway;
// the service manager's own process is not the resource being stopped.
return {
identity: `${fields.invocationid}:${fields.execmainstarttimestampmonotonic}`,
canStop: fields.canstop === "yes" && fields.refusemanualstop === "no",
};
};
const initial = await readIdentity(assertPreparing);
assertPreparing();
if (!initial.canStop) {
throw new Error(
"systemd does not permit manual stop of this Gateway service. Use its owning supervisor.",
);
}
const scopeUnit = `openclaw-stop-${randomUUID()}.scope`;
const executor = await prepareHostedStopExecutor({
command: ["systemctl", scope, "--no-ask-password", "--no-block", "stop", unit],
scopeArgs: [
scope,
"--scope",
"--quiet",
"--collect",
"--no-ask-password",
`--unit=${scopeUnit}`,
],
env,
signal,
assertCurrent: assertPreparing,
verifyPlacement: async (executorPid) => {
const executorCgroup = await readProcessCgroup(executorPid);
assertPreparing();
if (!executorCgroup.endsWith(`/${scopeUnit}`) || executorCgroup.startsWith(`${cgroup}/`)) {
throw new Error("Native stop executor did not leave the Gateway service cgroup.");
}
},
});
const assertIdentity = async (assertCurrent: () => void) => {
const current = await readIdentity(assertCurrent);
if (current.identity !== initial.identity) {
throw new Error("Native Gateway service instance changed before stop.");
}
assertProcess(assertCurrent);
return current.canStop;
};
try {
if (!(await assertIdentity(assertPreparing))) {
throw new Error("systemd no longer permits manual stop of this Gateway service.");
}
} catch (error) {
await executor.dispose();
throw error;
}
return {
dispose: executor.dispose,
async execute(assertCurrent) {
try {
if (!(await assertIdentity(assertCurrent))) {
return {
outcome: "refused",
detail: "systemd no longer permits manual stop of this Gateway service",
};
}
const result = await executor.execute(() => assertProcess(assertCurrent));
assertProcess(assertCurrent);
if (result.disposition === "accepted") {
return { outcome: "accepted" };
}
if (result.disposition === "refused") {
// An explicit command rejection plus the same active generation with
// no native job permits reopening. Lost transport never permits it.
await assertIdentity(assertCurrent);
return { outcome: "refused", detail: `systemd refused Gateway stop: ${result.detail}` };
}
return {
outcome: "uncertain",
detail: `systemd stop acceptance unconfirmed: ${result.detail}`,
};
} catch (error) {
return {
outcome: "uncertain",
detail: error instanceof Error ? error.message : String(error),
};
}
},
};
}
|