diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 19de7151d7e..f642463f599 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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); + }); +}); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 78c243d9c9c..24e5cc4f0ca 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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 { 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 { + 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 { 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 { 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 { result: resultWithPostUpdate, opts, refreshServiceEnv: refreshGatewayServiceEnv, + serviceEnv: gatewayServiceEnv, gatewayPort, restartScriptPath, invocationCwd,