refactor(runtime): move SQLite startup checks off the main thread (#147866)

* refactor(runtime): probe SQLite startup capabilities in a worker

Move current-runtime capability SQL off the main thread and share one pending result across launcher and runtime callers. Await worker exit before admission, preserve failure caching and Bun library selection, and retain the native SQLite SDK and foreign-runtime probe contracts.

* test(config): expose include provenance in writer failures

Carry the bounded consumed-snapshot, provenance, and include-boundary assertions from 3de18f60bd (#143587). Preserve the observed snapshot and all existing writer/exclusion assertions. The hosted failure cause remains unresolved.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger 2026-09-13 23:34:06 -07:00 • committed by GitHub
parent 0e6882a4b8
commit ea833bde9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 327 additions and 70 deletions

View file

@ -657,7 +657,7 @@ export async function recoverNodeRuntime({
!process.argv[1] || !process.argv[1] ||
isForegroundGmailRunInvocation(process.argv) || isForegroundGmailRunInvocation(process.argv) ||
(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) || (process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) ||
!nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()) !nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities())
) { ) {
return false; return false;
} }

View file

@ -6,7 +6,7 @@ export type SqliteCapabilities = {
json: boolean; json: boolean;
error?: string; error?: string;
}; };
export function detectCurrentSqliteCapabilities(): SqliteCapabilities; export function detectCurrentSqliteCapabilities(): Promise<SqliteCapabilities>;
export function isSqliteWalResetSafeVersion(value: string): boolean; export function isSqliteWalResetSafeVersion(value: string): boolean;
export function nodeRuntimeFailure( export function nodeRuntimeFailure(
version: string | null, version: string | null,

View file

@ -32,24 +32,102 @@ function probeSqlite(DatabaseSync) {
// The launcher and bundled runtime chunks must share one process-local result. // The launcher and bundled runtime chunks must share one process-local result.
const capabilityCacheKey = Symbol.for("openclaw.sqliteCapabilities"); const capabilityCacheKey = Symbol.for("openclaw.sqliteCapabilities");
export function detectCurrentSqliteCapabilities() { function unavailableSqliteCapabilities(error) {
let cachedProbe = globalThis[capabilityCacheKey]; return {
if (!cachedProbe) { available: false,
try { version: null,
cachedProbe = probeSqlite(process.getBuiltinModule?.("node:sqlite")?.DatabaseSync); text: false,
} catch (error) { blob: false,
cachedProbe = { json: false,
available: false, error: error instanceof Error ? error.message : String(error),
version: null, };
text: false, }
blob: false,
json: false, function isSqliteCapabilities(value) {
error: error instanceof Error ? error.message : String(error), 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) { export function isSqliteWalResetSafeVersion(value) {

View file

@ -49,7 +49,7 @@ const ensureSupportedRuntimeVersion = async () => {
); );
return process.exit(1); return process.exit(1);
} }
const probe = detectCurrentSqliteCapabilities(); const probe = await detectCurrentSqliteCapabilities();
const failure = nodeRuntimeFailure(process.versions.node, probe); const failure = nodeRuntimeFailure(process.versions.node, probe);
if (!failure) { if (!failure) {
const note = nodeRuntimeNote(process.versions.node, probe); const note = nodeRuntimeNote(process.versions.node, probe);
@ -626,7 +626,7 @@ const tryOutputPrecomputedCommandHelp = () => {
const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion(); const waitingForNodeUpdateRespawn = await ensureSupportedRuntimeVersion();
const currentNodeRuntimeFailure = process.versions.bun const currentNodeRuntimeFailure = process.versions.bun
? null ? null
: nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()); : nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities());
if (!waitingForNodeUpdateRespawn) { if (!waitingForNodeUpdateRespawn) {
// Diagnostics must not replay package lifecycle scripts under an unsupported Node. // Diagnostics must not replay package lifecycle scripts under an unsupported Node.

View file

@ -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) => { 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.stubGlobal("process", { ...process, versions: { ...process.versions, node } });
vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({ const capabilities = await nodeSqlite.detectCurrentSqliteCapabilities();
...nodeSqlite.detectCurrentSqliteCapabilities(), vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockResolvedValue({
...capabilities,
text: false, text: false,
}); });
runDoctorLintCli.mockResolvedValue(1); runDoctorLintCli.mockResolvedValue(1);

View file

@ -135,7 +135,7 @@ export function registerMaintenanceCommands(program: Command) {
typeof opts.sessionSqlite !== "string"; typeof opts.sessionSqlite !== "string";
const unsupportedNode = const unsupportedNode =
!process.versions.bun && !process.versions.bun &&
Boolean(nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities())); Boolean(nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities()));
const lintMode = const lintMode =
opts.lint === true || unsupportedNode ? "--lint" : jsonImpliesLint ? "--json" : undefined; opts.lint === true || unsupportedNode ? "--lint" : jsonImpliesLint ? "--json" : undefined;
const mutationOption = const mutationOption =

View file

@ -58,6 +58,7 @@ const pinConfigDirMock = vi.hoisted(() => vi.fn());
const pinRuntimePathsMock = vi.hoisted(() => vi.fn()); const pinRuntimePathsMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn()); const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn(async () => {})); const assertRuntimeMock = vi.hoisted(() => vi.fn(async () => {}));
const isCurrentRuntimeSupportedMock = vi.hoisted(() => vi.fn(async () => true));
const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {})); const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false)); const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false));
const listRegisteredAgentHarnessesMock = vi.hoisted(() => vi.fn((): unknown[] => [])); 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) => ({ vi.mock("../infra/runtime-guard.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/runtime-guard.js")>()), ...(await importOriginal<typeof import("../infra/runtime-guard.js")>()),
assertSupportedRuntime: assertRuntimeMock, assertSupportedRuntime: assertRuntimeMock,
isCurrentRuntimeSupported: isCurrentRuntimeSupportedMock,
})); }));
vi.mock("../plugins/memory-runtime.js", () => ({ vi.mock("../plugins/memory-runtime.js", () => ({
@ -2373,13 +2375,28 @@ describe("runCli exit behavior", () => {
expect(shouldStartProxyForCli(argv)).toBe(false); expect(shouldStartProxyForCli(argv)).toBe(false);
}); });
it("starts the managed proxy for network-capable commands by default", async () => { it.each([true, false])(
tryRouteCliMock.mockResolvedValueOnce(true); "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([ it.each([
["worker", { observe: false, pluginValidation: "core-only" }], ["worker", { observe: false, pluginValidation: "core-only" }],

View file

@ -1208,7 +1208,7 @@ async function runCliWithPreparedOutputMode(
env: process.env, env: process.env,
}); });
const useSourceOnlyBestEffortConfig = const useSourceOnlyBestEffortConfig =
!isCurrentRuntimeSupported() || !(await isCurrentRuntimeSupported()) ||
normalizedInvocation.primary === "update" || normalizedInvocation.primary === "update" ||
(normalizedInvocation.primary === "doctor" && hasFlag(normalizedArgv, "--lint")); (normalizedInvocation.primary === "doctor" && hasFlag(normalizedArgv, "--lint"));
const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => { const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => {

View file

@ -104,7 +104,7 @@ describe("update status Node runtime findings", () => {
); );
}); });
const freshGuard = await import("../../infra/runtime-guard.js"); const freshGuard = await import("../../infra/runtime-guard.js");
vi.spyOn(freshGuard, "detectRuntime").mockReturnValue({ vi.spyOn(freshGuard, "detectRuntime").mockResolvedValue({
kind: "node", kind: "node",
version: process.versions.node, version: process.versions.node,
execPath: "/fixture/node", execPath: "/fixture/node",
@ -164,7 +164,7 @@ describe("update status Node runtime findings", () => {
"renders admitted %s runtime information without a missing hint", "renders admitted %s runtime information without a missing hint",
async (source) => { async (source) => {
if (source === "cli") { if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({
kind: "node", kind: "node",
version: "24.15.0", version: "24.15.0",
execPath: "/fixture/node", execPath: "/fixture/node",
@ -200,7 +200,7 @@ describe("update status Node runtime findings", () => {
versions: { ...process.versions, node: source === "cli" ? version : "26.8.1" }, versions: { ...process.versions, node: source === "cli" ? version : "26.8.1" },
}); });
if (source === "cli") { if (source === "cli") {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({
kind: "node", kind: "node",
version, version,
execPath: "/fixture/node", execPath: "/fixture/node",

View file

@ -489,7 +489,7 @@ export async function prepareUpdateCommand(opts: UpdateCommandOptions) {
// Refuse before preflight can inspect write ownership or admit a live run ledger. // Refuse before preflight can inspect write ownership or admit a live run ledger.
const runtimeFailure = process.versions.bun const runtimeFailure = process.versions.bun
? null ? null
: nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities()); : nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities());
if (runtimeFailure) { if (runtimeFailure) {
const error = `${runtimeFailure}\n${formatUnsupportedNodeVersionMessage(process.versions.node)}`; const error = `${runtimeFailure}\n${formatUnsupportedNodeVersionMessage(process.versions.node)}`;
if (opts.json) { if (opts.json) {

View file

@ -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) => { 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.stubGlobal("process", { ...process, versions: { ...process.versions, node } });
vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockReturnValue({ const capabilities = await nodeSqlite.detectCurrentSqliteCapabilities();
...nodeSqlite.detectCurrentSqliteCapabilities(), vi.spyOn(nodeSqlite, "detectCurrentSqliteCapabilities").mockResolvedValue({
...capabilities,
text: false, text: false,
}); });
await expect(updateCommand({ json: true })).rejects.toEqual(new ExitError(1)); await expect(updateCommand({ json: true })).rejects.toEqual(new ExitError(1));

View file

@ -12,7 +12,7 @@ vi.mock("../../../node-sqlite.mjs", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../node-sqlite.mjs")>(); const actual = await importOriginal<typeof import("../../../node-sqlite.mjs")>();
return { return {
...actual, ...actual,
detectCurrentSqliteCapabilities: () => ({ detectCurrentSqliteCapabilities: async () => ({
available: true, available: true,
version: "3.51.3", version: "3.51.3",
text: probeState.text, text: probeState.text,

View file

@ -233,7 +233,10 @@ async function resolvePackageRuntimeForPreflight(params: {
const nodeRunner = normalizeOptionalString(params.nodeRunner); const nodeRunner = normalizeOptionalString(params.nodeRunner);
if (!nodeRunner) { if (!nodeRunner) {
const version = process.versions.node ?? null; const version = process.versions.node ?? null;
return { version, failure: nodeRuntimeFailure(version, detectCurrentSqliteCapabilities()) }; return {
version,
failure: nodeRuntimeFailure(version, await detectCurrentSqliteCapabilities()),
};
} }
const runtime = await resolveNodeRuntimeInfo( const runtime = await resolveNodeRuntimeInfo(
nodeRunner, nodeRunner,

View file

@ -35,7 +35,7 @@ vi.mock("./status-json-command.ts", () => ({
})); }));
function mockCliRuntime(version: string, text = true) { function mockCliRuntime(version: string, text = true) {
vi.spyOn(runtimeGuard, "detectRuntime").mockReturnValue({ vi.spyOn(runtimeGuard, "detectRuntime").mockResolvedValue({
kind: "node", kind: "node",
version, version,
execPath: "/fixture/node", execPath: "/fixture/node",

View file

@ -37,8 +37,8 @@ function unsupportedNodeFinding(
}; };
} }
function collectCurrentNodeRuntimeFindings(): readonly HealthFinding[] { async function collectCurrentNodeRuntimeFindings(): Promise<readonly HealthFinding[]> {
const runtime = detectRuntime(); const runtime = await detectRuntime();
if (runtime.kind !== "node" || !runtime.sqliteProbe) { if (runtime.kind !== "node" || !runtime.sqliteProbe) {
return []; return [];
} }
@ -64,7 +64,7 @@ export async function collectNodeRuntimeFindings(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): Promise<HealthFinding[]> { ): Promise<HealthFinding[]> {
return [ return [
...collectCurrentNodeRuntimeFindings(), ...(await collectCurrentNodeRuntimeFindings()),
...(await collectServiceNodeRuntimeFindings(env)), ...(await collectServiceNodeRuntimeFindings(env)),
]; ];
} }

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
const boundary = vi.hoisted(() => ({ const boundary = vi.hoisted(() => ({
mode: "flags" as "flags" | "compile-cache", mode: "flags" as "flags" | "compile-cache",
trace: false, trace: false,
runtimeSupported: true,
events: [] as string[], events: [] as string[],
writer: undefined as ((message: string, error?: unknown) => void | Promise<void>) | undefined, writer: undefined as ((message: string, error?: unknown) => void | Promise<void>) | undefined,
})); }));
@ -10,6 +11,10 @@ const boundary = vi.hoisted(() => ({
vi.mock("./infra/is-main.js", () => ({ isMainModule: () => true })); vi.mock("./infra/is-main.js", () => ({ isMainModule: () => true }));
vi.mock("./infra/openclaw-exec-env.js", () => ({ ensureOpenClawExecMarkerOnProcess: vi.fn() })); vi.mock("./infra/openclaw-exec-env.js", () => ({ ensureOpenClawExecMarkerOnProcess: vi.fn() }));
vi.mock("./infra/warning-filter.js", () => ({ installProcessWarningFilter: 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", () => ({ vi.mock("./cli/dotenv.js", () => ({
loadCliDotEnv: () => boundary.events.push("dotenv"), loadCliDotEnv: () => boundary.events.push("dotenv"),
})); }));
@ -47,6 +52,7 @@ const originalTitle = process.title;
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
boundary.events = []; boundary.events = [];
boundary.runtimeSupported = true;
boundary.writer = undefined; boundary.writer = undefined;
process.argv = [ process.argv = [
process.execPath, process.execPath,
@ -62,6 +68,21 @@ afterEach(() => {
vi.restoreAllMocks(); 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([ it.each([
{ mode: "flags", trace: false }, { mode: "flags", trace: false },
{ mode: "flags", trace: true }, { mode: "flags", trace: true },

View file

@ -135,7 +135,7 @@ if (
const startupEnv = { ...process.env }; const startupEnv = { ...process.env };
const { assertSupportedRuntime, isCurrentRuntimeSupported } = const { assertSupportedRuntime, isCurrentRuntimeSupported } =
await import("./infra/runtime-guard.js"); await import("./infra/runtime-guard.js");
if (!isCurrentRuntimeSupported()) { if (!(await isCurrentRuntimeSupported())) {
const { loadCliDotEnv } = await import("./cli/dotenv.js"); const { loadCliDotEnv } = await import("./cli/dotenv.js");
loadCliDotEnv({ quiet: true }); loadCliDotEnv({ quiet: true });
await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace); await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace);

View file

@ -1032,7 +1032,7 @@ describe("doctor gateway runtime checks", () => {
])( ])(
"reports current Node $version probe outcome as $severity", "reports current Node $version probe outcome as $severity",
async ({ version, text, severity, message }) => { async ({ version, text, severity, message }) => {
mocks.detectRuntime.mockReturnValue({ mocks.detectRuntime.mockResolvedValue({
kind: "node", kind: "node",
version, version,
execPath: "/opt/runtime/bin/node", execPath: "/opt/runtime/bin/node",

View file

@ -48,7 +48,7 @@ vi.mock("node:fs", async (importOriginal) => {
}); });
vi.mock("../../node-sqlite.mjs", async (importOriginal) => ({ vi.mock("../../node-sqlite.mjs", async (importOriginal) => ({
...(await importOriginal<typeof import("../../node-sqlite.mjs")>()), ...(await importOriginal<typeof import("../../node-sqlite.mjs")>()),
detectCurrentSqliteCapabilities: () => ({ detectCurrentSqliteCapabilities: async () => ({
available: true, available: true,
version: "3.51.3", version: "3.51.3",
text: mocks.currentAdmitted, text: mocks.currentAdmitted,

View file

@ -20,8 +20,8 @@ vi.mock("../../node-sqlite.mjs", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../node-sqlite.mjs")>(); const actual = await importOriginal<typeof import("../../node-sqlite.mjs")>();
return { return {
...actual, ...actual,
detectCurrentSqliteCapabilities: () => ({ detectCurrentSqliteCapabilities: async () => ({
...actual.detectCurrentSqliteCapabilities(), ...(await actual.detectCurrentSqliteCapabilities()),
text: state.lossless, text: state.lossless,
}), }),
}; };

View file

@ -80,17 +80,19 @@ function isAtLeast(version: Semver | null, minimum: Semver): boolean {
} }
/** Reads current process runtime metadata for startup support checks. */ /** Reads current process runtime metadata for startup support checks. */
export function detectRuntime(): RuntimeDetails { export async function detectRuntime(): Promise<RuntimeDetails> {
const bunVersion = process.versions?.bun; const bunVersion = process.versions?.bun;
const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown"; const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown";
const version = bunVersion ?? process.versions?.node ?? null; 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 { return {
kind, kind,
version, version,
execPath: process.execPath ?? null, execPath,
pathEnv: process.env.PATH ?? "(not set)", pathEnv,
hasNodeSqlite: sqlite.available, hasNodeSqlite: sqlite.available,
sqliteVersion: sqlite.version, sqliteVersion: sqlite.version,
sqliteSelectionError: sqlite.selectionError, sqliteSelectionError: sqlite.selectionError,
@ -98,12 +100,12 @@ export function detectRuntime(): RuntimeDetails {
}; };
} }
function detectCurrentRuntimeSqlite(): { async function detectCurrentRuntimeSqlite(): Promise<{
available: boolean; available: boolean;
version: string | null; version: string | null;
selectionError?: string; selectionError?: string;
probe?: SqliteCapabilities; probe?: SqliteCapabilities;
} { }> {
try { try {
ensureSqliteLibrarySelected(); ensureSqliteLibrarySelected();
} catch (error) { } catch (error) {
@ -114,7 +116,7 @@ function detectCurrentRuntimeSqlite(): {
}; };
} }
try { try {
const probe = detectCurrentSqliteCapabilities(); const probe = await detectCurrentSqliteCapabilities();
return { available: probe.available, version: probe.version, probe }; return { available: probe.available, version: probe.version, probe };
} catch { } catch {
return { available: false, version: null }; 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. */ /** Returns whether the current process runtime satisfies OpenClaw's engine contract. */
export function isCurrentRuntimeSupported(): boolean { export async function isCurrentRuntimeSupported(): Promise<boolean> {
return runtimeSatisfies(detectRuntime()); return runtimeSatisfies(await detectRuntime());
} }
/** Checks a Node version label against OpenClaw's supported Node version range. */ /** 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. */ /** Exits through the provided runtime when the current Node runtime is unsupported. */
export async function assertSupportedRuntime( export async function assertSupportedRuntime(
providedRuntime?: RuntimeEnv, providedRuntime?: RuntimeEnv,
details: RuntimeDetails = detectRuntime(), providedDetails?: RuntimeDetails,
argv?: readonly string[], argv?: readonly string[],
emitDiagnosticWarning = true, emitDiagnosticWarning = true,
recoveryEnv?: NodeJS.ProcessEnv, recoveryEnv?: NodeJS.ProcessEnv,
): Promise<void> { ): Promise<void> {
const details = providedDetails ?? (await detectRuntime());
if (runtimeSatisfies(details)) { if (runtimeSatisfies(details)) {
const note = const note =
details.kind === "node" && details.sqliteProbe details.kind === "node" && details.sqliteProbe

View file

@ -90,18 +90,56 @@ describe("SQLite NUL capability probe", () => {
}); });
}); });
it("caches the current process probe", async () => { it("shares one worker probe and joins its exit before returning capabilities", () => {
vi.resetModules(); const moduleUrl = new URL("../../node-sqlite.mjs", import.meta.url).href;
const { detectCurrentSqliteCapabilities } = await import("../../node-sqlite.mjs"); const output = execFileSync(
const prepare = vi.spyOn(DatabaseSync.prototype, "prepare"); process.execPath,
try { [
const first = detectCurrentSqliteCapabilities(); "--input-type=module",
const calls = prepare.mock.calls.length; "-e",
expect(first.text).toBe(true); `
expect(detectCurrentSqliteCapabilities()).toBe(first); import { DatabaseSync, StatementSync } from "node:sqlite";
expect(prepare).toHaveBeenCalledTimes(calls); const counts = {};
} finally { for (const [prototype, names] of [
prepare.mockRestore(); [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 },
});
}); });
}); });

View file

@ -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<number>>(),
}));
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();
});
});

View file

@ -31,7 +31,10 @@ export async function checkGitCandidateNodeRuntime(
} }
const engine = await readCandidateNodeEngine(root); const engine = await readCandidateNodeEngine(root);
const currentVersion = process.versions.node; const currentVersion = process.versions.node;
const capabilityError = nodeRuntimeFailure(currentVersion, detectCurrentSqliteCapabilities()); const capabilityError = nodeRuntimeFailure(
currentVersion,
await detectCurrentSqliteCapabilities(),
);
if (!capabilityError && nodeVersionSatisfiesEngine(currentVersion, engine) !== false) { if (!capabilityError && nodeVersionSatisfiesEngine(currentVersion, engine) !== false) {
return null; return null;
} }