From 01e4cf06ddb41eae74ce5d08380029895b4eb2bc Mon Sep 17 00:00:00 2001 From: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:29:26 +0000 Subject: [PATCH] fix(update): provision heap for finalization doctor 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 | 85 ++++++++++--------- src/commands/doctor-gateway-services.ts | 2 +- src/daemon/service-env.test.ts | 2 +- src/daemon/service-env.ts | 2 +- .../node-heap.test.ts} | 20 ++++- .../gateway-heap.ts => infra/node-heap.ts} | 12 +++ 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 | 14 +-- src/infra/update-runner.test.ts | 1 + 17 files changed, 224 insertions(+), 155 deletions(-) create mode 100644 src/cli/update-cli/update-command-fresh-doctor.test.ts rename src/{daemon/gateway-heap.test.ts => infra/node-heap.test.ts} (84%) rename src/{daemon/gateway-heap.ts => infra/node-heap.ts} (92%) diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index fe9a9bd0395c..10f663463048 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -20,7 +20,6 @@ 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"; @@ -43,6 +42,7 @@ 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 063080942bed..152da6fb04ff 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -5,7 +5,6 @@ import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, } from "../../daemon/constants.js"; -import { formatGatewayHeapLimitReport } from "../../daemon/gateway-heap.js"; import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js"; import { resolveGatewayRestartLogPath, @@ -18,6 +17,7 @@ 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 8882b28c6ef2..331e1acfb0ab 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -104,11 +104,15 @@ const execFile = vi.fn((...args: unknown[]) => { }); const spawn = vi.fn(); const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); -const serviceEnvSnapshot = captureEnv([ +const inheritedServiceEnvKeys = [ "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, @@ -997,6 +1001,14 @@ 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); @@ -1343,9 +1355,9 @@ describe("update-cli", () => { }; beforeEach(() => { - delete process.env.OPENCLAW_SERVICE_MARKER; - delete process.env.OPENCLAW_SERVICE_KIND; - delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]; + for (const key of inheritedServiceEnvKeys) { + delete process.env[key]; + } restartHealthTestControl.snapshot = undefined; vi.clearAllMocks(); serviceEnabled.mockResolvedValue(true); @@ -7536,7 +7548,8 @@ describe("update-cli", () => { }); }); - it("updateFinalizeCommand defers plugin installation during pre-plugin doctor", async () => { + it("updateFinalizeCommand defers plugin installation during fresh pre-plugin doctor", async () => { + mockFreshDoctorEntrypoints(); await withEnvAsync( { OPENCLAW_UPDATE_IN_PROGRESS: undefined, @@ -7545,10 +7558,6 @@ 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({ @@ -7559,19 +7568,30 @@ describe("update-cli", () => { acknowledgeClawHubRisk: true, }); - 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"); + 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_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).toHaveBeenCalledWith(defaultRuntime, { - nonInteractive: true, - repair: true, - yes: true, - }); + expect(doctorCommand).not.toHaveBeenCalled(); expect(syncPluginCall()?.channel).toBe("stable"); expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); expect(lastNpmPluginUpdateCall()?.timeoutMs).toBe(9_000); @@ -7615,7 +7635,7 @@ describe("update-cli", () => { }); it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => { - vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce("/tmp/openclaw-entry.mjs"); + mockFreshDoctorEntrypoints(2); const preDoctorConfig = { update: { channel: "stable" }, plugins: { entries: { pre: { enabled: true } } }, @@ -7650,31 +7670,29 @@ describe("update-cli", () => { await updateFinalizeCommand({ json: true, timeout: "9", restart: false }); - expect(doctorCommand).toHaveBeenCalledWith(defaultRuntime, { - nonInteractive: true, - repair: true, - yes: false, - }); - expect(doctorCommand).toHaveBeenCalledTimes(1); - const freshDoctorCall = vi + expect(doctorCommand).not.toHaveBeenCalled(); + const freshDoctorCalls = vi .mocked(runExec) - .mock.calls.find(([, args]) => args.includes("doctor")); - expect(freshDoctorCall?.[1]).toEqual([ - "/tmp/openclaw-entry.mjs", + .mock.calls.filter(([, args]) => args.includes("doctor")); + expect(freshDoctorCalls).toHaveLength(2); + expect(freshDoctorCalls[0]?.[1]).toEqual([ + FRESH_POST_UPDATE_ENTRYPOINT, "doctor", "--repair", "--non-interactive", "--no-workspace-suggestions", ]); - expect(freshDoctorCall?.[2]).toMatchObject({ + expect(freshDoctorCalls[0]?.[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, @@ -7684,13 +7702,14 @@ describe("update-cli", () => { }, }); expect(lastReplaceConfigCall()?.baseHash).toBe("post-doctor"); - expect(vi.mocked(doctorCommand).mock.invocationCallOrder[0] ?? 0).toBeLessThan( + expect(vi.mocked(runExec).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 = { @@ -7737,6 +7756,7 @@ 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, { @@ -7764,6 +7784,7 @@ 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 new file mode 100644 index 000000000000..d106d7cf4e6b --- /dev/null +++ b/src/cli/update-cli/update-command-fresh-doctor.test.ts @@ -0,0 +1,51 @@ +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 60c22abc0b84..1bcb2fece3f7 100644 --- a/src/cli/update-cli/update-command-fresh-doctor.ts +++ b/src/cli/update-cli/update-command-fresh-doctor.ts @@ -7,6 +7,7 @@ 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"; @@ -22,43 +23,10 @@ import { type UpdateDoctorPhase = "pre-plugin" | "post-plugin"; -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; - } - } +function resolveUpdateFinalizationBaseEnv(): NodeJS.ProcessEnv { + const env = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)); + env.NODE_OPTIONS = resolveUpdateNodeOptions(env.NODE_OPTIONS); + return env; } async function withNormalConfigValidation(run: () => Promise): Promise { @@ -115,7 +83,7 @@ export async function runUpdateFinalizationDoctorInFreshProcess(params: { "--no-workspace-suggestions", ...(params.yes ? ["--yes"] : []), ]; - const baseEnv = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)); + const baseEnv = resolveUpdateFinalizationBaseEnv(); delete baseEnv[UPDATE_POST_CORE_CONVERGENCE_ENV]; const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, { cwd: params.root, @@ -155,7 +123,7 @@ async function validatePostPluginConfigInFreshProcess(params: { timeoutMs: params.timeoutMs, maxBuffer: 4 * 1024 * 1024, logOutput: false, - baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), + baseEnv: resolveUpdateFinalizationBaseEnv(), 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 35f54fe8a342..e68c63d89574 100644 --- a/src/cli/update-cli/update-command-package.ts +++ b/src/cli/update-cli/update-command-package.ts @@ -1,12 +1,5 @@ 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 { @@ -24,6 +17,7 @@ import { resolveGlobalInstallTarget, type ResolvedGlobalInstallTarget, } from "../../infra/update-global.js"; +import { buildUpdateDoctorEnv } from "../../infra/update-runner-doctor.js"; import { resolveUpdateDoctorExecutionPolicy, type UpdateRunResult, @@ -145,30 +139,27 @@ 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: { - ...resolvePostInstallDoctorEnv({ - serviceEnv: params.managedServiceEnv, - invocationCwd: params.invocationCwd, + ...doctorBaseEnv, + ...buildUpdateDoctorEnv({ + allowGatewayServiceRepair: params.allowGatewayServiceRepair, + allowGatewayActivation: params.allowGatewayActivation, + ...(doctorPolicy.serviceRepairPolicy + ? { serviceRepairPolicy: doctorPolicy.serviceRepairPolicy } + : {}), + deferConfiguredPluginInstallRepair: true, + compatibilityHostVersion: candidateHostVersion, + ...(doctorBaseEnv.NODE_OPTIONS ? { nodeOptions: doctorBaseEnv.NODE_OPTIONS } : {}), }), - 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 9addbd454eba..6f703b80c8aa 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -7,7 +7,6 @@ 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, @@ -45,6 +44,7 @@ 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, - withPrePluginUpdateDoctorEnv, + runUpdateFinalizationDoctorInFreshProcess, } from "./update-command-fresh-doctor.js"; import { updatePluginsAfterCoreUpdate, @@ -220,48 +220,48 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis } const completedPluginUpdate = await withPluginLifecycleLease({}, async () => { - 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, + 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, - 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, + 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(); + 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,6 +579,7 @@ 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 945c032e31d0..e68fc260a51b 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -10,7 +10,6 @@ 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, @@ -41,6 +40,7 @@ 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/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 6ab72de89cc0..479efb094f74 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 "./gateway-heap.js"; +import { inspectGatewayHeapLimit } from "../infra/node-heap.js"; import { resolveGatewayStateDir } from "./paths.js"; import { buildNodeServiceEnvironment, diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 45effaba6203..05fc5f075091 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -4,6 +4,7 @@ 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, @@ -12,7 +13,6 @@ import { resolveGatewayWindowsTaskName, resolveNodeServiceIdentityEnvironment, } from "./constants.js"; -import { resolveGatewayHeapNodeOptions } from "./gateway-heap.js"; import { resolveGatewayStateDir } from "./paths.js"; type MinimalServicePathOptions = { diff --git a/src/daemon/gateway-heap.test.ts b/src/infra/node-heap.test.ts similarity index 84% rename from src/daemon/gateway-heap.test.ts rename to src/infra/node-heap.test.ts index ad91fc526459..82a95d5a806f 100644 --- a/src/daemon/gateway-heap.test.ts +++ b/src/infra/node-heap.test.ts @@ -4,7 +4,8 @@ import { formatGatewayHeapLimitReport, inspectGatewayHeapLimit, resolveGatewayHeapNodeOptions, -} from "./gateway-heap.js"; + resolveNodeOptionsWithMinimumOldSpaceSize, +} from "./node-heap.js"; const MIB = 1024 * 1024; @@ -114,3 +115,20 @@ 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/daemon/gateway-heap.ts b/src/infra/node-heap.ts similarity index 92% rename from src/daemon/gateway-heap.ts rename to src/infra/node-heap.ts index 83881c20dbc4..4e370f1a58c7 100644 --- a/src/daemon/gateway-heap.ts +++ b/src/infra/node-heap.ts @@ -127,6 +127,18 @@ 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/infra/update-post-core-finalize.test.ts b/src/infra/update-post-core-finalize.test.ts index 59a0f7d4c08c..a494c68d71f8 100644 --- a/src/infra/update-post-core-finalize.test.ts +++ b/src/infra/update-post-core-finalize.test.ts @@ -120,6 +120,7 @@ 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", @@ -130,6 +131,9 @@ 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 93f2ac5d99bd..f13a93488ed7 100644 --- a/src/infra/update-post-core-finalize.ts +++ b/src/infra/update-post-core-finalize.ts @@ -33,6 +33,7 @@ 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 @@ -63,6 +64,7 @@ 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 60bdb23b2ec7..8f518ef8c179 100644 --- a/src/infra/update-runner-doctor.ts +++ b/src/infra/update-runner-doctor.ts @@ -1,3 +1,4 @@ +import { resolveNodeOptionsWithMinimumOldSpaceSize } from "./node-heap.js"; import { compareSemverStrings } from "./update-check.js"; const UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV = @@ -12,6 +13,13 @@ 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; @@ -37,8 +45,10 @@ 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 c3cb9d3df98a..63acc0e3408d 100644 --- a/src/infra/update-runner-git-commands.ts +++ b/src/infra/update-runner-git-commands.ts @@ -3,9 +3,9 @@ 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 BUILD_MAX_OLD_SPACE_MB = 8192; const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { OPENCLAW_LOCAL_CHECK: "1", OPENCLAW_LOCAL_CHECK_MODE: "throttled", @@ -29,17 +29,7 @@ export function shouldPreferIgnoreScriptsForWindowsPreflight( } function resolveBuildNodeOptions(baseOptions: string | undefined): string { - const current = baseOptions?.trim() ?? ""; - const desired = `--max-old-space-size=${BUILD_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 >= BUILD_MAX_OLD_SPACE_MB) { - return current; - } - return current.replace(/(?:^|\s)--max-old-space-size=\d+(?=\s|$)/, ` ${desired}`).trim(); + return resolveUpdateNodeOptions(baseOptions); } export function resolveBuildEnv( diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 283fea002a5d..c27abacd89b9 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1366,6 +1366,7 @@ 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 () => {