diff --git a/node-runtime-recovery.mjs b/node-runtime-recovery.mjs index fd9a037dfca7..27d9a27889cc 100644 --- a/node-runtime-recovery.mjs +++ b/node-runtime-recovery.mjs @@ -657,7 +657,7 @@ export async function recoverNodeRuntime({ !process.argv[1] || isForegroundGmailRunInvocation(process.argv) || (process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) || - !nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()) + !nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities()) ) { return false; } diff --git a/node-sqlite.d.mts b/node-sqlite.d.mts index e59a4e6c7732..dd82b72dcd97 100644 --- a/node-sqlite.d.mts +++ b/node-sqlite.d.mts @@ -6,7 +6,7 @@ export type SqliteCapabilities = { json: boolean; error?: string; }; -export function detectCurrentSqliteCapabilities(): SqliteCapabilities; +export function detectCurrentSqliteCapabilities(): Promise; export function isSqliteWalResetSafeVersion(value: string): boolean; export function nodeRuntimeFailure( version: string | null, diff --git a/node-sqlite.mjs b/node-sqlite.mjs index be9a58963cb2..d225894fe51b 100644 --- a/node-sqlite.mjs +++ b/node-sqlite.mjs @@ -32,24 +32,102 @@ function probeSqlite(DatabaseSync) { // The launcher and bundled runtime chunks must share one process-local result. const capabilityCacheKey = Symbol.for("openclaw.sqliteCapabilities"); -export function detectCurrentSqliteCapabilities() { - let cachedProbe = globalThis[capabilityCacheKey]; - if (!cachedProbe) { - try { - cachedProbe = probeSqlite(process.getBuiltinModule?.("node:sqlite")?.DatabaseSync); - } catch (error) { - cachedProbe = { - available: false, - version: null, - text: false, - blob: false, - json: false, - error: error instanceof Error ? error.message : String(error), - }; +function unavailableSqliteCapabilities(error) { + return { + available: false, + version: null, + text: false, + blob: false, + json: false, + error: error instanceof Error ? error.message : String(error), + }; +} + +function isSqliteCapabilities(value) { + return ( + value !== null && + typeof value === "object" && + typeof value.available === "boolean" && + (value.version === null || typeof value.version === "string") && + typeof value.text === "boolean" && + typeof value.blob === "boolean" && + typeof value.json === "boolean" && + (value.error === undefined || typeof value.error === "string") + ); +} + +async function probeCurrentSqliteInWorker() { + let worker; + try { + if (typeof process.getBuiltinModule?.("node:sqlite")?.DatabaseSync !== "function") { + return unavailableSqliteCapabilities(new Error("node:sqlite is unavailable")); } - globalThis[capabilityCacheKey] = cachedProbe; + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/^(NODE_OPTIONS|BUN_OPTIONS)$/i.test(name)), + ); + env.NODE_NO_WARNINGS = "1"; + // Older diagnostic runtimes need SQLite enabled, but must not replay entry preloads. + const execArgv = + !process.versions.bun && process.allowedNodeEnvironmentFlags?.has("--experimental-sqlite") + ? ["--experimental-sqlite"] + : []; + const { Worker } = await import("node:worker_threads"); + worker = new Worker( + `const { parentPort } = require("node:worker_threads"); + parentPort.postMessage(${SQLITE_CAPABILITY_PROBE}); + parentPort.close();`, + { eval: true, env, execArgv }, + ); + } catch (error) { + return unavailableSqliteCapabilities(error); } - return cachedProbe; + return new Promise((resolve) => { + let result; + let failure; + let retirement; + const fail = (error) => { + failure ??= { error }; + retirement ??= Promise.resolve() + .then(() => worker.terminate()) + .catch((error) => { + failure ??= { error }; + }); + }; + const onMessage = (value) => { + if (result !== undefined || !isSqliteCapabilities(value)) { + fail(new Error("SQLite capability worker returned an invalid result")); + return; + } + result = value; + }; + const onExit = (code) => { + worker.off("message", onMessage); + worker.off("error", fail); + worker.off("messageerror", fail); + if (failure) { + resolve(unavailableSqliteCapabilities(failure.error)); + } else if (code !== 0 || result === undefined) { + resolve( + unavailableSqliteCapabilities( + new Error(`SQLite capability worker exited without a complete result (code ${code})`), + ), + ); + } else { + // The scratch database and worker must both close before startup is admitted. + resolve(result); + } + }; + worker.on("message", onMessage); + worker.on("error", fail); + worker.on("messageerror", fail); + worker.once("exit", onExit); + }); +} + +export function detectCurrentSqliteCapabilities() { + // Publish the Promise before Worker construction can notify another startup caller. + globalThis[capabilityCacheKey] ??= Promise.resolve().then(probeCurrentSqliteInWorker); + return Promise.resolve(globalThis[capabilityCacheKey]); } export function isSqliteWalResetSafeVersion(value) { diff --git a/openclaw.mjs b/openclaw.mjs index cb44b2eda1f2..5efc664711b3 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -49,7 +49,7 @@ const ensureSupportedRuntimeVersion = async () => { ); return process.exit(1); } - const probe = detectCurrentSqliteCapabilities(); + const probe = await detectCurrentSqliteCapabilities(); const failure = nodeRuntimeFailure(process.versions.node, probe); if (!failure) { const note = nodeRuntimeNote(process.versions.node, probe); @@ -626,7 +626,7 @@ const tryOutputPrecomputedCommandHelp = () => { const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion(); const currentNodeRuntimeFailure = process.versions.bun ? null - : nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()); + : nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities()); if (!waitingForNodeUpdateRespawn) { // Diagnostics must not replay package lifecycle scripts under an unsupported Node. diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index bdc5fe1e0501..d72297be6653 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -113,8 +113,9 @@ describe("registerMaintenanceCommands doctor action", () => { it.each(["22.23.2", "26.0.0"])("keeps plain doctor read-only on Node %s", async (node) => { vi.stubGlobal("process", { ...process, versions: { ...process.versions, node } }); - vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({ - ...nodeSqlite.detectCurrentSqliteCapabilities(), + const capabilities = await nodeSqlite.detectCurrentSqliteCapabilities(); + vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockResolvedValue({ + ...capabilities, text: false, }); runDoctorLintCli.mockResolvedValue(1); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 12b7dcb49b51..358a3f4755ff 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -135,7 +135,7 @@ export function registerMaintenanceCommands(program: Command) { typeof opts.sessionSqlite !== "string"; const unsupportedNode = !process.versions.bun && - Boolean(nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities())); + Boolean(nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities())); const lintMode = opts.lint === true || unsupportedNode ? "--lint" : jsonImpliesLint ? "--json" : undefined; const mutationOption = diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index b19da6acb97b..d9434e83c8cb 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -58,6 +58,7 @@ const pinConfigDirMock = vi.hoisted(() => vi.fn()); const pinRuntimePathsMock = vi.hoisted(() => vi.fn()); const ensurePathMock = vi.hoisted(() => vi.fn()); const assertRuntimeMock = vi.hoisted(() => vi.fn(async () => {})); +const isCurrentRuntimeSupportedMock = vi.hoisted(() => vi.fn(async () => true)); const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {})); const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false)); const listRegisteredAgentHarnessesMock = vi.hoisted(() => vi.fn((): unknown[] => [])); @@ -322,6 +323,7 @@ vi.mock("../infra/path-env.js", () => ({ vi.mock("../infra/runtime-guard.js", async (importOriginal) => ({ ...(await importOriginal()), assertSupportedRuntime: assertRuntimeMock, + isCurrentRuntimeSupported: isCurrentRuntimeSupportedMock, })); vi.mock("../plugins/memory-runtime.js", () => ({ @@ -2373,13 +2375,28 @@ describe("runCli exit behavior", () => { expect(shouldStartProxyForCli(argv)).toBe(false); }); - it("starts the managed proxy for network-capable commands by default", async () => { - tryRouteCliMock.mockResolvedValueOnce(true); + it.each([true, false])( + "selects proxy config after async runtime support resolves to %s", + async (supported) => { + tryRouteCliMock.mockResolvedValueOnce(true); + isCurrentRuntimeSupportedMock.mockResolvedValueOnce(supported); + if (supported) { + loadConfigMock.mockReturnValueOnce({ proxy: { proxyUrl: "http://validated.invalid" } }); + } else { + readSourceConfigBestEffortMock.mockResolvedValueOnce({ + proxy: { proxyUrl: "http://source.invalid" }, + }); + } - await runCli(["node", "openclaw", "plugins", "marketplace", "list"]); + await runCli(["node", "openclaw", "plugins", "marketplace", "list"]); - expect(startProxyMock).toHaveBeenCalledWith(undefined); - }); + expect(readSourceConfigBestEffortMock).toHaveBeenCalledTimes(supported ? 0 : 1); + expect(loadConfigMock).toHaveBeenCalledTimes(supported ? 1 : 0); + expect(startProxyMock).toHaveBeenCalledWith({ + proxyUrl: supported ? "http://validated.invalid" : "http://source.invalid", + }); + }, + ); it.each([ ["worker", { observe: false, pluginValidation: "core-only" }], diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index fec07dad82c3..ef7153c83406 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1208,7 +1208,7 @@ async function runCliWithPreparedOutputMode( env: process.env, }); const useSourceOnlyBestEffortConfig = - !isCurrentRuntimeSupported() || + !(await isCurrentRuntimeSupported()) || normalizedInvocation.primary === "update" || (normalizedInvocation.primary === "doctor" && hasFlag(normalizedArgv, "--lint")); const readBestEffortCliConfig = async (): Promise => { diff --git a/src/cli/update-cli/status.test.ts b/src/cli/update-cli/status.test.ts index abaaf356da77..30acc12034b2 100644 --- a/src/cli/update-cli/status.test.ts +++ b/src/cli/update-cli/status.test.ts @@ -104,7 +104,7 @@ describe("update status Node runtime findings", () => { ); }); const freshGuard = await import("../../infra/runtime-guard.js"); - vi.spyOn(freshGuard, "detectRuntime").mockReturnValue({ + vi.spyOn(freshGuard, "detectRuntime").mockResolvedValue({ kind: "node", version: process.versions.node, execPath: "/fixture/node", @@ -164,7 +164,7 @@ describe("update status Node runtime findings", () => { "renders admitted %s runtime information without a missing hint", async (source) => { if (source === "cli") { - vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ + vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({ kind: "node", version: "24.15.0", execPath: "/fixture/node", @@ -200,7 +200,7 @@ describe("update status Node runtime findings", () => { versions: { ...process.versions, node: source === "cli" ? version : "26.8.1" }, }); if (source === "cli") { - vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ + vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({ kind: "node", version, execPath: "/fixture/node", diff --git a/src/cli/update-cli/update-command-run.ts b/src/cli/update-cli/update-command-run.ts index f5a0a378b80b..815a4a49b96c 100644 --- a/src/cli/update-cli/update-command-run.ts +++ b/src/cli/update-cli/update-command-run.ts @@ -489,7 +489,7 @@ export async function prepareUpdateCommand(opts: UpdateCommandOptions) { // Refuse before preflight can inspect write ownership or admit a live run ledger. const runtimeFailure = process.versions.bun ? null - : nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()); + : nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities()); if (runtimeFailure) { const error = `${runtimeFailure}\n${formatUnsupportedNodeVersionMessage(process.versions.node)}`; if (opts.json) { diff --git a/src/cli/update-cli/update-command-runtime.test.ts b/src/cli/update-cli/update-command-runtime.test.ts index e14fb14cec14..cabe9c67ce5d 100644 --- a/src/cli/update-cli/update-command-runtime.test.ts +++ b/src/cli/update-cli/update-command-runtime.test.ts @@ -34,8 +34,9 @@ describe("unsupported CLI Node update admission", () => { it.each(["22.23.2", "26.0.0"])("refuses Node %s before stateful preparation", async (node) => { vi.stubGlobal("process", { ...process, versions: { ...process.versions, node } }); - vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({ - ...nodeSqlite.detectCurrentSqliteCapabilities(), + const capabilities = await nodeSqlite.detectCurrentSqliteCapabilities(); + vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockResolvedValue({ + ...capabilities, text: false, }); await expect(updateCommand({ json: true })).rejects.toEqual(new ExitError(1)); diff --git a/src/cli/update-cli/update-command-service-plan.test.ts b/src/cli/update-cli/update-command-service-plan.test.ts index e2e41ebc5aae..1f931363368d 100644 --- a/src/cli/update-cli/update-command-service-plan.test.ts +++ b/src/cli/update-cli/update-command-service-plan.test.ts @@ -12,7 +12,7 @@ vi.mock("../../../node-sqlite.mjs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - detectCurrentSqliteCapabilities: () => ({ + detectCurrentSqliteCapabilities: async () => ({ available: true, version: "3.51.3", text: probeState.text, diff --git a/src/cli/update-cli/update-command-service-plan.ts b/src/cli/update-cli/update-command-service-plan.ts index 2dd3390b2702..d3683920fc3f 100644 --- a/src/cli/update-cli/update-command-service-plan.ts +++ b/src/cli/update-cli/update-command-service-plan.ts @@ -233,7 +233,10 @@ async function resolvePackageRuntimeForPreflight(params: { const nodeRunner = normalizeOptionalString(params.nodeRunner); if (!nodeRunner) { const version = process.versions.node ?? null; - return { version, failure: nodeRuntimeFailure(version, detectCurrentSqliteCapabilities()) }; + return { + version, + failure: nodeRuntimeFailure(version, await detectCurrentSqliteCapabilities()), + }; } const runtime = await resolveNodeRuntimeInfo( nodeRunner, diff --git a/src/commands/node-runtime-diagnostics.test.ts b/src/commands/node-runtime-diagnostics.test.ts index 17bdc9cebda6..5e6d74fc9525 100644 --- a/src/commands/node-runtime-diagnostics.test.ts +++ b/src/commands/node-runtime-diagnostics.test.ts @@ -35,7 +35,7 @@ vi.mock("./status-json-command.ts", () => ({ })); function mockCliRuntime(version: string, text = true) { - vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ + vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({ kind: "node", version, execPath: "/fixture/node", diff --git a/src/commands/node-runtime-diagnostics.ts b/src/commands/node-runtime-diagnostics.ts index f26692b42d71..847f04593e35 100644 --- a/src/commands/node-runtime-diagnostics.ts +++ b/src/commands/node-runtime-diagnostics.ts @@ -37,8 +37,8 @@ function unsupportedNodeFinding( }; } -function collectCurrentNodeRuntimeFindings(): readonly HealthFinding[] { - const runtime = detectRuntime(); +async function collectCurrentNodeRuntimeFindings(): Promise { + const runtime = await detectRuntime(); if (runtime.kind !== "node" || !runtime.sqliteProbe) { return []; } @@ -64,7 +64,7 @@ export async function collectNodeRuntimeFindings( env: NodeJS.ProcessEnv = process.env, ): Promise { return [ - ...collectCurrentNodeRuntimeFindings(), + ...(await collectCurrentNodeRuntimeFindings()), ...(await collectServiceNodeRuntimeFindings(env)), ]; } diff --git a/src/entry.respawn-diagnostics.test.ts b/src/entry.respawn-diagnostics.test.ts index 6b57538215b5..e949ca5f6805 100644 --- a/src/entry.respawn-diagnostics.test.ts +++ b/src/entry.respawn-diagnostics.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; const boundary = vi.hoisted(() => ({ mode: "flags" as "flags" | "compile-cache", trace: false, + runtimeSupported: true, events: [] as string[], writer: undefined as ((message: string, error?: unknown) => void | Promise) | undefined, })); @@ -10,6 +11,10 @@ const boundary = vi.hoisted(() => ({ vi.mock("./infra/is-main.js", () => ({ isMainModule: () => true })); vi.mock("./infra/openclaw-exec-env.js", () => ({ ensureOpenClawExecMarkerOnProcess: vi.fn() })); vi.mock("./infra/warning-filter.js", () => ({ installProcessWarningFilter: vi.fn() })); +vi.mock("./infra/runtime-guard.js", () => ({ + isCurrentRuntimeSupported: async () => boundary.runtimeSupported, + assertSupportedRuntime: async () => {}, +})); vi.mock("./cli/dotenv.js", () => ({ loadCliDotEnv: () => boundary.events.push("dotenv"), })); @@ -47,6 +52,7 @@ const originalTitle = process.title; beforeEach(() => { vi.resetModules(); boundary.events = []; + boundary.runtimeSupported = true; boundary.writer = undefined; process.argv = [ process.execPath, @@ -62,6 +68,21 @@ afterEach(() => { vi.restoreAllMocks(); }); +it.each([true, false])( + "prepares early runtime diagnostics after async support resolves to %s", + async (supported) => { + boundary.mode = "compile-cache"; + boundary.trace = false; + boundary.runtimeSupported = supported; + + await import("./entry.js"); + + expect(boundary.events).toEqual( + supported ? ["spawn"] : ["dotenv", "trace formatting", "spawn"], + ); + }, +); + it.each([ { mode: "flags", trace: false }, { mode: "flags", trace: true }, diff --git a/src/entry.ts b/src/entry.ts index 4f5088fa0864..584a2fa74eed 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -135,7 +135,7 @@ if ( const startupEnv = { ...process.env }; const { assertSupportedRuntime, isCurrentRuntimeSupported } = await import("./infra/runtime-guard.js"); - if (!isCurrentRuntimeSupported()) { + if (!(await isCurrentRuntimeSupported())) { const { loadCliDotEnv } = await import("./cli/dotenv.js"); loadCliDotEnv({ quiet: true }); await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace); diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index a013b4c226b7..7781a19cb42f 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -1032,7 +1032,7 @@ describe("doctor gateway runtime checks", () => { ])( "reports current Node $version probe outcome as $severity", async ({ version, text, severity, message }) => { - mocks.detectRuntime.mockReturnValue({ + mocks.detectRuntime.mockResolvedValue({ kind: "node", version, execPath: "/opt/runtime/bin/node", diff --git a/src/infra/node-runtime-recovery.test.ts b/src/infra/node-runtime-recovery.test.ts index 16a50b7bbe22..aa6962f0e80d 100644 --- a/src/infra/node-runtime-recovery.test.ts +++ b/src/infra/node-runtime-recovery.test.ts @@ -48,7 +48,7 @@ vi.mock("node:fs", async (importOriginal) => { }); vi.mock("../../node-sqlite.mjs", async (importOriginal) => ({ ...(await importOriginal()), - detectCurrentSqliteCapabilities: () => ({ + detectCurrentSqliteCapabilities: async () => ({ available: true, version: "3.51.3", text: mocks.currentAdmitted, diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index f58ba8c4d640..003971cee6d9 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -20,8 +20,8 @@ vi.mock("../../node-sqlite.mjs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - detectCurrentSqliteCapabilities: () => ({ - ...actual.detectCurrentSqliteCapabilities(), + detectCurrentSqliteCapabilities: async () => ({ + ...(await actual.detectCurrentSqliteCapabilities()), text: state.lossless, }), }; diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index f09093d5b0dc..c4f94c7a2ccf 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -80,17 +80,19 @@ function isAtLeast(version: Semver | null, minimum: Semver): boolean { } /** Reads current process runtime metadata for startup support checks. */ -export function detectRuntime(): RuntimeDetails { +export async function detectRuntime(): Promise { const bunVersion = process.versions?.bun; const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown"; const version = bunVersion ?? process.versions?.node ?? null; - const sqlite = detectCurrentRuntimeSqlite(); + const execPath = process.execPath ?? null; + const pathEnv = process.env.PATH ?? "(not set)"; + const sqlite = await detectCurrentRuntimeSqlite(); return { kind, version, - execPath: process.execPath ?? null, - pathEnv: process.env.PATH ?? "(not set)", + execPath, + pathEnv, hasNodeSqlite: sqlite.available, sqliteVersion: sqlite.version, sqliteSelectionError: sqlite.selectionError, @@ -98,12 +100,12 @@ export function detectRuntime(): RuntimeDetails { }; } -function detectCurrentRuntimeSqlite(): { +async function detectCurrentRuntimeSqlite(): Promise<{ available: boolean; version: string | null; selectionError?: string; probe?: SqliteCapabilities; -} { +}> { try { ensureSqliteLibrarySelected(); } catch (error) { @@ -114,7 +116,7 @@ function detectCurrentRuntimeSqlite(): { }; } try { - const probe = detectCurrentSqliteCapabilities(); + const probe = await detectCurrentSqliteCapabilities(); return { available: probe.available, version: probe.version, probe }; } catch { return { available: false, version: null }; @@ -143,8 +145,8 @@ function runtimeSatisfies(details: RuntimeDetails): boolean { } /** Returns whether the current process runtime satisfies OpenClaw's engine contract. */ -export function isCurrentRuntimeSupported(): boolean { - return runtimeSatisfies(detectRuntime()); +export async function isCurrentRuntimeSupported(): Promise { + return runtimeSatisfies(await detectRuntime()); } /** Checks a Node version label against OpenClaw's supported Node version range. */ @@ -212,11 +214,12 @@ export function nodeVersionSatisfiesEngine( /** Exits through the provided runtime when the current Node runtime is unsupported. */ export async function assertSupportedRuntime( providedRuntime?: RuntimeEnv, - details: RuntimeDetails = detectRuntime(), + providedDetails?: RuntimeDetails, argv?: readonly string[], emitDiagnosticWarning = true, recoveryEnv?: NodeJS.ProcessEnv, ): Promise { + const details = providedDetails ?? (await detectRuntime()); if (runtimeSatisfies(details)) { const note = details.kind === "node" && details.sqliteProbe diff --git a/src/infra/sqlite-capabilities.test.ts b/src/infra/sqlite-capabilities.test.ts index 195d444b2c3a..a8c9f3b40cde 100644 --- a/src/infra/sqlite-capabilities.test.ts +++ b/src/infra/sqlite-capabilities.test.ts @@ -90,18 +90,56 @@ describe("SQLite NUL capability probe", () => { }); }); - it("caches the current process probe", async () => { - vi.resetModules(); - const { detectCurrentSqliteCapabilities } = await import("../../node-sqlite.mjs"); - const prepare = vi.spyOn(DatabaseSync.prototype, "prepare"); - try { - const first = detectCurrentSqliteCapabilities(); - const calls = prepare.mock.calls.length; - expect(first.text).toBe(true); - expect(detectCurrentSqliteCapabilities()).toBe(first); - expect(prepare).toHaveBeenCalledTimes(calls); - } finally { - prepare.mockRestore(); - } + it("shares one worker probe and joins its exit before returning capabilities", () => { + const moduleUrl = new URL("../../node-sqlite.mjs", import.meta.url).href; + const output = execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { DatabaseSync, StatementSync } from "node:sqlite"; + const counts = {}; + for (const [prototype, names] of [ + [DatabaseSync.prototype, ["prepare", "exec"]], + [StatementSync.prototype, ["get", "all", "run", "iterate"]], + ]) { + for (const name of names) { + counts[name] = 0; + const original = prototype[name]; + prototype[name] = function (...args) { + counts[name]++; + return Reflect.apply(original, this, args); + }; + } + } + let workersStarted = 0; + let workersExited = 0; + process.on("worker", (worker) => { + workersStarted++; + worker.once("exit", () => workersExited++); + }); + const { detectCurrentSqliteCapabilities } = await import(${JSON.stringify(moduleUrl)}); + const pending = detectCurrentSqliteCapabilities(); + const samePending = pending === detectCurrentSqliteCapabilities(); + const capabilities = await pending; + const sameResult = capabilities === await detectCurrentSqliteCapabilities(); + process.stdout.write(JSON.stringify({ + asynchronous: pending instanceof Promise, + samePending, sameResult, capabilities, workersStarted, workersExited, counts, + })); + `, + ], + { encoding: "utf8", timeout: 10_000 }, + ); + expect(JSON.parse(output)).toMatchObject({ + asynchronous: true, + samePending: true, + sameResult: true, + capabilities: { available: true, text: true, blob: true, json: true }, + workersStarted: 1, + workersExited: 1, + counts: { prepare: 0, exec: 0, get: 0, all: 0, run: 0, iterate: 0 }, + }); }); }); diff --git a/src/infra/sqlite-capability-worker.test.ts b/src/infra/sqlite-capability-worker.test.ts new file mode 100644 index 000000000000..6b74bdc8b64b --- /dev/null +++ b/src/infra/sqlite-capability-worker.test.ts @@ -0,0 +1,92 @@ +import type { EventEmitter } from "node:events"; +import { expectDefined } from "@openclaw/normalization-core/expect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { detectCurrentSqliteCapabilities } from "../../node-sqlite.mjs"; + +const workers = vi.hoisted(() => ({ + created: vi.fn<(worker: EventEmitter) => void>(), + terminate: vi.fn<() => Promise>(), +})); + +vi.mock("node:worker_threads", async () => { + const { EventEmitter } = await import("node:events"); + return { + Worker: class extends EventEmitter { + constructor() { + super(); + workers.created(this); + } + terminate = workers.terminate; + }, + }; +}); + +const capabilities = { + available: true, + version: "3.51.3", + text: true, + blob: true, + json: true, +}; + +beforeEach(() => { + Reflect.deleteProperty(globalThis, Symbol.for("openclaw.sqliteCapabilities")); + workers.created.mockReset(); + workers.terminate.mockReset().mockResolvedValue(1); +}); + +afterEach(() => { + Reflect.deleteProperty(globalThis, Symbol.for("openclaw.sqliteCapabilities")); +}); + +describe("SQLite capability worker readiness", () => { + it.each(["empty", "nonzero", "error", "messageerror", "invalid"] as const)( + "memoizes unavailable capabilities after a %s worker result", + async (outcome) => { + const pending = detectCurrentSqliteCapabilities(); + let settled = false; + void pending.then(() => (settled = true)); + await vi.waitFor(() => expect(workers.created).toHaveBeenCalledOnce()); + const worker = expectDefined(workers.created.mock.calls[0]?.[0], "capability worker"); + if (outcome === "nonzero") { + worker.emit("message", capabilities); + } else if (outcome === "error" || outcome === "messageerror") { + worker.emit(outcome, new Error("worker transport failed")); + } else if (outcome === "invalid") { + worker.emit("message", { available: true }); + } + await Promise.resolve(); + expect(settled).toBe(false); + worker.emit("exit", outcome === "nonzero" ? 1 : 0); + const result = await pending; + expect(result).toMatchObject({ + available: false, + version: null, + text: false, + blob: false, + json: false, + error: expect.any(String), + }); + expect(await detectCurrentSqliteCapabilities()).toBe(result); + expect(workers.created).toHaveBeenCalledOnce(); + expect(workers.terminate).toHaveBeenCalledTimes( + ["error", "messageerror", "invalid"].includes(outcome) ? 1 : 0, + ); + expect(worker.eventNames()).toEqual([]); + }, + ); + + it("memoizes constructor failure without rejecting startup readiness", async () => { + workers.created.mockImplementation(() => { + throw new Error("worker unavailable"); + }); + const pending = detectCurrentSqliteCapabilities(); + expect(detectCurrentSqliteCapabilities()).toBe(pending); + await expect(pending).resolves.toMatchObject({ + available: false, + error: "worker unavailable", + }); + expect(detectCurrentSqliteCapabilities()).toBe(pending); + expect(workers.created).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/infra/update-runner-git-node-preflight.ts b/src/infra/update-runner-git-node-preflight.ts index cf055978b173..b6cc17f756a9 100644 --- a/src/infra/update-runner-git-node-preflight.ts +++ b/src/infra/update-runner-git-node-preflight.ts @@ -31,7 +31,10 @@ export async function checkGitCandidateNodeRuntime( } const engine = await readCandidateNodeEngine(root); const currentVersion = process.versions.node; - const capabilityError = nodeRuntimeFailure(currentVersion, detectCurrentSqliteCapabilities()); + const capabilityError = nodeRuntimeFailure( + currentVersion, + await detectCurrentSqliteCapabilities(), + ); if (!capabilityError && nodeVersionSatisfiesEngine(currentVersion, engine) !== false) { return null; }