mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
0e6882a4b8
commit
ea833bde9e
24 changed files with 327 additions and 70 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
110
node-sqlite.mjs
110
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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
92
src/infra/sqlite-capability-worker.test.ts
Normal file
92
src/infra/sqlite-capability-worker.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue