refactor(update): simplify finalization heap fix

Co-authored-by: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-08-19 03:37:18 +00:00
parent 01e4cf06dd
commit 7264f5d9fd
20 changed files with 182 additions and 232 deletions
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
+32 -53
View File
@@ -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,
@@ -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");
});
});
@@ -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<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;
}
}
}
async function withNormalConfigValidation<T>(run: () => Promise<T>): Promise<T> {
@@ -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" },
},
);
+24 -15
View File
@@ -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,
});
+43 -44
View File
@@ -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: {
+1 -1
View File
@@ -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";
@@ -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");
});
});
@@ -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 = {},
+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 "../infra/node-heap.js";
import { inspectGatewayHeapLimit } from "./gateway-heap.js";
import { resolveGatewayStateDir } from "./paths.js";
import {
buildNodeServiceEnvironment,
+1 -1
View File
@@ -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 = {
+7 -3
View File
@@ -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", () => {
+13
View File
@@ -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);
+16
View File
@@ -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();
}
@@ -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();
-2
View File
@@ -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];
-10
View File
@@ -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" }
+2 -6
View File
@@ -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;
}
-1
View File
@@ -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 () => {