From 7264f5d9fd422b4ff894f0ee507d5eeec94b8cf9 Mon Sep 17 00:00:00 2001 From: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:37:18 +0000 Subject: [PATCH] refactor(update): simplify finalization heap fix Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com> --- src/cli/daemon-cli/status.gather.ts | 2 +- src/cli/daemon-cli/status.print.ts | 2 +- src/cli/update-cli.test.ts | 85 +++++++----------- .../update-command-fresh-doctor.test.ts | 51 ----------- .../update-cli/update-command-fresh-doctor.ts | 46 ++++++++-- src/cli/update-cli/update-command-package.ts | 39 +++++---- .../update-cli/update-command-post-core.ts | 87 +++++++++---------- src/commands/doctor-gateway-services.ts | 2 +- .../gateway-heap.test.ts} | 20 +---- .../node-heap.ts => daemon/gateway-heap.ts} | 12 --- src/daemon/service-env.test.ts | 2 +- src/daemon/service-env.ts | 2 +- src/entry.respawn.test.ts | 10 ++- src/entry.respawn.ts | 13 +++ src/infra/update-node-options.ts | 16 ++++ src/infra/update-post-core-finalize.test.ts | 4 - src/infra/update-post-core-finalize.ts | 2 - src/infra/update-runner-doctor.ts | 10 --- src/infra/update-runner-git-commands.ts | 8 +- src/infra/update-runner.test.ts | 1 - 20 files changed, 182 insertions(+), 232 deletions(-) delete mode 100644 src/cli/update-cli/update-command-fresh-doctor.test.ts rename src/{infra/node-heap.test.ts => daemon/gateway-heap.test.ts} (84%) rename src/{infra/node-heap.ts => daemon/gateway-heap.ts} (92%) create mode 100644 src/infra/update-node-options.ts diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 10f663463048..fe9a9bd0395c 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -20,6 +20,7 @@ import type { } from "../../config/types.js"; import { resolveSecretInputRef } from "../../config/types.secrets.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; +import { inspectGatewayHeapLimit, type GatewayHeapLimitReport } from "../../daemon/gateway-heap.js"; import type { ExtraGatewayService, FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js"; import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; @@ -42,7 +43,6 @@ import { inspectBestEffortPrimaryTailnetIPv4, resolveBestEffortGatewayBindHostForDisplay, } from "../../infra/network-discovery-display.js"; -import { inspectGatewayHeapLimit, type GatewayHeapLimitReport } from "../../infra/node-heap.js"; import { formatPortDiagnostics } from "../../infra/ports-format.js"; import { inspectPortConnections, diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 152da6fb04ff..063080942bed 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -5,6 +5,7 @@ import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, } from "../../daemon/constants.js"; +import { formatGatewayHeapLimitReport } from "../../daemon/gateway-heap.js"; import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js"; import { resolveGatewayRestartLogPath, @@ -17,7 +18,6 @@ import { } from "../../daemon/systemd-hints.js"; import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailable.js"; import { resolveControlUiLinks } from "../../gateway/control-ui-links.js"; -import { formatGatewayHeapLimitReport } from "../../infra/node-heap.js"; import { formatGatewayRestartHandoffDiagnostic } from "../../infra/restart-handoff.js"; import { isWSLEnv } from "../../infra/wsl.js"; import { resolvePluginVersionDriftUpdateCommand } from "../../plugins/plugin-version-drift.js"; diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 331e1acfb0ab..8882b28c6ef2 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -104,15 +104,11 @@ const execFile = vi.fn((...args: unknown[]) => { }); const spawn = vi.fn(); const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); -const inheritedServiceEnvKeys = [ +const serviceEnvSnapshot = captureEnv([ "OPENCLAW_SERVICE_MARKER", "OPENCLAW_SERVICE_KIND", GATEWAY_SERVICE_RUNTIME_PID_ENV, - "OPENCLAW_LAUNCHD_LABEL", - "OPENCLAW_SYSTEMD_UNIT", - "OPENCLAW_WINDOWS_TASK_NAME", -] as const; -const serviceEnvSnapshot = captureEnv([...inheritedServiceEnvKeys]); +]); vi.mock("@clack/prompts", () => ({ confirm, @@ -1001,14 +997,6 @@ describe("update-cli", () => { const FRESH_POST_UPDATE_ENTRYPOINT = "/tmp/openclaw-updated-entry.mjs"; - const mockFreshDoctorEntrypoints = (count = 1) => { - for (let index = 0; index < count; index += 1) { - vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( - FRESH_POST_UPDATE_ENTRYPOINT, - ); - } - }; - const mockCurrentProcessFreshDoctor = (params: { postCoreResumeAttempt?: boolean } = {}) => { if (params.postCoreResumeAttempt !== false) { vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(undefined); @@ -1355,9 +1343,9 @@ describe("update-cli", () => { }; beforeEach(() => { - for (const key of inheritedServiceEnvKeys) { - delete process.env[key]; - } + delete process.env.OPENCLAW_SERVICE_MARKER; + delete process.env.OPENCLAW_SERVICE_KIND; + delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; restartHealthTestControl.snapshot = undefined; vi.clearAllMocks(); serviceEnabled.mockResolvedValue(true); @@ -7548,8 +7536,7 @@ describe("update-cli", () => { }); }); - it("updateFinalizeCommand defers plugin installation during fresh pre-plugin doctor", async () => { - mockFreshDoctorEntrypoints(); + it("updateFinalizeCommand defers plugin installation during pre-plugin doctor", async () => { await withEnvAsync( { OPENCLAW_UPDATE_IN_PROGRESS: undefined, @@ -7558,6 +7545,10 @@ describe("update-cli", () => { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, async () => { + let doctorEnv: NodeJS.ProcessEnv | undefined; + vi.mocked(doctorCommand).mockImplementationOnce(async () => { + doctorEnv = { ...process.env }; + }); vi.mocked(defaultRuntime.writeJson).mockClear(); await updateFinalizeCommand({ @@ -7568,30 +7559,19 @@ describe("update-cli", () => { acknowledgeClawHubRisk: true, }); - const doctorCall = vi - .mocked(runExec) - .mock.calls.find(([, args]) => args[0] === FRESH_POST_UPDATE_ENTRYPOINT); - const doctorOptions = doctorCall?.[2]; - const doctorEnv = typeof doctorOptions === "number" ? undefined : doctorOptions?.env; - expect(doctorCall?.[1]).toEqual([ - FRESH_POST_UPDATE_ENTRYPOINT, - "doctor", - "--repair", - "--non-interactive", - "--no-workspace-suggestions", - "--yes", - ]); - expect(doctorEnv).toMatchObject({ - OPENCLAW_UPDATE_IN_PROGRESS: "1", - OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1", - OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", - }); + expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); + expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1"); + expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBeUndefined(); expect(process.env.OPENCLAW_UPDATE_IN_PROGRESS).toBeUndefined(); expect(process.env.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBeUndefined(); expect(process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBeUndefined(); expect(process.env.OPENCLAW_UPDATE_POST_CORE_CONVERGENCE).toBe("1"); - expect(doctorCommand).not.toHaveBeenCalled(); + expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, { + nonInteractive: true, + repair: true, + yes: true, + }); expect(syncPluginCall()?.channel).toBe("stable"); expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000); @@ -7635,7 +7615,7 @@ describe("update-cli", () => { }); it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => { - mockFreshDoctorEntrypoints(2); + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce("/tmp/openclaw-entry.mjs"); const preDoctorConfig = { update: { channel: "stable" }, plugins: { entries: { pre: { enabled: true } } }, @@ -7670,29 +7650,31 @@ describe("update-cli", () => { await updateFinalizeCommand({ json: true, timeout: "9", restart: false }); - expect(doctorCommand).not.toHaveBeenCalled(); - const freshDoctorCalls = vi + expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, { + nonInteractive: true, + repair: true, + yes: false, + }); + expect(doctorCommand).toHaveBeenCalledTimes(1); + const freshDoctorCall = vi .mocked(runExec) - .mock.calls.filter(([, args]) => args.includes("doctor")); - expect(freshDoctorCalls).toHaveLength(2); - expect(freshDoctorCalls[0]?.[1]).toEqual([ - FRESH_POST_UPDATE_ENTRYPOINT, + .mock.calls.find(([, args]) => args.includes("doctor")); + expect(freshDoctorCall?.[1]).toEqual([ + "/tmp/openclaw-entry.mjs", "doctor", "--repair", "--non-interactive", "--no-workspace-suggestions", ]); - expect(freshDoctorCalls[0]?.[2]).toMatchObject({ + expect(freshDoctorCall?.[2]).toMatchObject({ cwd: process.cwd(), env: { OPENCLAW_UPDATE_IN_PROGRESS: "1", OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1", OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", + OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1", }, }); - expect(freshDoctorCalls[1]?.[2]).toMatchObject({ - env: { OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1" }, - }); expect(syncPluginCall()?.channel).toBe("beta"); expect(syncPluginCall()?.config).toEqual({ ...postDoctorConfig, @@ -7702,14 +7684,13 @@ describe("update-cli", () => { }, }); expect(lastReplaceConfigCall()?.baseHash).toBe("post-doctor"); - expect(vi.mocked(runExec).mock.invocationCallOrder[0] ?? 0).toBeLessThan( + expect(vi.mocked(doctorCommand).mock.invocationCallOrder[0] ?? 0).toBeLessThan( loadInstalledPluginIndexInstallRecords.mock.invocationCallOrder[0] ?? 0, ); expect((lastWriteJsonCall() as { channel?: string } | undefined)?.channel).toBe("beta"); }); it("updateFinalizeCommand restores channels from the RPC pre-update config payload", async () => { - mockFreshDoctorEntrypoints(); const tempDir = createCaseDir("openclaw-rpc-finalize"); const sourceConfigPath = path.join(tempDir, "source-config.json"); const preUpdateConfig = { @@ -7756,7 +7737,6 @@ describe("update-cli", () => { }); it("updateFinalizeCommand reapplies requested channel against post-doctor config", async () => { - mockFreshDoctorEntrypoints(); const preDoctorConfig = { update: { channel: "stable" } } as OpenClawConfig; const postDoctorConfig = { update: { channel: "beta" } } as OpenClawConfig; const preDoctorSnapshot = configSnapshot(preDoctorConfig, { @@ -7784,7 +7764,6 @@ describe("update-cli", () => { }); it("updateFinalizeCommand converges on the effective channel from env without persisting update.channel", async () => { - mockFreshDoctorEntrypoints(); const noChannelConfig = {} as OpenClawConfig; const noChannelSnapshot = configSnapshot(noChannelConfig, { parsed: baseSnapshot.parsed, diff --git a/src/cli/update-cli/update-command-fresh-doctor.test.ts b/src/cli/update-cli/update-command-fresh-doctor.test.ts deleted file mode 100644 index d106d7cf4e6b..000000000000 --- a/src/cli/update-cli/update-command-fresh-doctor.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; -import { withEnvAsync } from "../../test-utils/env.js"; -import { runUpdateFinalizationDoctorInFreshProcess } from "./update-command-fresh-doctor.js"; - -const tempDirs = useAutoCleanupTempDirTracker(afterEach); - -describe("runUpdateFinalizationDoctorInFreshProcess", () => { - it("gives the updater-owned Doctor enough heap while preserving other Node options", async () => { - const root = tempDirs.make("openclaw-update-doctor-heap-"); - const entryPath = path.join(root, "capture-heap.mjs"); - const outputPath = path.join(root, "heap.json"); - await fs.writeFile( - entryPath, - `import fs from "node:fs"; -import { getHeapStatistics } from "node:v8"; -fs.writeFileSync(process.env.OPENCLAW_TEST_OUTPUT_PATH, JSON.stringify({ - heapSizeLimitMiB: Math.floor(getHeapStatistics().heap_size_limit / 1024 / 1024), - nodeOptions: process.env.NODE_OPTIONS ?? "", -}));`, - "utf8", - ); - - await withEnvAsync( - { - NODE_OPTIONS: "--trace-warnings --max-old-space-size=1024", - OPENCLAW_TEST_OUTPUT_PATH: outputPath, - }, - async () => { - await runUpdateFinalizationDoctorInFreshProcess({ - phase: "pre-plugin", - root, - yes: true, - json: true, - timeoutMs: 10_000, - nodeRunner: process.execPath, - entryPath, - }); - }, - ); - - const result = JSON.parse(await fs.readFile(outputPath, "utf8")) as { - heapSizeLimitMiB: number; - nodeOptions: string; - }; - expect(result.heapSizeLimitMiB).toBeGreaterThanOrEqual(8192); - expect(result.nodeOptions).toContain("--trace-warnings"); - }); -}); diff --git a/src/cli/update-cli/update-command-fresh-doctor.ts b/src/cli/update-cli/update-command-fresh-doctor.ts index 1bcb2fece3f7..60c22abc0b84 100644 --- a/src/cli/update-cli/update-command-fresh-doctor.ts +++ b/src/cli/update-cli/update-command-fresh-doctor.ts @@ -7,7 +7,6 @@ import { import { readConfigFileSnapshot } from "../../config/config.js"; import type { ConfigFileSnapshot } from "../../config/types.openclaw.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; -import { resolveUpdateNodeOptions } from "../../infra/update-runner-doctor.js"; import { runExec } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveNodeRunner } from "./shared.js"; @@ -23,10 +22,43 @@ import { type UpdateDoctorPhase = "pre-plugin" | "post-plugin"; -function resolveUpdateFinalizationBaseEnv(): NodeJS.ProcessEnv { - const env = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)); - env.NODE_OPTIONS = resolveUpdateNodeOptions(env.NODE_OPTIONS); - return env; +export async function withPrePluginUpdateDoctorEnv(run: () => Promise): Promise { + const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; + const previousDeferConfiguredPluginInstallRepair = + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; + const previousParentSupportsDoctorConfigWrite = + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; + const previousPostCoreConvergence = process.env[UPDATE_POST_CORE_CONVERGENCE_ENV]; + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1"; + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1"; + delete process.env[UPDATE_POST_CORE_CONVERGENCE_ENV]; + try { + return await run(); + } finally { + if (previousUpdateInProgress === undefined) { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } else { + process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress; + } + if (previousDeferConfiguredPluginInstallRepair === undefined) { + delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; + } else { + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = + previousDeferConfiguredPluginInstallRepair; + } + if (previousParentSupportsDoctorConfigWrite === undefined) { + delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; + } else { + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = + previousParentSupportsDoctorConfigWrite; + } + if (previousPostCoreConvergence === undefined) { + delete process.env[UPDATE_POST_CORE_CONVERGENCE_ENV]; + } else { + process.env[UPDATE_POST_CORE_CONVERGENCE_ENV] = previousPostCoreConvergence; + } + } } async function withNormalConfigValidation(run: () => Promise): Promise { @@ -83,7 +115,7 @@ export async function runUpdateFinalizationDoctorInFreshProcess(params: { "--no-workspace-suggestions", ...(params.yes ? ["--yes"] : []), ]; - const baseEnv = resolveUpdateFinalizationBaseEnv(); + const baseEnv = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)); delete baseEnv[UPDATE_POST_CORE_CONVERGENCE_ENV]; const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, { cwd: params.root, @@ -123,7 +155,7 @@ async function validatePostPluginConfigInFreshProcess(params: { timeoutMs: params.timeoutMs, maxBuffer: 4 * 1024 * 1024, logOutput: false, - baseEnv: resolveUpdateFinalizationBaseEnv(), + baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" }, }, ); diff --git a/src/cli/update-cli/update-command-package.ts b/src/cli/update-cli/update-command-package.ts index e68c63d89574..35f54fe8a342 100644 --- a/src/cli/update-cli/update-command-package.ts +++ b/src/cli/update-cli/update-command-package.ts @@ -1,5 +1,12 @@ import path from "node:path"; import { theme } from "../../../packages/terminal-core/src/theme.js"; +import { + UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, + UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV, + UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV, + UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, + UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV, +} from "../../commands/doctor/shared/update-phase.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { createLowDiskSpaceWarning } from "../../infra/disk-space.js"; import { @@ -17,7 +24,6 @@ import { resolveGlobalInstallTarget, type ResolvedGlobalInstallTarget, } from "../../infra/update-global.js"; -import { buildUpdateDoctorEnv } from "../../infra/update-runner-doctor.js"; import { resolveUpdateDoctorExecutionPolicy, type UpdateRunResult, @@ -139,27 +145,30 @@ export async function runPackageInstallUpdate(params: { total: 0, }; params.progress?.onStepStart?.(doctorProgressInfo); - const doctorBaseEnv = resolvePostInstallDoctorEnv({ - serviceEnv: params.managedServiceEnv, - invocationCwd: params.invocationCwd, - }); const doctorStep = await runUpdateStep({ name: `${CLI_NAME} doctor`, argv: doctorArgv, cwd: verifiedPackageRoot, env: { - ...doctorBaseEnv, - ...buildUpdateDoctorEnv({ - allowGatewayServiceRepair: params.allowGatewayServiceRepair, - allowGatewayActivation: params.allowGatewayActivation, - ...(doctorPolicy.serviceRepairPolicy - ? { serviceRepairPolicy: doctorPolicy.serviceRepairPolicy } - : {}), - deferConfiguredPluginInstallRepair: true, - compatibilityHostVersion: candidateHostVersion, - ...(doctorBaseEnv.NODE_OPTIONS ? { nodeOptions: doctorBaseEnv.NODE_OPTIONS } : {}), + ...resolvePostInstallDoctorEnv({ + serviceEnv: params.managedServiceEnv, + invocationCwd: params.invocationCwd, }), + OPENCLAW_UPDATE_IN_PROGRESS: "1", + [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1", + [UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: params.allowGatewayServiceRepair + ? "1" + : "0", + [UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV]: params.allowGatewayActivation ? "1" : "0", + ...(doctorPolicy.serviceRepairPolicy + ? { OPENCLAW_SERVICE_REPAIR_POLICY: doctorPolicy.serviceRepairPolicy } + : {}), [UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]: doctorResultPath, + ...(candidateHostVersion === null + ? {} + : { OPENCLAW_COMPATIBILITY_HOST_VERSION: candidateHostVersion }), }, timeoutMs: params.timeoutMs, }); diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index 6f703b80c8aa..9addbd454eba 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -7,6 +7,7 @@ import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number- import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; +import { doctorCommand } from "../../commands/doctor.js"; import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot, @@ -44,7 +45,6 @@ import { POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV, type PreUpdateConfigRestoreInput, } from "../../infra/update-post-core-context.js"; -import { resolveUpdateNodeOptions } from "../../infra/update-runner-doctor.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js"; import { @@ -79,7 +79,7 @@ import { } from "./update-command-config.js"; import { completePostCorePluginUpdate, - runUpdateFinalizationDoctorInFreshProcess, + withPrePluginUpdateDoctorEnv, } from "./update-command-fresh-doctor.js"; import { updatePluginsAfterCoreUpdate, @@ -220,48 +220,48 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis } const completedPluginUpdate = await withPluginLifecycleLease({}, async () => { - await createUpdateConfigSnapshot(); - await runUpdateFinalizationDoctorInFreshProcess({ - phase: "pre-plugin", - root, - yes: opts.yes === true, - json: opts.json === true, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - }); - configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); - if (requestedChannel) { - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, + const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { + await createUpdateConfigSnapshot(); + await doctorCommand(defaultRuntime, { + nonInteractive: true, + repair: true, + yes: opts.yes === true, + }); + configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); + if (requestedChannel) { + configSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + } + const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); + configSnapshot = restoredConfig.snapshot; + const postDoctorStoredChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + const postDoctorChannel = + requestedChannel ?? + postDoctorStoredChannel ?? + storedChannel ?? + effectiveChannel ?? + DEFAULT_PACKAGE_CHANNEL; + const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + return await updatePluginsAfterCoreUpdate({ + root, + channel: postDoctorChannel, + configSnapshot, + configChanged: restoredConfig.changed, + restoredAuthoredChannels: restoredConfig.authoredChannels, + opts: { + json: opts.json, + timeout: opts.timeout, + yes: opts.yes, + restart: false, + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + }, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, + pluginInstallRecords, }); - } - const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); - configSnapshot = restoredConfig.snapshot; - const postDoctorStoredChannel = configSnapshot.valid - ? normalizeUpdateChannel(configSnapshot.config.update?.channel) - : null; - const postDoctorChannel = - requestedChannel ?? - postDoctorStoredChannel ?? - storedChannel ?? - effectiveChannel ?? - DEFAULT_PACKAGE_CHANNEL; - const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); - const initialPluginUpdate = await updatePluginsAfterCoreUpdate({ - root, - channel: postDoctorChannel, - configSnapshot, - configChanged: restoredConfig.changed, - restoredAuthoredChannels: restoredConfig.authoredChannels, - opts: { - json: opts.json, - timeout: opts.timeout, - yes: opts.yes, - restart: false, - acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, - }, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - pluginInstallRecords, }); return await completePostCorePluginUpdate({ root, @@ -579,7 +579,6 @@ export async function continuePostCoreUpdateInFreshProcess(params: { requestedChannel: params.requestedChannel, sourceConfigPath: params.preUpdateConfig ? sourceConfigPath : undefined, }); - handoffEnv.NODE_OPTIONS = resolveUpdateNodeOptions(handoffEnv.NODE_OPTIONS); const child = spawn(params.nodeRunner ?? resolveNodeRunner(), argv, { stdio: childStdio, env: { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index e68fc260a51b..945c032e31d0 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -10,6 +10,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { replaceConfigFile, type OpenClawConfig } from "../config/config.js"; import { isDefaultInstallIdentity, resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; +import { formatGatewayHeapLimitReport, inspectGatewayHeapLimit } from "../daemon/gateway-heap.js"; import { findExtraGatewayServices, renderGatewayServiceCleanupHints, @@ -40,7 +41,6 @@ import { import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "../infra/gateway-supervision.js"; -import { formatGatewayHeapLimitReport, inspectGatewayHeapLimit } from "../infra/node-heap.js"; import { readWindowsProcessArgsSync } from "../infra/windows-port-pids.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; diff --git a/src/infra/node-heap.test.ts b/src/daemon/gateway-heap.test.ts similarity index 84% rename from src/infra/node-heap.test.ts rename to src/daemon/gateway-heap.test.ts index 82a95d5a806f..ad91fc526459 100644 --- a/src/infra/node-heap.test.ts +++ b/src/daemon/gateway-heap.test.ts @@ -4,8 +4,7 @@ import { formatGatewayHeapLimitReport, inspectGatewayHeapLimit, resolveGatewayHeapNodeOptions, - resolveNodeOptionsWithMinimumOldSpaceSize, -} from "./node-heap.js"; +} from "./gateway-heap.js"; const MIB = 1024 * 1024; @@ -115,20 +114,3 @@ describe("Gateway service NODE_OPTIONS", () => { ); }); }); - -describe("minimum Node heap options", () => { - it("raises a lower limit without dropping other options", () => { - expect( - resolveNodeOptionsWithMinimumOldSpaceSize("--trace-warnings --max-old-space-size=1024", 8192), - ).toBe("--trace-warnings --max-old-space-size=1024 --max-old-space-size=8192"); - }); - - it("preserves a larger explicit limit in any Node-supported spelling", () => { - expect( - resolveNodeOptionsWithMinimumOldSpaceSize( - "--max_old_space_size 12288 --trace-warnings", - 8192, - ), - ).toBe("--max_old_space_size 12288 --trace-warnings"); - }); -}); diff --git a/src/infra/node-heap.ts b/src/daemon/gateway-heap.ts similarity index 92% rename from src/infra/node-heap.ts rename to src/daemon/gateway-heap.ts index 4e370f1a58c7..83881c20dbc4 100644 --- a/src/infra/node-heap.ts +++ b/src/daemon/gateway-heap.ts @@ -127,18 +127,6 @@ function parseMaxOldSpaceSizeMiB(nodeOptions: string | undefined): number | null return result; } -export function resolveNodeOptionsWithMinimumOldSpaceSize( - nodeOptions: string | undefined, - minimumMiB: number, -): string { - const current = nodeOptions?.trim() ?? ""; - const existingLimit = parseMaxOldSpaceSizeMiB(current); - if (existingLimit !== null && existingLimit >= minimumMiB) { - return current; - } - return [current, `--max-old-space-size=${minimumMiB}`].filter(Boolean).join(" "); -} - export function resolveGatewayHeapNodeOptions( existingNodeOptions: string | undefined, memory: GatewayHeapMemoryInputs = {}, diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 479efb094f74..6ab72de89cc0 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { resolveAutoNodeExtraCaCerts } from "../bootstrap/node-extra-ca-certs.js"; -import { inspectGatewayHeapLimit } from "../infra/node-heap.js"; +import { inspectGatewayHeapLimit } from "./gateway-heap.js"; import { resolveGatewayStateDir } from "./paths.js"; import { buildNodeServiceEnvironment, diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 05fc5f075091..45effaba6203 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -4,7 +4,6 @@ import os from "node:os"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { resolveNodeStartupTlsEnvironment } from "../bootstrap/node-startup-env.js"; -import { resolveGatewayHeapNodeOptions } from "../infra/node-heap.js"; import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER, @@ -13,6 +12,7 @@ import { resolveGatewayWindowsTaskName, resolveNodeServiceIdentityEnvironment, } from "./constants.js"; +import { resolveGatewayHeapNodeOptions } from "./gateway-heap.js"; import { resolveGatewayStateDir } from "./paths.js"; type MinimalServicePathOptions = { diff --git a/src/entry.respawn.test.ts b/src/entry.respawn.test.ts index a11df58a5b40..143df91103b3 100644 --- a/src/entry.respawn.test.ts +++ b/src/entry.respawn.test.ts @@ -163,10 +163,13 @@ describe("buildCliRespawnPlan", () => { ).toBeNull(); }); - it("does not overwrite an existing NODE_EXTRA_CA_CERTS value", () => { + it("preserves startup env while applying the update heap budget", () => { const plan = buildCliRespawnPlan({ - argv: ["node", "openclaw", "status"], - env: { NODE_EXTRA_CA_CERTS: "/custom/ca.pem" }, + argv: ["node", "openclaw", "update", "repair"], + env: { + NODE_EXTRA_CA_CERTS: "/custom/ca.pem", + NODE_OPTIONS: "--trace-warnings --max-old-space-size=1024", + }, execArgv: [], autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt", platform: "linux", @@ -174,6 +177,7 @@ describe("buildCliRespawnPlan", () => { const respawnPlan = expectCliRespawnPlan(plan); expect(respawnPlan.env.NODE_EXTRA_CA_CERTS).toBe("/custom/ca.pem"); + expect(respawnPlan.env.NODE_OPTIONS).toBe("--trace-warnings --max-old-space-size=8192"); }); it("returns null when both respawn guards are already satisfied", () => { diff --git a/src/entry.respawn.ts b/src/entry.respawn.ts index a55d3df7adaf..78d90b34fda0 100644 --- a/src/entry.respawn.ts +++ b/src/entry.respawn.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import path from "node:path"; import { resolveNodeStartupTlsEnvironment } from "./bootstrap/node-startup-env.js"; +import { resolveCliArgvInvocation } from "./cli/argv-invocation.js"; import { isTerminalInteractiveRespawnArgv, shouldSkipRespawnForArgv, @@ -9,6 +10,7 @@ import { } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { isTruthyEnvValue } from "./infra/env.js"; +import { resolveUpdateNodeOptions } from "./infra/update-node-options.js"; import { attachChildProcessBridge } from "./process/child-process-bridge.js"; import { runRespawnChildWithSignalBridge, @@ -101,6 +103,17 @@ export function buildCliRespawnPlan( const childExecArgv = [...execArgv]; let needsRespawn = false; + const invocation = resolveCliArgvInvocation(normalizedArgv); + if (invocation.primary === "update" && invocation.commandPath[1] !== "status") { + // Update Doctor may inspect large recovered state before spawning another process. + // Apply the existing build heap budget at startup, while V8 can still honor it. + const nodeOptions = resolveUpdateNodeOptions(childEnv.NODE_OPTIONS); + if (nodeOptions !== childEnv.NODE_OPTIONS) { + childEnv.NODE_OPTIONS = nodeOptions; + needsRespawn = true; + } + } + if (platform === "win32") { if (!hasStackSizeConfigured(childExecArgv)) { childExecArgv.unshift(WINDOWS_STACK_SIZE_FLAG); diff --git a/src/infra/update-node-options.ts b/src/infra/update-node-options.ts new file mode 100644 index 000000000000..4e72d9523790 --- /dev/null +++ b/src/infra/update-node-options.ts @@ -0,0 +1,16 @@ +// Shared Node heap policy for update builds and updater-owned CLI work. +const UPDATE_MAX_OLD_SPACE_MB = 8192; + +export function resolveUpdateNodeOptions(baseOptions: string | undefined): string { + const current = baseOptions?.trim() ?? ""; + const desired = `--max-old-space-size=${UPDATE_MAX_OLD_SPACE_MB}`; + const existingMatch = /(?:^|\s)--max-old-space-size=(\d+)(?=\s|$)/.exec(current); + if (!existingMatch) { + return current ? `${current} ${desired}` : desired; + } + const existingValue = Number(existingMatch[1]); + if (Number.isFinite(existingValue) && existingValue >= UPDATE_MAX_OLD_SPACE_MB) { + return current; + } + return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim(); +} diff --git a/src/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index a494c68d71f8..59a0f7d4c08c 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -120,7 +120,6 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { spawnFinalize, env: { PATH: "/usr/bin", - NODE_OPTIONS: "--trace-warnings --max-old-space-size=1024", OPENCLAW_SERVICE_MARKER: "openclaw", OPENCLAW_SERVICE_KIND: "gateway", OPENCLAW_GATEWAY_SERVICE_PID: "4242", @@ -131,9 +130,6 @@ describe("runPostCoreFinalizeAfterGatewayUpdate", () => { "spawnFinalize.mock.calls[0] test invariant", )[0]; expect(env.PATH).toBe("/usr/bin"); - expect(env.NODE_OPTIONS).toBe( - "--trace-warnings --max-old-space-size=1024 --max-old-space-size=8192", - ); expect(env.OPENCLAW_SERVICE_MARKER).toBeUndefined(); expect(env.OPENCLAW_SERVICE_KIND).toBeUndefined(); expect(env.OPENCLAW_GATEWAY_SERVICE_PID).toBeUndefined(); diff --git a/src/infra/update-post-core-finalize.ts b/src/infra/update-post-core-finalize.ts index f13a93488ed7..93f2ac5d99bd 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -33,7 +33,6 @@ import { buildPostCoreHandoffEnv, type PreUpdateConfigRestoreInput, } from "./update-post-core-context.js"; -import { resolveUpdateNodeOptions } from "./update-runner-doctor.js"; import type { UpdateRunResult } from "./update-runner.js"; // Whole-process backstop for the finalizer. `update finalize` runs several timed @@ -64,7 +63,6 @@ function buildFinalizeEnv( compatHostVersion, sourceConfigPath, }); - env.NODE_OPTIONS = resolveUpdateNodeOptions(env.NODE_OPTIONS); delete env.OPENCLAW_SERVICE_MARKER; delete env.OPENCLAW_SERVICE_KIND; delete env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; diff --git a/src/infra/update-runner-doctor.ts b/src/infra/update-runner-doctor.ts index 8f518ef8c179..60bdb23b2ec7 100644 --- a/src/infra/update-runner-doctor.ts +++ b/src/infra/update-runner-doctor.ts @@ -1,4 +1,3 @@ -import { resolveNodeOptionsWithMinimumOldSpaceSize } from "./node-heap.js"; import { compareSemverStrings } from "./update-check.js"; const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV = @@ -13,13 +12,6 @@ const UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION_ENV = "OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION"; const UPDATE_DOCTOR_SERVICE_REPAIR_POLICY_ENV = "OPENCLAW_SERVICE_REPAIR_POLICY"; const EXTERNAL_SERVICE_REPAIR_POLICY_MIN_VERSION = "2026.4.25-beta.1"; -const UPDATE_MIN_OLD_SPACE_MIB = 8192; - -export function resolveUpdateNodeOptions(nodeOptions: string | undefined): string { - // Source builds and post-core Doctor/finalizer processes share one update budget. Keep larger - // operator limits, but never let a fresh handoff fall back to V8's much smaller default. - return resolveNodeOptionsWithMinimumOldSpaceSize(nodeOptions, UPDATE_MIN_OLD_SPACE_MIB); -} export function resolveUpdateDoctorExecutionPolicy(params: { targetVersion: string | null; @@ -45,10 +37,8 @@ export function buildUpdateDoctorEnv(params: { serviceRepairPolicy?: "external"; deferConfiguredPluginInstallRepair?: boolean; compatibilityHostVersion?: string | null; - nodeOptions?: string; }): NodeJS.ProcessEnv { return { - NODE_OPTIONS: resolveUpdateNodeOptions(params.nodeOptions ?? process.env.NODE_OPTIONS), OPENCLAW_UPDATE_IN_PROGRESS: "1", ...(params.deferConfiguredPluginInstallRepair ? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" } diff --git a/src/infra/update-runner-git-commands.ts b/src/infra/update-runner-git-commands.ts index 63acc0e3408d..15a6cb8bb7c6 100644 --- a/src/infra/update-runner-git-commands.ts +++ b/src/infra/update-runner-git-commands.ts @@ -1,9 +1,9 @@ import { DEV_BRANCH } from "./update-channels.js"; +import { resolveUpdateNodeOptions } from "./update-node-options.js"; import { managerInstallIgnoreScriptsArgs, type UpdatePackageManagerFailureReason, } from "./update-package-manager.js"; -import { resolveUpdateNodeOptions } from "./update-runner-doctor.js"; import type { UpdateRunResult, UpdateStepResult } from "./update-runner-types.js"; const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { @@ -28,16 +28,12 @@ export function shouldPreferIgnoreScriptsForWindowsPreflight( return process.platform === "win32" && manager === "pnpm"; } -function resolveBuildNodeOptions(baseOptions: string | undefined): string { - return resolveUpdateNodeOptions(baseOptions); -} - export function resolveBuildEnv( env?: NodeJS.ProcessEnv, buildCacheRoot?: string, ): NodeJS.ProcessEnv | undefined { const currentNodeOptions = env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS; - const nextNodeOptions = resolveBuildNodeOptions(currentNodeOptions); + const nextNodeOptions = resolveUpdateNodeOptions(currentNodeOptions); if (nextNodeOptions === currentNodeOptions && !buildCacheRoot) { return env; } diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index c27abacd89b9..283fea002a5d 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1366,7 +1366,6 @@ describe("runGatewayUpdate", () => { expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_ACTIVATION).toBe("1"); - expect(doctorEnv?.NODE_OPTIONS).toBe("--max-old-space-size=8192"); }); it("uses the pre-mutation activation decision for the git update doctor pass", async () => {