fix(update): provision heap for finalization doctor

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-19 02:29:26 +00:00
parent 23da04ba8a
commit 01e4cf06dd
17 changed files with 224 additions and 155 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+53 -32
View File
@@ -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,
@@ -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");
});
});
@@ -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<T>(run: () => Promise<T>): Promise<T> {
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<T>(run: () => Promise<T>): Promise<T> {
@@ -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" },
},
);
+15 -24
View File
@@ -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,
});
+43 -42
View File
@@ -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: {
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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 = {
@@ -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");
});
});
@@ -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 = {},
@@ -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();
+2
View File
@@ -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];
+10
View File
@@ -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" }
+2 -12
View File
@@ -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(
+1
View File
@@ -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 () => {