fix: preserve unmanaged gateway restart truth (#105241)

This commit is contained in:
Peter Steinberger
2026-07-12 10:32:17 +01:00
committed by GitHub
parent 1592d422ee
commit cc3dfc9ded
4 changed files with 68 additions and 12 deletions
+27
View File
@@ -53,6 +53,7 @@ const probeGateway = vi.fn<
const callGatewayCli = vi.fn();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
const readActiveGatewayLockPort = vi.hoisted(() => vi.fn<() => Promise<number | undefined>>());
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
const repairLoadedGatewayServiceForStart = vi.hoisted(() => vi.fn());
const findInstalledSystemdGatewayScope = vi.hoisted(() =>
@@ -103,6 +104,10 @@ vi.mock("../../infra/gateway-processes.js", () => ({
formatGatewayPidList: (pids: number[]) => formatGatewayPidList(pids),
}));
vi.mock("../../infra/gateway-lock.js", () => ({
readActiveGatewayLockPort: () => readActiveGatewayLockPort(),
}));
vi.mock("../../gateway/probe.js", () => ({
probeGateway: (opts: {
url: string;
@@ -214,6 +219,7 @@ describe("runDaemonRestart health checks", () => {
callGatewayCli.mockReset();
isRestartEnabled.mockReset();
loadConfig.mockReset();
readActiveGatewayLockPort.mockReset();
recoverInstalledLaunchAgent.mockReset();
repairLoadedGatewayServiceForStart.mockReset();
@@ -224,6 +230,7 @@ describe("runDaemonRestart health checks", () => {
service.restart.mockResolvedValue({ outcome: "completed" });
runServiceStart.mockResolvedValue(undefined);
recoverInstalledLaunchAgent.mockResolvedValue(null);
readActiveGatewayLockPort.mockResolvedValue(undefined);
findInstalledSystemdGatewayScope.mockReset();
findInstalledSystemdGatewayScope.mockResolvedValue(null);
restartSystemdService.mockReset();
@@ -575,6 +582,26 @@ describe("runDaemonRestart health checks", () => {
expect(service.restart).not.toHaveBeenCalled();
});
it("signals and verifies the active unmanaged port despite a config edit", async () => {
loadConfig.mockReturnValue({ gateway: { port: 19_001 } });
readActiveGatewayLockPort.mockResolvedValue(18_789);
findVerifiedGatewayListenerPidsOnPortSync.mockImplementation((port) =>
port === 18_789 ? [4200] : [],
);
mockUnmanagedRestart({ runPostRestartCheck: true });
await runDaemonRestart({ json: true });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18_789);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1");
expect(waitForGatewayHealthyListener).toHaveBeenCalledWith(
expect.objectContaining({ port: 18_789 }),
);
});
it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
recoverInstalledLaunchAgent.mockResolvedValue({
+14 -8
View File
@@ -12,6 +12,7 @@ import {
} from "../../daemon/systemd.js";
import { callGatewayCli } from "../../gateway/call.js";
import { probeGateway } from "../../gateway/probe.js";
import { readActiveGatewayLockPort } from "../../infra/gateway-lock.js";
import {
findVerifiedGatewayListenerPidsOnPortSync,
formatGatewayPidList,
@@ -334,9 +335,14 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
let restartedWithoutServiceManager = false;
const restartIntent = resolveGatewayRestartIntentOptions(opts);
const configuredPort = await resolveExplicitGatewayConfigPort();
const restartPort =
const managedRestartPort =
configuredPort ??
(await resolveGatewayLifecyclePort(service).catch(() => resolveGatewayPortFallback()));
// An unmanaged run loop keeps its lock port across in-process restarts, even
// when config changes underneath it. Use that port for both the signal and
// health proof or a valid CLI/env override looks like a failed restart.
const unmanagedPort =
(await readActiveGatewayLockPort().catch(() => undefined)) ?? managedRestartPort;
const restartHealthAttempts = postRestartHealthAttempts();
const restartWaitMs = restartHealthAttempts * POST_RESTART_HEALTH_DELAY_MS;
const restartWaitSeconds = Math.round(restartWaitMs / 1000);
@@ -369,7 +375,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
return recovered;
}
}
const handled = await restartGatewayWithoutServiceManager(restartPort, restartIntent);
const handled = await restartGatewayWithoutServiceManager(unmanagedPort, restartIntent);
if (handled) {
restartedWithoutServiceManager = true;
return handled;
@@ -380,7 +386,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
if (restartedWithoutServiceManager) {
// SIGUSR1 restarts have no service-manager state to watch; use listener health only.
const health = await waitForGatewayHealthyListener({
port: restartPort,
port: unmanagedPort,
attempts: restartHealthAttempts,
delayMs: POST_RESTART_HEALTH_DELAY_MS,
});
@@ -389,7 +395,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
const diagnostics = renderGatewayPortHealthDiagnostics(health);
const timeoutLine = `Timed out after ${restartWaitSeconds}s waiting for gateway port ${restartPort} to become healthy.`;
const timeoutLine = `Timed out after ${restartWaitSeconds}s waiting for gateway port ${unmanagedPort} to become healthy.`;
if (!jsonOutput) {
defaultRuntime.log(theme.warn(timeoutLine));
for (const line of diagnostics) {
@@ -409,7 +415,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
let health = await waitForGatewayHealthyRestart({
service,
port: restartPort,
port: managedRestartPort,
attempts: restartHealthAttempts,
delayMs: POST_RESTART_HEALTH_DELAY_MS,
includeUnknownListenersAsStale: process.platform === "win32",
@@ -432,7 +438,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
}
health = await waitForGatewayHealthyRestart({
service,
port: restartPort,
port: managedRestartPort,
attempts: restartHealthAttempts,
delayMs: POST_RESTART_HEALTH_DELAY_MS,
includeUnknownListenersAsStale: process.platform === "win32",
@@ -446,12 +452,12 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
const diagnostics = renderRestartDiagnostics(health);
const failure = formatRestartFailure({
health,
port: restartPort,
port: managedRestartPort,
timeoutSeconds: restartWaitSeconds,
});
const runningNoPortLine =
health.runtime.status === "running" && health.portUsage.status === "free"
? `Gateway process is running but port ${restartPort} is still free (startup hang/crash loop or very slow VM startup).`
? `Gateway process is running but port ${managedRestartPort} is still free (startup hang/crash loop or very slow VM startup).`
: null;
if (!jsonOutput) {
defaultRuntime.log(theme.warn(failure.statusLine));