-
Notifications
You must be signed in to change notification settings - Fork 511
fix(worker-shell): bundle sqlite worker runtime #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { readFile } from "node:fs/promises"; | ||
| import { dirname, resolve } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const here = dirname(fileURLToPath(import.meta.url)); | ||
| const justBashEntry = fileURLToPath(import.meta.resolve("just-bash")); | ||
| const sqliteWorker = resolve(dirname(justBashEntry), "../commands/sqlite3/worker.js"); | ||
| const sqlJsEntry = fileURLToPath(import.meta.resolve("sql.js")); | ||
|
|
||
| export const SQLITE_WASM_PATH = resolve(dirname(sqlJsEntry), "sql-wasm.wasm"); | ||
|
|
||
| const SQLITE_WORKER_ERROR = "sqlite3 worker not found. Run 'pnpm build' to compile the worker."; | ||
| const FIND_WORKER = | ||
| 'for(let r of t)if(ce(r))return r;throw new Error("' + SQLITE_WORKER_ERROR + '")'; | ||
| const CREATE_WORKER = | ||
| "return new he(e,{workerData:t,resourceLimits:{maxOldGenerationSizeMb:be,maxYoungGenerationSizeMb:we}})"; | ||
|
|
||
| function replaceExactlyOnce(source, search, replacement, label) { | ||
| const first = source.indexOf(search); | ||
| if (first === -1 || source.indexOf(search, first + search.length) !== -1) { | ||
| throw new Error(`sqlite bundle: expected exactly one ${label}`); | ||
| } | ||
| return source.slice(0, first) + replacement + source.slice(first + search.length); | ||
| } | ||
|
|
||
| export function sqliteWorkerPlugin() { | ||
| let commandAdapted = false; | ||
| let queryWorkerLoaded = false; | ||
| let sqlJsLoaded = false; | ||
|
|
||
| return { | ||
| name: "adapt-sqlite-worker", | ||
| setup(build) { | ||
| // just-bash's generated command chunk looks for a worker file with | ||
| // node:fs and constructs node:worker_threads.Worker. Neither mechanism | ||
| // can reach a Dynamic Worker Loader module, so route both through a lazy | ||
| // adapter that implements the same small event protocol in this isolate. | ||
| build.onLoad( | ||
| { filter: /[\\/]just-bash[\\/]dist[\\/]bundle[\\/]chunks[\\/]chunk-[^/\\]+\.js$/ }, | ||
| async (args) => { | ||
| let source = await readFile(args.path, "utf8"); | ||
| if (!source.includes(SQLITE_WORKER_ERROR)) return undefined; | ||
|
|
||
| source = replaceExactlyOnce( | ||
| source, | ||
| FIND_WORKER, | ||
| 'return "inline:sqlite3"', | ||
| "sqlite worker lookup", | ||
| ); | ||
| source = replaceExactlyOnce( | ||
| source, | ||
| CREATE_WORKER, | ||
| "return __createInlineSqliteWorker(t)", | ||
| "sqlite Worker constructor", | ||
| ); | ||
| source = | ||
| `import { createInlineSqliteWorker as __createInlineSqliteWorker } from ${JSON.stringify( | ||
| resolve(here, "sqlite-command-adapter.mjs"), | ||
| )};\n` + source; | ||
| // Wrap the exported command so WorkspaceFsAdapter gets a stable | ||
| // database-lock identity without changing the always-on adapter. | ||
| source = replaceExactlyOnce( | ||
| source, | ||
| "export{$e as a,_e as b,Fe as c};", | ||
| "const __sqliteCommand=__adaptSqliteCommand(_e);export{$e as a,__sqliteCommand as b,Fe as c};", | ||
| "sqlite command export", | ||
| ); | ||
| source = | ||
| `import { adaptSqliteCommand as __adaptSqliteCommand } from ${JSON.stringify( | ||
| resolve(here, "sqlite-command-adapter.mjs"), | ||
| )};\n` + source; | ||
| commandAdapted = true; | ||
| return { contents: source, loader: "js", resolveDir: dirname(args.path) }; | ||
| }, | ||
| ); | ||
|
|
||
| // Pull the worker's query implementation into the sqlite feature graph. | ||
| // Its Node worker entrypoint is guarded by parentPort, which is null here; | ||
| // exporting executeQuery lets the adapter call the same implementation. | ||
| build.onResolve({ filter: /^computer:sqlite-query-worker$/ }, () => ({ | ||
| path: "query-worker", | ||
| namespace: "computer-sqlite", | ||
| })); | ||
| build.onLoad({ filter: /^query-worker$/, namespace: "computer-sqlite" }, async () => { | ||
| let source = await readFile(sqliteWorker, "utf8"); | ||
| // WorkerDefenseInDepth belongs around a dedicated thread. Running it | ||
| // in the shell's isolate would harden the shell itself after a query. | ||
| source = replaceExactlyOnce( | ||
| source, | ||
| " activateDefense();\n", | ||
| "", | ||
| "worker defense activation", | ||
| ); | ||
| queryWorkerLoaded = true; | ||
| return { | ||
| contents: `${source}\nexport { executeQuery };\n`, | ||
| loader: "js", | ||
| resolveDir: dirname(sqliteWorker), | ||
| }; | ||
| }); | ||
|
|
||
| // sql.js normally fetches sql-wasm.wasm from a package filesystem. The | ||
| // Dynamic Worker instead imports a precompiled module supplied in its | ||
| // Loader module table, then hands that module to Emscripten explicitly. | ||
| build.onResolve({ filter: /^sql\.js$/ }, () => ({ | ||
| path: "sql.js", | ||
| namespace: "computer-sqlite", | ||
| })); | ||
| build.onLoad({ filter: /^sql\.js$/, namespace: "computer-sqlite" }, () => ({ | ||
| contents: ` | ||
| import initSqlJs from ${JSON.stringify(sqlJsEntry)}; | ||
| import sqliteWasm from "./sql-wasm.wasm"; | ||
| export default function init(options = {}) { | ||
| return initSqlJs({ | ||
| ...options, | ||
| instantiateWasm(imports, receiveInstance) { | ||
| const instance = new WebAssembly.Instance(sqliteWasm, imports); | ||
| receiveInstance(instance, sqliteWasm); | ||
| return instance.exports; | ||
| }, | ||
| }); | ||
| } | ||
| `, | ||
| loader: "js", | ||
| resolveDir: dirname(sqlJsEntry), | ||
| })); | ||
| build.onResolve({ filter: /^\.\/sql-wasm\.wasm$/ }, () => ({ | ||
| path: "./sql-wasm.wasm", | ||
| external: true, | ||
| })); | ||
|
|
||
| // sql.js sees WorkerGlobalScope and process shims in workerd and otherwise | ||
| // chooses loading branches that require self.location or node:fs. Neither | ||
| // branch is needed when instantiateWasm is supplied by the wrapper above. | ||
| build.onLoad({ filter: /[\\/]sql\.js[\\/]dist[\\/]sql-wasm\.js$/ }, async (args) => { | ||
| let source = await readFile(args.path, "utf8"); | ||
| source = replaceExactlyOnce( | ||
| source, | ||
| "globalThis.WorkerGlobalScope", | ||
| "undefined", | ||
| "sql.js WorkerGlobalScope probe", | ||
| ); | ||
| source = replaceExactlyOnce( | ||
| source, | ||
| "globalThis.process?.versions?.node", | ||
| "undefined", | ||
| "sql.js Node probe", | ||
| ); | ||
| sqlJsLoaded = true; | ||
| return { contents: source, loader: "js", resolveDir: dirname(args.path) }; | ||
| }); | ||
|
|
||
| build.onEnd((result) => { | ||
| if (result.errors.length > 0) return; | ||
| if (!commandAdapted || !queryWorkerLoaded || !sqlJsLoaded) { | ||
| throw new Error( | ||
| `sqlite bundle: incomplete adaptation (${JSON.stringify({ | ||
| commandAdapted, | ||
| queryWorkerLoaded, | ||
| sqlJsLoaded, | ||
| })})`, | ||
| ); | ||
| } | ||
| }); | ||
| }, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // just-bash's sqlite3 command executes queries in a Node Worker thread. The | ||
| // Workers runtime exposes the node:worker_threads API surface but does not | ||
| // implement the Worker constructor. A WorkerShellBackend already runs in its | ||
| // own Dynamic Worker isolate, so execute the query worker in that isolate while | ||
| // preserving the small EventEmitter protocol just-bash expects. | ||
|
|
||
| import { executeQuery } from "computer:sqlite-query-worker"; | ||
|
|
||
| class InlineSqliteWorker { | ||
| #listeners = new Map(); | ||
| #terminated = false; | ||
|
|
||
| constructor(workerData) { | ||
| queueMicrotask(() => { | ||
| void executeQuery(workerData) | ||
| .then((result) => { | ||
| this.#emit("message", { | ||
| ...result, | ||
| protocolToken: workerData.protocolToken, | ||
| }); | ||
| }) | ||
| .catch((error) => this.#emit("error", error)); | ||
| }); | ||
| } | ||
|
|
||
| on(event, listener) { | ||
| let listeners = this.#listeners.get(event); | ||
| if (listeners === undefined) { | ||
| listeners = new Set(); | ||
| this.#listeners.set(event, listeners); | ||
| } | ||
| listeners.add(listener); | ||
| return this; | ||
| } | ||
|
|
||
| removeListener(event, listener) { | ||
| this.#listeners.get(event)?.delete(listener); | ||
| return this; | ||
| } | ||
|
|
||
| async terminate() { | ||
| this.#terminated = true; | ||
| return 0; | ||
| } | ||
|
|
||
| #emit(event, value) { | ||
| if (this.#terminated) return; | ||
| for (const listener of this.#listeners.get(event) ?? []) listener(value); | ||
| } | ||
| } | ||
|
|
||
| // WorkspaceFsAdapter has no Node dev/ino pair because its storage is remote. | ||
| // It also does not support hard links, so a canonical path is a stable lock | ||
| // identity for the database. Keep this compatibility layer in SQLite's lazy | ||
| // chunk instead of adding bytes to every Worker shell. | ||
| function filesystemWithStableIdentity(fs) { | ||
| return new Proxy(fs, { | ||
| get(target, property) { | ||
| if (property === "stat") { | ||
| return async (path) => { | ||
| const stat = await target.stat(path); | ||
| if (stat.identity !== undefined || (stat.dev !== undefined && stat.ino !== undefined)) { | ||
| return stat; | ||
| } | ||
| return { ...stat, identity: await target.realpath(path) }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Symlinked databases bypass locks
Learn moreSQLite adds the filesystem's stable identity to its database lock keys. This wrapper synthesizes that identity from Example: Recommended fix: Resolve symlinks to their final canonical target before synthesizing Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }; | ||
| } | ||
|
|
||
| // WorkspaceFsAdapter uses private fields, so methods must retain the | ||
| // original receiver rather than receiving this Proxy as `this`. | ||
| const value = Reflect.get(target, property, target); | ||
| return typeof value === "function" ? value.bind(target) : value; | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| export function createInlineSqliteWorker(workerData) { | ||
| return new InlineSqliteWorker(workerData); | ||
| } | ||
|
|
||
| export function adaptSqliteCommand(command) { | ||
| return { | ||
| ...command, | ||
| execute(args, context) { | ||
| return command.execute(args, { | ||
| ...context, | ||
| fs: filesystemWithStableIdentity(context.fs), | ||
| }); | ||
|
Comment on lines
+84
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Concurrent SQLite writes lose updates
Learn morejust-bash stores SQLite locks in a Example: Executions A and B open Recommended fix: Give every SQLite command in the Dynamic Worker a shared Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }, | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 SQLite timeouts cannot stop queries
A CPU-bound query prevents
terminate()from running untilexecuteQueryfinishes. SQLite limits and shell cancellation cannot stop it, blocking every command in the Dynamic Worker.Learn more
The original SQLite implementation runs
executeQueryin a Node worker thread. Its controller enforcesmaxSqliteTimeoutMsby terminating that thread. The inline adapter runs the same synchronous sql.js WebAssembly work on the Dynamic Worker's event loop. A timer, abort RPC, orterminate()call cannot execute while that work occupies the isolate. The configured query timeout therefore only takes effect after the query has already returned.Example: A recursive query that runs for minutes starts with a five-second SQLite timeout. The five-second timer cannot run while WebAssembly executes. The shell isolate remains unavailable until the query naturally finishes instead of returning after five seconds.
Recommended fix: Execute SQLite in a separately terminable Worker-compatible isolate, or add an interruption mechanism inside SQLite that the runtime can trigger independently of the blocked event loop. Do not report successful termination unless the computation has actually stopped.
Was this helpful? React with 👍 or 👎 to provide feedback.