mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 11:50:53 +00:00
fix(gateway): persist macOS stop disable after bootout
Summary: - carry forward #78412's macOS LaunchAgent bootout-by-default stop behavior and repair guard - fix the remaining `gateway stop --disable` tail when the service is already not loaded after bootout - add lifecycle regressions, docs, and changelog Verification: - pnpm install - pnpm test src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/lifecycle.test.ts src/daemon/launchd.test.ts - pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/cli/daemon-cli/lifecycle-core.ts src/cli/daemon-cli/lifecycle.ts src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/lifecycle.test.ts docs/cli/gateway.md docs/gateway/index.md src/daemon/launchd.ts src/daemon/launchd.test.ts src/cli/daemon-cli/register-service-commands.ts src/cli/daemon-cli/types.ts src/daemon/service-types.ts - git diff --check origin/main...HEAD - pnpm build - Parallels macOS Tahoe VM reproduce/fix proof in PR body - PR checks green: Real behavior proof, auto-response, dispatch, label, label-issues Co-authored-by: wdeveloper16 <25180374+wdeveloper16@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fe79d85ae0
commit
1f88cb2ce5
@@ -216,6 +216,30 @@ describe("runServiceRestart token drift", () => {
|
||||
expect(service.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a requested managed stop even when the service is not loaded", async () => {
|
||||
const onNotLoaded = vi.fn(async () => ({
|
||||
result: "stopped" as const,
|
||||
message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.",
|
||||
}));
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
|
||||
await runServiceStop({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
opts: { json: true, disable: true },
|
||||
stopWhenNotLoaded: true,
|
||||
onNotLoaded,
|
||||
});
|
||||
|
||||
const payload = readJsonLog<{ result?: string; service?: { loaded?: boolean } }>();
|
||||
expect(payload.result).toBe("stopped");
|
||||
expect(payload.service?.loaded).toBe(false);
|
||||
expect(service.stop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ env: process.env, disable: true }),
|
||||
);
|
||||
expect(onNotLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits started when a not-loaded start path repairs the service", async () => {
|
||||
service.isLoaded.mockResolvedValue(false);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type DaemonLifecycleOptions = {
|
||||
force?: boolean;
|
||||
wait?: string;
|
||||
restartIntent?: GatewayRestartIntent;
|
||||
disable?: boolean;
|
||||
};
|
||||
|
||||
type RestartPostCheckContext = {
|
||||
@@ -362,6 +363,7 @@ export async function runServiceStop(params: {
|
||||
service: GatewayService;
|
||||
opts?: DaemonLifecycleOptions;
|
||||
onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise<ServiceRecoveryResult | null>;
|
||||
stopWhenNotLoaded?: boolean;
|
||||
}) {
|
||||
const json = Boolean(params.opts?.json);
|
||||
const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json });
|
||||
@@ -382,6 +384,20 @@ export async function runServiceStop(params: {
|
||||
}
|
||||
}
|
||||
if (!loaded) {
|
||||
if (params.stopWhenNotLoaded) {
|
||||
try {
|
||||
await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable });
|
||||
} catch (err) {
|
||||
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "stopped",
|
||||
service: buildDaemonServiceSnapshot(params.service, false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const handled = await params.onNotLoaded?.({ json, stdout, fail });
|
||||
if (handled) {
|
||||
@@ -413,7 +429,7 @@ export async function runServiceStop(params: {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.service.stop({ env: process.env, stdout });
|
||||
await params.service.stop({ env: process.env, stdout, disable: params.opts?.disable });
|
||||
} catch (err) {
|
||||
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
|
||||
return;
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("runDaemonRestart health checks", () => {
|
||||
safe?: boolean;
|
||||
force?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
let runDaemonStop: (opts?: { json?: boolean }) => Promise<void>;
|
||||
let runDaemonStop: (opts?: { json?: boolean; disable?: boolean }) => Promise<void>;
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
function mockUnmanagedRestart({
|
||||
@@ -447,6 +447,19 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM");
|
||||
});
|
||||
|
||||
it("routes macOS disable stops through the service manager when not loaded", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
|
||||
|
||||
await runDaemonStop({ json: true, disable: true });
|
||||
|
||||
expect(runServiceStop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
opts: { json: true, disable: true },
|
||||
stopWhenNotLoaded: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips gateway port resolution on stop when the service manager handles the stop", async () => {
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
opts,
|
||||
stopWhenNotLoaded: process.platform === "darwin" && Boolean(opts.disable),
|
||||
onNotLoaded: async () => {
|
||||
gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() =>
|
||||
resolveGatewayPortFallback(),
|
||||
|
||||
@@ -114,6 +114,11 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
|
||||
.command("stop")
|
||||
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option(
|
||||
"--disable",
|
||||
"Persistently suppress KeepAlive/RunAtLoad so the gateway does not respawn until next start (launchd only)",
|
||||
false,
|
||||
)
|
||||
.action(async (cmdOpts) => {
|
||||
const { runDaemonStop } = await loadDaemonLifecycleModule();
|
||||
await runDaemonStop(cmdOpts);
|
||||
|
||||
@@ -29,4 +29,5 @@ export type DaemonLifecycleOptions = {
|
||||
force?: boolean;
|
||||
safe?: boolean;
|
||||
wait?: string;
|
||||
disable?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user