fix: restart package updates through updated install

This commit is contained in:
Peter Steinberger
2026-04-26 23:51:44 +01:00
parent b5714b90ed
commit 6077941d0b
2 changed files with 159 additions and 8 deletions

View File

@@ -4,6 +4,10 @@ import {
buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates,
resolveGatewayInstallEntrypoint,
} from "../../daemon/gateway-entrypoint.js";
import {
shouldPrepareUpdatedInstallRestart,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command.js";
describe("resolveGatewayInstallEntrypointCandidates", () => {
it("prefers index.js before legacy entry.js", () => {
@@ -39,3 +43,55 @@ describe("resolveGatewayInstallEntrypoint", () => {
).resolves.toBe(entryPath);
});
});
describe("shouldPrepareUpdatedInstallRestart", () => {
it("prepares package update restarts when the service is installed but stopped", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(true);
});
it("does not install a new service for package updates when no service exists", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "npm",
serviceInstalled: false,
serviceLoaded: false,
}),
).toBe(false);
});
it("keeps non-package updates tied to the loaded service state", () => {
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: false,
}),
).toBe(false);
expect(
shouldPrepareUpdatedInstallRestart({
updateMode: "git",
serviceInstalled: true,
serviceLoaded: true,
}),
).toBe(true);
});
});
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
it("never restarts package updates through the pre-update process", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
});
it("keeps the in-process restart path for non-package updates", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
});
});

View File

@@ -17,7 +17,7 @@ import { formatConfigIssueLines } from "../../config/issue-format.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { createLowDiskSpaceWarning } from "../../infra/disk-space.js";
import { runGlobalPackageUpdateSteps } from "../../infra/package-update-steps.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -133,6 +133,24 @@ function pickUpdateQuip(): string {
function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
serviceLoaded: boolean;
}): boolean {
if (isPackageManagerUpdateMode(params.updateMode)) {
return params.serviceInstalled;
}
return params.serviceLoaded;
}
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
function formatCommandFailure(stdout: string, stderr: string): string {
const detail = (stderr || stdout).trim();
if (!detail) {
@@ -267,6 +285,7 @@ async function refreshGatewayServiceEnv(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const args = ["gateway", "install", "--force"];
if (params.jsonMode) {
@@ -277,7 +296,7 @@ async function refreshGatewayServiceEnv(params: {
if (entrypoint) {
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(process.env, params.invocationCwd),
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
@@ -288,9 +307,45 @@ async function refreshGatewayServiceEnv(params: {
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
await runDaemonInstall({ force: true, json: params.jsonMode || undefined });
}
async function runUpdatedInstallGatewayRestart(params: {
result: UpdateRunResult;
jsonMode: boolean;
invocationCwd?: string;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root);
if (!entrypoint) {
throw new Error(
`updated install entrypoint not found under ${params.result.root ?? "unknown"}`,
);
}
const args = ["gateway", "restart"];
if (params.jsonMode) {
args.push("--json");
}
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
cwd: params.result.root,
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
});
if (res.code === 0) {
return true;
}
throw new Error(
`updated install restart failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`,
);
}
async function tryInstallShellCompletion(opts: {
jsonMode: boolean;
skipPrompt: boolean;
@@ -739,11 +794,26 @@ async function maybeRestartService(params: {
result: UpdateRunResult;
opts: UpdateCommandOptions;
refreshServiceEnv: boolean;
serviceEnv?: NodeJS.ProcessEnv;
gatewayPort: number;
restartScriptPath?: string | null;
invocationCwd?: string;
}): Promise<boolean> {
const verifyRestartedGateway = async (expectedGatewayVersion: string | undefined) => {
const restartAfterStaleCleanup = async () => {
if (params.refreshServiceEnv && isPackageManagerUpdateMode(params.result.mode)) {
await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
return;
}
if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
await runDaemonRestart();
}
};
const service = resolveGatewayService();
let health = await waitForGatewayHealthyRestart({
service,
@@ -759,7 +829,7 @@ async function maybeRestartService(params: {
);
}
await terminateStaleGatewayPids(health.staleGatewayPids);
await runDaemonRestart();
await restartAfterStaleCleanup();
health = await waitForGatewayHealthyRestart({
service,
port: params.gatewayPort,
@@ -799,6 +869,7 @@ async function maybeRestartService(params: {
const expectedGatewayVersion = isPackageManagerUpdateMode(params.result.mode)
? normalizeOptionalString(params.result.after?.version)
: undefined;
const isPackageUpdate = isPackageManagerUpdateMode(params.result.mode);
let restarted = false;
let restartInitiated = false;
if (params.refreshServiceEnv) {
@@ -807,6 +878,7 @@ async function maybeRestartService(params: {
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} catch (err) {
// Always log the refresh failure so callers can detect it (issue #56772).
@@ -818,7 +890,7 @@ async function maybeRestartService(params: {
} else {
defaultRuntime.log(theme.warn(message));
}
if (isPackageManagerUpdateMode(params.result.mode)) {
if (isPackageUpdate) {
return false;
}
}
@@ -826,8 +898,17 @@ async function maybeRestartService(params: {
if (params.restartScriptPath) {
await runRestartScript(params.restartScriptPath);
restartInitiated = true;
} else {
} else if (params.refreshServiceEnv && isPackageUpdate) {
restarted = await runUpdatedInstallGatewayRestart({
result: params.result,
jsonMode: Boolean(params.opts.json),
invocationCwd: params.invocationCwd,
env: params.serviceEnv,
});
} else if (shouldUseLegacyProcessRestartAfterUpdate({ updateMode: params.result.mode })) {
restarted = await runDaemonRestart();
} else if (!params.opts.json) {
defaultRuntime.log(theme.muted("No installed gateway service found; skipped restart."));
}
const shouldVerifyRestart =
@@ -871,6 +952,9 @@ async function maybeRestartService(params: {
),
);
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return false;
}
}
return true;
}
@@ -1419,15 +1503,25 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
let restartScriptPath: string | null = null;
let refreshGatewayServiceEnv = false;
let gatewayServiceEnv: NodeJS.ProcessEnv | undefined;
const gatewayPort = resolveGatewayPort(
postUpdateConfigSnapshot.valid ? postUpdateConfigSnapshot.config : undefined,
process.env,
);
if (shouldRestart) {
try {
const loaded = await resolveGatewayService().isLoaded({ env: process.env });
if (loaded) {
restartScriptPath = await prepareRestartScript(process.env, gatewayPort);
const serviceState = await readGatewayServiceState(resolveGatewayService(), {
env: process.env,
});
if (
shouldPrepareUpdatedInstallRestart({
updateMode: resultWithPostUpdate.mode,
serviceInstalled: serviceState.installed,
serviceLoaded: serviceState.loaded,
})
) {
gatewayServiceEnv = serviceState.env;
restartScriptPath = await prepareRestartScript(serviceState.env, gatewayPort);
refreshGatewayServiceEnv = true;
}
} catch {
@@ -1446,6 +1540,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
result: resultWithPostUpdate,
opts,
refreshServiceEnv: refreshGatewayServiceEnv,
serviceEnv: gatewayServiceEnv,
gatewayPort,
restartScriptPath,
invocationCwd,