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] ||
isForegroundGmailRunInvocation(process.argv) ||
(process.platform !== "win32" && isNativeHookRelayInvocation(process.argv)) ||
!nodeRuntimeFailure(process.versions.node, detectCurrentSqliteCapabilities())
!nodeRuntimeFailure(process.versions.node, await detectCurrentSqliteCapabilities())
) {
return false;
}

View file

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

View file

@ -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) {

View file

@ -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.

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) => {
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);

View file

@ -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 =

View file

@ -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<typeof import("../infra/runtime-guard.js")>()),
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" }],

View file

@ -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<OpenClawConfig> => {

View file

@ -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",

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.
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) {

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) => {
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));

View file

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

View file

@ -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,

View file

@ -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",

View file

@ -37,8 +37,8 @@ function unsupportedNodeFinding(
};
}
function collectCurrentNodeRuntimeFindings(): readonly HealthFinding[] {
const runtime = detectRuntime();
async function collectCurrentNodeRuntimeFindings(): Promise<readonly HealthFinding[]> {
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<HealthFinding[]> {
return [
...collectCurrentNodeRuntimeFindings(),
...(await collectCurrentNodeRuntimeFindings()),
...(await collectServiceNodeRuntimeFindings(env)),
];
}

View file

@ -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<void>) | 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 },

View file

@ -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);

View file

@ -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",

View file

@ -48,7 +48,7 @@ vi.mock("node:fs", async (importOriginal) => {
});
vi.mock("../../node-sqlite.mjs", async (importOriginal) => ({
...(await importOriginal<typeof import("../../node-sqlite.mjs")>()),
detectCurrentSqliteCapabilities: () => ({
detectCurrentSqliteCapabilities: async () => ({
available: true,
version: "3.51.3",
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")>();
return {
...actual,
detectCurrentSqliteCapabilities: () => ({
...actual.detectCurrentSqliteCapabilities(),
detectCurrentSqliteCapabilities: async () => ({
...(await actual.detectCurrentSqliteCapabilities()),
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. */
export function detectRuntime(): RuntimeDetails {
export async function detectRuntime(): Promise<RuntimeDetails> {
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<boolean> {
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<void> {
const details = providedDetails ?? (await detectRuntime());
if (runtimeSatisfies(details)) {
const note =
details.kind === "node" && details.sqliteProbe

View file

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

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 currentVersion = process.versions.node;
const capabilityError = nodeRuntimeFailure(currentVersion, detectCurrentSqliteCapabilities());
const capabilityError = nodeRuntimeFailure(
currentVersion,
await detectCurrentSqliteCapabilities(),
);
if (!capabilityError && nodeVersionSatisfiesEngine(currentVersion, engine) !== false) {
return null;
}