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.
This commit is contained in:
Peter Steinberger
2026-08-13 02:04:25 -07:00
committed by GitHub
parent af377128b4
commit 5ed9bc2bd2
4 changed files with 48 additions and 11 deletions
+27
View File
@@ -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();
+13 -11
View File
@@ -124,11 +124,22 @@ async function resolveServiceLoadedOrFail(params: {
serviceNoun: string;
service: GatewayService;
fail: ReturnType<typeof createDaemonActionContext>["fail"];
acceptInstalledDefinition?: boolean;
}): Promise<boolean | null> {
// 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;
+4
View File
@@ -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),
};
+4
View File
@@ -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<GatewayServiceRestartResult>;
isLoaded: (args: GatewayServiceEnvArgs) => Promise<boolean>;
isEnabled?: (args: GatewayServiceEnvArgs) => Promise<boolean>;
hasInstalledDefinition?: (args: GatewayServiceEnvArgs) => Promise<boolean>;
readCommand: (env: GatewayServiceEnv) => Promise<GatewayServiceCommandConfig | null>;
readRuntime: (
env: GatewayServiceEnv,
@@ -354,6 +356,8 @@ const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayS
stop: stopSystemdService,
restart: restartSystemdService,
isLoaded: isSystemdServiceEnabled,
hasInstalledDefinition: async ({ env }) =>
(await findInstalledSystemdGatewayScope(env ?? process.env)) !== null,
readCommand: readSystemdServiceExecStart,
readRuntime: readSystemdServiceRuntime,
},