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:
Peter Steinberger
2026-05-08 05:35:21 +01:00
committed by GitHub
parent fe79d85ae0
commit 1f88cb2ce5
12 changed files with 182 additions and 31 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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(),

View File

@@ -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);

View File

@@ -29,4 +29,5 @@ export type DaemonLifecycleOptions = {
force?: boolean;
safe?: boolean;
wait?: string;
disable?: boolean;
};