From 5ed9bc2bd29623f8165e613a92e19b89c436b0c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 02:04:25 -0700 Subject: [PATCH] fix: gateway restart avoids false failure when systemd probe is unavailable (#123069) * fix(cli): avoid false gateway restart failures Allow restart to proceed when the loaded-state probe is unavailable but an installed service definition identifies the native manager. Native restart errors and Gateway health checks remain authoritative. * fix(daemon): detect system-scope restart definitions Move installed-definition detection into the platform service adapter so gateway restart can recover from an unavailable loaded-state probe for both user- and system-scope systemd units. --- src/cli/daemon-cli/lifecycle-core.test.ts | 27 +++++++++++++++++++++++ src/cli/daemon-cli/lifecycle-core.ts | 24 +++++++++++--------- src/daemon/node-service.ts | 4 ++++ src/daemon/service.ts | 4 ++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 3c24a6261aaa..90c9d3f308aa 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -268,6 +268,33 @@ describe("runServiceRestart token drift", () => { ); }); + it("restarts an installed system-scope service when its loaded-state probe is unavailable", async () => { + service.isLoaded.mockRejectedValue( + new Error( + "systemctl is-enabled unavailable: Command failed during launch or output capture (EACCES)", + ), + ); + service.readCommand.mockResolvedValue(null); + const hasInstalledDefinition = vi.fn(async () => true); + const postRestartCheck = vi.fn(async () => {}); + + await expect( + runServiceRestart({ + ...createServiceRunArgs(), + service: { ...service, hasInstalledDefinition } as GatewayService, + postRestartCheck, + }), + ).resolves.toBe(true); + + expect(hasInstalledDefinition).toHaveBeenCalledWith({ env: process.env }); + expect(service.restart).toHaveBeenCalledTimes(1); + expect(postRestartCheck).toHaveBeenCalledTimes(1); + expect(readJsonLog<{ ok?: boolean; result?: string }>()).toMatchObject({ + ok: true, + result: "restarted", + }); + }); + it("aborts loaded-service mutation when the service guard rejects", async () => { const repairLoadedService = vi.fn(); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index f684edef5feb..38586bc2ea71 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -124,11 +124,22 @@ async function resolveServiceLoadedOrFail(params: { serviceNoun: string; service: GatewayService; fail: ReturnType["fail"]; + acceptInstalledDefinition?: boolean; }): Promise { // Returning null keeps failure emission centralized in the caller's action context. try { return await params.service.isLoaded({ env: process.env }); } catch (err) { + if (params.acceptInstalledDefinition) { + // The adapter owns platform-specific install discovery; systemd spans + // user, system, marker-owned, and dueling definitions. + const installed = params.service.hasInstalledDefinition + ? await params.service.hasInstalledDefinition({ env: process.env }).catch(() => false) + : Boolean(await params.service.readCommand(process.env).catch(() => null)); + if (installed) { + return true; + } + } params.fail(`${params.serviceNoun} service check failed: ${String(err)}`); return null; } @@ -514,6 +525,7 @@ export async function runServiceRestart(params: { serviceNoun: params.serviceNoun, service: params.service, fail, + acceptInstalledDefinition: true, }); if (loaded === null) { return false; @@ -679,21 +691,11 @@ export async function runServiceRestart(params: { } } } - let restarted = loaded; - if (loaded) { - try { - restarted = await params.service.isLoaded({ env: process.env }); - } catch { - restarted = true; - } - } else if (recoveredLoadedState !== null) { - restarted = recoveredLoadedState; - } emit({ ok: true, result: "restarted", message: handledRecovery?.message ?? handledRepair?.message, - service: buildDaemonServiceSnapshot(params.service, restarted), + service: buildDaemonServiceSnapshot(params.service, loaded || recoveredLoadedState === true), warnings: warnings.length ? warnings : undefined, }); const actionMessage = handledRecovery?.message ?? handledRepair?.message; diff --git a/src/daemon/node-service.ts b/src/daemon/node-service.ts index 424462074b94..82ab517ffaee 100644 --- a/src/daemon/node-service.ts +++ b/src/daemon/node-service.ts @@ -26,6 +26,7 @@ function withNodeInstallEnv(args: GatewayServiceInstallArgs): GatewayServiceInst /** Returns a service controller bound to node-host labels across all platforms. */ export function resolveNodeService(): GatewayService { const base = resolveGatewayService(); + const hasInstalledDefinition = base.hasInstalledDefinition; return { ...base, stage: (args) => base.stage(withNodeInstallEnv(args)), @@ -39,6 +40,9 @@ export function resolveNodeService(): GatewayService { // wedged service manager instead of hanging the whole status command. return base.isLoaded({ env: withNodeServiceEnv(args.env ?? {}), timeoutMs: args.timeoutMs }); }, + hasInstalledDefinition: hasInstalledDefinition + ? (args) => hasInstalledDefinition({ ...args, env: withNodeServiceEnv(args.env ?? {}) }) + : undefined, readCommand: (env) => base.readCommand(withNodeServiceEnv(env)), readRuntime: (env, opts) => base.readRuntime(withNodeServiceEnv(env), opts), }; diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 30fbcc357dd1..80978c8bc47b 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -46,6 +46,7 @@ import type { GatewayServiceState, } from "./service-types.js"; import { + findInstalledSystemdGatewayScope, installSystemdService, isSystemdServiceEnabled, readSystemdServiceExecStart, @@ -84,6 +85,7 @@ export type GatewayService = { restart: (args: GatewayServiceControlArgs) => Promise; isLoaded: (args: GatewayServiceEnvArgs) => Promise; isEnabled?: (args: GatewayServiceEnvArgs) => Promise; + hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise; readCommand: (env: GatewayServiceEnv) => Promise; readRuntime: ( env: GatewayServiceEnv, @@ -354,6 +356,8 @@ const GATEWAY_SERVICE_REGISTRY: Record + (await findInstalledSystemdGatewayScope(env ?? process.env)) !== null, readCommand: readSystemdServiceExecStart, readRuntime: readSystemdServiceRuntime, },