mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
* 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>
181 lines
6.7 KiB
JavaScript
181 lines
6.7 KiB
JavaScript
import { isSupportedOpenClawNodeVersion, parseNodeReleaseVersion } from "./node-version.mjs";
|
|
|
|
// Self-contained so a different runtime can execute exactly the same probe with -e.
|
|
function probeSqlite(DatabaseSync) {
|
|
const result = { available: false, version: null, text: false, blob: false, json: false };
|
|
let database;
|
|
try {
|
|
database = new DatabaseSync(":memory:");
|
|
result.available = true;
|
|
const version = database.prepare("SELECT sqlite_version() AS version").get()?.version;
|
|
result.version = typeof version === "string" ? version : null;
|
|
const text = "a\u0000b\u0000";
|
|
const bytes = Buffer.from(text, "utf8");
|
|
const json = JSON.stringify({ value: text });
|
|
database.exec("CREATE TABLE probe (text_value TEXT, blob_value BLOB, json_value TEXT)");
|
|
database.prepare("INSERT INTO probe VALUES (?, ?, ?)").run(text, bytes, json);
|
|
const row = database.prepare("SELECT text_value, blob_value, json_value FROM probe").get();
|
|
result.text =
|
|
typeof row?.text_value === "string" &&
|
|
row.text_value.length === text.length &&
|
|
Buffer.from(row.text_value, "utf8").equals(bytes);
|
|
result.blob =
|
|
row?.blob_value instanceof Uint8Array && Buffer.from(row.blob_value).equals(bytes);
|
|
result.json = row?.json_value === json && JSON.parse(row.json_value).value === text;
|
|
} catch (error) {
|
|
result.error = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
database?.close();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// The launcher and bundled runtime chunks must share one process-local result.
|
|
const capabilityCacheKey = Symbol.for("openclaw.sqliteCapabilities");
|
|
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"));
|
|
}
|
|
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 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) {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value.trim());
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
const [major, minor, patch] = match.slice(1).map(Number);
|
|
if (![major, minor, patch].every(Number.isSafeInteger)) {
|
|
return false;
|
|
}
|
|
return (
|
|
major > 3 ||
|
|
(major === 3 &&
|
|
(minor > 51 ||
|
|
(minor === 51 && patch >= 3) ||
|
|
(minor === 50 && patch >= 7) ||
|
|
(minor === 44 && patch >= 6)))
|
|
);
|
|
}
|
|
|
|
export function nodeRuntimeFailure(version, probe) {
|
|
const label = `Node ${version ?? "unknown"}`;
|
|
if (!probe.available) {
|
|
return `${label}: node:sqlite is unavailable; use 24.16+/26.1+ or a build with the fix.`;
|
|
}
|
|
if (!probe.text && !probe.error) {
|
|
return `${label}: node:sqlite truncates TEXT at embedded NUL (nodejs/node#61954); use 24.16+/26.1+ or a build with the fix`;
|
|
}
|
|
if (probe.error || !probe.text || !probe.blob || !probe.json) {
|
|
return `${label}: node:sqlite NUL round-trip capability probe failed; use 24.16+/26.1+ or a build with the fix.`;
|
|
}
|
|
if (!probe.version || !isSqliteWalResetSafeVersion(probe.version)) {
|
|
return `${label}: SQLite ${probe.version ?? "unknown"} is not WAL-reset-safe; use SQLite 3.51.3+, 3.50.7+, or 3.44.6+ on its patched release line.`;
|
|
}
|
|
// Node 22/23 remain excluded until their separate compatibility work is complete.
|
|
const release = parseNodeReleaseVersion(version);
|
|
if (!release || release.major < 24) {
|
|
return `${label}: openclaw requires Node >=24.16.0 <25, or >=26.1.0.`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function nodeRuntimeNote(version, probe) {
|
|
return !nodeRuntimeFailure(version, probe) && !isSupportedOpenClawNodeVersion(version)
|
|
? `Node ${version}: unsupported version, capability probe passed. Supported releases: 24.16+/26.1+.`
|
|
: null;
|
|
}
|
|
|
|
export const SQLITE_CAPABILITY_PROBE = `(${probeSqlite.toString()})(require("node:sqlite").DatabaseSync)`;
|