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] ||
|
!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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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.
|
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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" }],
|
||||||
|
|
|
||||||
|
|
@ -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> => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue