From a340589cb99561e676928510ea455cd741d5cff9 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:12:48 +0800 Subject: [PATCH 1/6] test(vnext): prove isolated parser worker placement --- .github/workflows/test.yml | 3 + docs/adr/0003-node-sql-parser-adapter.md | 5 + docs/adr/0004-isolated-parser-execution.md | 313 ++++++++++ docs/vnext/node-sql-parser-adapter.md | 4 + package.json | 1 + scripts/worker-placement.mjs | 509 ++++++++++++++++ test/worker-placement/README.md | 19 + test/worker-placement/core.html | 12 + test/worker-placement/package.json | 15 + test/worker-placement/pnpm-lock.yaml | 547 ++++++++++++++++++ test/worker-placement/src/bigquery-worker.js | 5 + test/worker-placement/src/core.js | 20 + test/worker-placement/src/parser-worker.js | 166 ++++++ .../worker-placement/src/postgresql-worker.js | 5 + test/worker-placement/src/workers.js | 162 ++++++ test/worker-placement/vite.core.config.mjs | 36 ++ test/worker-placement/vite.workers.config.mjs | 17 + test/worker-placement/workers.html | 12 + 18 files changed, 1851 insertions(+) create mode 100644 docs/adr/0004-isolated-parser-execution.md create mode 100644 scripts/worker-placement.mjs create mode 100644 test/worker-placement/README.md create mode 100644 test/worker-placement/core.html create mode 100644 test/worker-placement/package.json create mode 100644 test/worker-placement/pnpm-lock.yaml create mode 100644 test/worker-placement/src/bigquery-worker.js create mode 100644 test/worker-placement/src/core.js create mode 100644 test/worker-placement/src/parser-worker.js create mode 100644 test/worker-placement/src/postgresql-worker.js create mode 100644 test/worker-placement/src/workers.js create mode 100644 test/worker-placement/vite.core.config.mjs create mode 100644 test/worker-placement/vite.workers.config.mjs create mode 100644 test/worker-placement/workers.html diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f5afba4..68f46db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,9 @@ jobs: - name: ๐Ÿงช Browser Tests run: pnpm run test:browser + - name: ๐Ÿงต Worker Placement Evidence + run: pnpm run test:worker-placement + package: env: EXPECTED_PNPM_VERSION: ${{ matrix.pnpm-version }} diff --git a/docs/adr/0003-node-sql-parser-adapter.md b/docs/adr/0003-node-sql-parser-adapter.md index 246b85f..3e60f1f 100644 --- a/docs/adr/0003-node-sql-parser-adapter.md +++ b/docs/adr/0003-node-sql-parser-adapter.md @@ -167,6 +167,11 @@ follow-up decision records one of: That decision must include browser measurements, cancellation behavior, worker/module failure recovery, and the effect of many mounted editors. +[ADR 0004](./0004-isolated-parser-execution.md) selects a dedicated, +service-owned browser worker and defines the remaining evidence gates. It does +not authorize session wiring until those gates and in-worker semantic +normalization pass. + The current production loader supports pure Node only. A future browser integration must invoke parsing from a dedicated worker whose global object is not shared with application code, the legacy parser, or another installed copy. diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md new file mode 100644 index 0000000..c8c400d --- /dev/null +++ b/docs/adr/0004-isolated-parser-execution.md @@ -0,0 +1,313 @@ +# ADR 0004: Isolated Browser Parser Execution + +Status: accepted for implementation, session wiring gated by evidence +Date: 2026-07-25 + +## Context + +ADR 0003 keeps the `node-sql-parser` adapter internal and unwired. Its parser +is synchronous, so an `AbortSignal` cannot interrupt it while it occupies the +JavaScript thread. Even a late result that is correctly discarded can make an +editor unresponsive. + +Moving the current adapter object into a worker is not valid. Parser requests, +authorities, ranges, artifacts, and analyses are authenticated by +package-owned, realm-local `WeakSet` and `WeakMap` state. Structured cloning +would produce unauthenticated copies. The backend AST is also retained in a +realm-local weak map and cannot become a cross-realm semantic API. + +The distributed dialect builds introduce separate constraints: + +- The Node loader uses `node:module` and intentionally rejects any realm with + `self` or `window`. +- The browser builds are CommonJS/UMD files which a consumer bundler must + transform. +- Loading a build may write `NodeSQLParser` or `global` on its realm. +- A browser worker can be terminated for a wall-clock deadline, but browsers + do not expose an enforceable per-worker heap limit. +- One worker per editor would multiply parser memory across marimo's many + mounted editors. + +This decision concerns local browser placement. Node `worker_threads`, native +providers, remote providers, and public packaging are separate decisions. + +## Decision + +### Browser-first placement + +Interactive browser parsing will use a dedicated module worker. The existing +pure-Node inline adapter remains internal evidence and batch-test +infrastructure. It is not a fallback when browser worker construction, +loading, or execution fails. + +Browser placement is accepted with an explicit residual risk: input, queue, +response, cache, and lifetime can be bounded, but transient parser allocation +cannot be capped before the browser itself terminates an over-consuming +worker. The current 16 KiB input ceiling remains an upper safety bound, not an +interactive performance claim. Production session wiring remains blocked +until adversarial memory, latency, failure-recovery, and many-editor gates +pass. + +### Ownership and scheduling + +Each `SqlLanguageService` will lazily own at most one dedicated parser worker. +All sessions opened by that service share it. The worker is neither a +`SharedWorker` nor a module-global singleton. + +The first executor is single-lane: + +- At most one request is posted at a time. +- The host queue is bounded independently by request count and retained UTF-16 + text units. +- A service owns construction, listeners, timers, termination, and disposal. +- No worker pool or idle shutdown is introduced without profile evidence. +- Service disposal terminates the worker and settles every pending consumer. + +Ordinary caller cancellation and supersession settle the consumer promptly +without relying on a worker message that cannot run during synchronous +parsing. The executor may drain and discard that active result. A hard +wall-clock deadline, worker crash, malformed protocol, or service disposal +terminates the generation. The placement benchmark must compare drain versus +restart under rapid edits before the executor policy is frozen. + +The safety deadline is separate from product latency targets. A deadline +failure never upgrades parser authority and an active request is not +automatically replayed after a crash or timeout. + +### Realm and loading boundary + +The worker is created with a same-origin URL: + +```ts +new Worker( + new URL("./node-sql-parser-browser-worker.js", import.meta.url), + { name: "codemirror-sql-parser", type: "module" }, +); +``` + +Blob, data, and evaluated workers are not used. Hosts must allow the emitted +worker URL in their Content Security Policy, normally through +`worker-src 'self'`. The worker asset response also receives a restrictive +policy because a worker has its own execution context. + +The constructor shape and its literal options stay static. Current +[Vite worker handling](https://vite.dev/guide/features#web-workers) recognizes +the URL only when `new URL(..., import.meta.url)` appears directly inside the +worker constructor. The +[platform worker contract](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) +also requires a same-origin entry URL and JavaScript response media type. + +The browser worker has a browser-specific loader. It does not weaken or reuse +the pure-Node realm gate. It dynamically imports literal, dialect-specific +paths only: + +```text +node-sql-parser/build/postgresql.js +node-sql-parser/build/bigquery.js +``` + +The worker verifies that `self === globalThis` and that no DOM window exists. +It snapshots and restores the exact `NodeSQLParser` and `global` descriptors +around dialect loading. Cleanup failure poisons that worker generation. + +### Private wire protocol + +The worker protocol is package-private, versioned, closed, and decoded from +`unknown` on both sides. It transports plain evidence, never authenticated +syntax objects. + +The initial request contains only: + +- Protocol version +- Host correlation ID +- Grammar ID: PostgreSQL or BigQuery +- Exact untrimmed statement text + +DuckDB uses the PostgreSQL grammar. Target-dialect policy stays in the main +realm. + +The initial response contains only one closed outcome: + +- Parsed normalized statement kind +- Syntax rejection +- Bounded unsupported reason +- Bounded failure code plus retryability + +Messages do not contain: + +- Public document revisions or session identities +- Parser authorities or dialect handles +- `AbortSignal`, `Error`, stack, or raw backend message values +- Source text echoed in a response +- Absolute document ranges +- Raw ASTs or generic payload bags + +The host requires the current protocol version and correlation ID, validates +all keys and closed values, and copies accepted data into new frozen objects. +It then constructs an authentic `SqlParserAnalysis` with the exact pending +request text and the host-owned authority. PostgreSQL and BigQuery rejection +remain uncovered constructs; DuckDB rejection remains compatibility rejection. +Worker isolation does not strengthen the compatibility-only evidence recorded +by ADR 0003. + +Old-generation events are ignored by generation-owned listeners. A malformed, +duplicate, unsolicited, or mismatched response kills the generation and +settles the active operation exactly once without exposing raw event data. + +### Semantic reuse + +Raw backend ASTs will not cross the worker boundary and the first protocol will +not introduce remote AST handles or worker-local AST leases. + +Before production session wiring, the worker request will parse once and run +adapter-owned semantic decoders in the same realm. It will return only the +bounded, validated relation facts required by the first completion slice. +This keeps backend shapes private, avoids reparsing once for syntax and again +for relations, and makes cached main-realm evidence measurable. + +Worker-local AST caching is deferred until profiling demonstrates that +reparsing is material enough to justify leases, byte accounting, generation +invalidation, and release semantics. + +### Packaging boundary + +Core and `/vnext` imports must remain SSR-safe and contain no parser grammar or +worker asset. A future optional integration entry may create the worker lazily, +but it will expose an opaque language-service module factory rather than the +protocol, worker URL, transport, pool, or backend AST. + +The initial supported bundler claim is limited to packed-consumer fixtures that +run in CI. Source-workspace success is not packaging evidence. + +## Evidence required before session wiring + +A production-shaped fixture built from the exact `npm pack` archive must prove: + +- Core-only import emits no parser or worker bytes. +- PostgreSQL and BigQuery emit separate worker chunks. +- The all-dialect build is absent. +- Worker creation is lazy. +- Both grammars execute in a real browser. +- Main-window parser globals remain unchanged. +- Core import remains SSR-safe. +- A same-origin module worker runs under a strict CSP. +- Worker startup, cold import, warm parse, and message round-trip samples are + recorded. +- Raw and gzip worker sizes are recorded. + +The executor and semantic slices additionally require: + +- Main-thread long-task and event-loop responsiveness evidence. +- Malformed message, crash, timeout, late-event, and restart tests. +- Rapid-edit drain-versus-restart measurements. +- One, ten, and fifty editor scenarios. +- Retained worker, listener, timer, and memory checks after disposal. +- Adversarial statements at the accepted input ceiling. + +The current product envelopes remain: + +- No routine main-thread task over 50 ms. +- Warm active-statement analysis p95 under 16 ms. +- Local completion p95 under 50 ms. + +Safety timeouts are not evidence that these product targets are met. + +### Initial packed-consumer baseline + +The placement harness introduced with this decision builds the exact packed +archive, consumes it from an isolated Vite 8 fixture, serves the production +output with a same-origin CSP, and runs it in Chromium. Its first local +Node 24 / Chromium 149 / arm64 macOS sample recorded: + +| Output | Raw | gzip | +| --- | ---: | ---: | +| Core-only fixture | 24,462 B | 7,477 B | +| PostgreSQL grammar plus worker entry | 318,628 B | 66,211 B | +| BigQuery grammar plus worker entry | 222,769 B | 49,492 B | +| Complete worker fixture | 567,271 B | 123,798 B | + +The core module trace contained no `node-sql-parser` module. No dialect +resource loaded before explicit construction. PostgreSQL and BigQuery were +emitted as separate assets and both parsed successfully without changing the +main-window parser sentinel. + +Two consecutive cold/warm runs measured: + +| Dialect | Cold request range | Warm parse | Warm round trip | +| --- | ---: | ---: | ---: | +| PostgreSQL | 17.0โ€“32.6 ms | 0.2 ms | 0.2โ€“0.3 ms | +| BigQuery | 10.1โ€“10.8 ms | 0.3 ms | 0.3 ms | + +These numbers establish packaging feasibility and initial size guards. They +are not percentile claims. Stable latency decisions require repeated, +cross-platform samples over the representative and adversarial corpus. + +The checked-in harness fails above 68 KiB gzip for the PostgreSQL assets, +50 KiB for BigQuery, or 124 KiB / 590 KiB for the complete worker fixture in +gzip/raw form. These ceilings include small measurement headroom and are +placement-spike guards, not the final optional-integration bundle budget. + +## Implementation sequence + +1. Add this ADR and the packed-consumer browser placement harness. +2. Extract a realm-neutral backend engine and add strict protocol codecs. +3. Add the minimal browser worker and single-lane executor. +4. Add in-worker normalized relation extraction. +5. Add the pure statement coordinator, bounded cache, in-flight sharing, and + atomic session ownership. +6. Ship relation completion as the first public consuming vertical slice. + +Every production step is a medium change and receives two independent, +commit-bound adversarial reviews. + +## Consequences + +- Synchronous parser CPU work cannot block the editor main thread. +- Fifty editors on one service do not imply fifty parser workers. +- Realm-local authenticity remains an internal safety boundary. +- Worker failure is explicit and never falls back to unsafe inline parsing. +- The raw AST remains replaceable and private. +- A serial worker may create head-of-line blocking; measurement, queue bounds, + and hard deadlines make that tradeoff visible before considering a pool. +- Browser heap exhaustion cannot be fully contained and remains a documented + residual risk. +- Browser and Node interactive execution can evolve independently. + +## Rejected alternatives + +### Run the parser on the browser main thread + +Late-result rejection preserves correctness but cannot restore responsiveness +while synchronous parsing runs. + +### Clone normalized syntax objects from the worker + +Structured cloning loses the package-owned realm authentication required by +the syntax contract. + +### Send raw ASTs or AST handles + +Raw ASTs expose backend coupling and can be very large. Remote handles add +leases, eviction, crash invalidation, and release semantics before a semantic +consumer exists. + +### One worker per editor + +This multiplies grammar and runtime memory and conflicts with the many-editor +release target. + +### `SharedWorker` or a module-global singleton + +Both weaken service ownership and disposal isolation. `SharedWorker` also +narrows runtime and CSP compatibility. + +### A generic worker or provider transport + +The first need is one parser with a small closed protocol. A general framework +would stabilize abstractions before there is evidence from a second workload. + +### A worker pool + +A pool increases grammar duplication, memory, scheduling, and cancellation +complexity. It can be reconsidered only if a measured serial bottleneck +outweighs those costs. diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index 31a8693..be67067 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -114,3 +114,7 @@ without importing a backend. For that reason, this adapter remains unwired. A worker-versus-main-thread ADR, with browser latency, memory, hostile-input, timeout, and recovery evidence, is required before interactive sessions may call it. + +[ADR 0004](../adr/0004-isolated-parser-execution.md) chooses isolated +browser-worker execution and records the packaging, performance, memory, and +semantic-reuse gates that still block session wiring. diff --git a/package.json b/package.json index 90031e7..d28281b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "vitest run --config vitest.config.ts --coverage", "test:coverage:changed": "node ./scripts/changed-coverage.mjs", "test:browser": "vitest run --config vitest.browser.config.ts", + "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs new file mode 100644 index 0000000..6e8fb6f --- /dev/null +++ b/scripts/worker-placement.mjs @@ -0,0 +1,509 @@ +import { execFileSync } from "node:child_process"; +import { + cpSync, + createReadStream, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, join, resolve, sep } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; + +const CONTENT_SECURITY_POLICY = [ + "base-uri 'none'", + "connect-src 'self'", + "default-src 'none'", + "object-src 'none'", + "script-src 'self'", + "worker-src 'self'", +].join("; "); +const PARSER_MARKERS = [ + "NodeSQLParser", + "whiteListCheck", + "trimQuery", + "columnList", + "tableList", +]; +const BIGQUERY_GZIP_LIMIT = 50 * 1024; +const POSTGRESQL_GZIP_LIMIT = 68 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 124 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 590 * 1024; +const MIME_TYPES = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], +]); +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureSource = join(repository, "test", "worker-placement"); +const temporaryDirectory = mkdtempSync( + join(tmpdir(), "codemirror-sql-worker-placement-"), +); +const packageManagerExecutable = process.env.npm_execpath; + +function parseArguments(arguments_) { + let reportPath = process.env.WORKER_PLACEMENT_REPORT; + let index = arguments_[0] === "--" ? 1 : 0; + for (; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument !== "--report" || index + 1 >= arguments_.length) { + throw new Error( + "Usage: pnpm run test:worker-placement -- [--report ]", + ); + } + reportPath = arguments_[index + 1]; + index += 1; + } + return reportPath === undefined + ? undefined + : resolve(repository, reportPath); +} + +function run(command, arguments_, cwd, capture = false) { + return execFileSync(command, arguments_, { + cwd, + encoding: "utf8", + env: { + ...process.env, + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + npm_config_manage_package_manager_versions: "false", + npm_config_package_manager_strict_version: "false", + }, + stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit", + }); +} + +function runPackageManager(arguments_, cwd, capture = false) { + if (!packageManagerExecutable) { + throw new Error( + "Worker placement must run through a package-manager script", + ); + } + return run( + process.execPath, + [packageManagerExecutable, ...arguments_], + cwd, + capture, + ); +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function listFiles(directory) { + const files = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) { + continue; + } + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(path); + } else if (entry.isFile()) { + files.push(path); + } + } + } + return files.sort(); +} + +function bundleReport(directory) { + const files = listFiles(directory).map((path) => { + const contents = readFileSync(path); + return { + file: path.slice(directory.length + 1).split(sep).join("/"), + gzipBytes: gzipSync(contents).length, + rawBytes: contents.length, + }; + }); + return { + files, + gzipBytes: files.reduce((total, file) => total + file.gzipBytes, 0), + rawBytes: files.reduce((total, file) => total + file.rawBytes, 0), + }; +} + +function verifyWorkerAssets(workersDirectory) { + const report = bundleReport(workersDirectory); + const javascriptFiles = report.files.filter((file) => + file.file.endsWith(".js"), + ); + const postgresqlFiles = javascriptFiles.filter((file) => + /postgresql/i.test(file.file), + ); + const bigqueryFiles = javascriptFiles.filter((file) => + /bigquery/i.test(file.file), + ); + if (postgresqlFiles.length === 0 || bigqueryFiles.length === 0) { + throw new Error( + "Worker build did not emit separate dialect assets", + ); + } + if ( + postgresqlFiles.some((postgresql) => + bigqueryFiles.some((bigquery) => bigquery.file === postgresql.file), + ) + ) { + throw new Error("PostgreSQL and BigQuery shared a dialect-named asset"); + } + const postgresqlGzipBytes = postgresqlFiles.reduce( + (total, file) => total + file.gzipBytes, + 0, + ); + const bigqueryGzipBytes = bigqueryFiles.reduce( + (total, file) => total + file.gzipBytes, + 0, + ); + if (postgresqlGzipBytes > POSTGRESQL_GZIP_LIMIT) { + throw new Error( + `PostgreSQL assets exceeded ${POSTGRESQL_GZIP_LIMIT} gzip bytes: ${postgresqlGzipBytes}`, + ); + } + if (bigqueryGzipBytes > BIGQUERY_GZIP_LIMIT) { + throw new Error( + `BigQuery assets exceeded ${BIGQUERY_GZIP_LIMIT} gzip bytes: ${bigqueryGzipBytes}`, + ); + } + if (report.gzipBytes > WORKER_TOTAL_GZIP_LIMIT) { + throw new Error( + `Worker output exceeded ${WORKER_TOTAL_GZIP_LIMIT} gzip bytes: ${report.gzipBytes}`, + ); + } + if (report.rawBytes > WORKER_TOTAL_RAW_LIMIT) { + throw new Error( + `Worker output exceeded ${WORKER_TOTAL_RAW_LIMIT} raw bytes: ${report.rawBytes}`, + ); + } + return { + ...report, + dialects: { + bigquery: { + files: bigqueryFiles.map((file) => file.file), + gzipBytes: bigqueryGzipBytes, + gzipLimit: BIGQUERY_GZIP_LIMIT, + }, + postgresql: { + files: postgresqlFiles.map((file) => file.file), + gzipBytes: postgresqlGzipBytes, + gzipLimit: POSTGRESQL_GZIP_LIMIT, + }, + }, + limits: { + gzipBytes: WORKER_TOTAL_GZIP_LIMIT, + rawBytes: WORKER_TOTAL_RAW_LIMIT, + }, + }; +} + +function verifyCoreExcludesParser(coreDirectory) { + const moduleIds = JSON.parse( + readFileSync(join(coreDirectory, "module-ids.json"), "utf8"), + ); + if (!Array.isArray(moduleIds)) { + throw new Error("Core module trace was not an array"); + } + const parserModules = moduleIds.filter( + (moduleId) => + typeof moduleId === "string" && + moduleId.includes("/node-sql-parser/"), + ); + if (parserModules.length > 0) { + throw new Error( + `Core-only build included parser modules: ${parserModules.join(", ")}`, + ); + } + for (const path of listFiles(coreDirectory)) { + const extension = extname(path); + if (extension !== ".js" && extension !== ".json") { + continue; + } + const contents = readFileSync(path, "utf8"); + const marker = PARSER_MARKERS.find((candidate) => + contents.includes(candidate), + ); + if (marker !== undefined) { + throw new Error( + `Core-only output ${basename(path)} contained parser marker ${marker}`, + ); + } + } + return moduleIds.length; +} + +function verifySsrImport(fixtureDirectory) { + const source = ` +Object.defineProperty(globalThis, "window", { + configurable: true, + get() { + throw new Error("SSR import read window"); + }, +}); +Object.defineProperty(globalThis, "Worker", { + configurable: true, + get() { + throw new Error("SSR import read Worker"); + }, +}); +const api = await import("@marimo-team/codemirror-sql/vnext"); +if ( + typeof api.createSqlLanguageService !== "function" || + typeof api.duckdbDialect !== "function" +) { + throw new Error("Packed SSR import was incomplete"); +} +`; + run( + process.execPath, + ["--input-type=module", "--eval", source], + fixtureDirectory, + ); +} + +function startStaticServer(directory) { + const root = resolve(directory); + const server = createServer((request, response) => { + const requestUrl = new URL( + request.url ?? "/", + "http://worker-placement.invalid", + ); + const relativePath = + requestUrl.pathname === "/" + ? "workers.html" + : decodeURIComponent(requestUrl.pathname.slice(1)); + const path = resolve(root, relativePath); + if (path !== root && !path.startsWith(`${root}${sep}`)) { + response.writeHead(403); + response.end("forbidden"); + return; + } + if (!existsSync(path) || !statSync(path).isFile()) { + response.writeHead(404); + response.end("not found"); + return; + } + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Security-Policy": CONTENT_SECURITY_POLICY, + "Content-Type": + MIME_TYPES.get(extname(path)) ?? + "application/octet-stream", + "Cross-Origin-Resource-Policy": "same-origin", + "X-Content-Type-Options": "nosniff", + }); + createReadStream(path).pipe(response); + }); + return new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Static server did not acquire a TCP port")); + return; + } + resolvePromise({ + close: async () => + await new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + } else { + resolveClose(); + } + }); + }), + url: `http://127.0.0.1:${address.port}/workers.html`, + }); + }); + }); +} + +async function runChromium(fixtureDirectory, workersDirectory) { + const fixtureRequire = createRequire( + join(fixtureDirectory, "package.json"), + ); + const { chromium } = fixtureRequire("playwright"); + const staticServer = await startStaticServer(workersDirectory); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const browserErrors = []; + page.on("console", (message) => { + if (message.type() === "error") { + browserErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + browserErrors.push(`page: ${error.message}`); + }); + const response = await page.goto(staticServer.url, { + waitUntil: "load", + }); + if ( + response === null || + response.headers()["content-security-policy"] !== + CONTENT_SECURITY_POLICY + ) { + throw new Error("Fixture did not receive the strict CSP header"); + } + await page.waitForFunction( + () => + document.body.dataset.status === "passed" || + document.body.dataset.status === "failed", + undefined, + { timeout: 20_000 }, + ); + const status = await page.locator("body").getAttribute("data-status"); + if (status !== "passed") { + throw new Error( + `Worker fixture failed: ${await page.locator("#result").textContent()}`, + ); + } + if (browserErrors.length > 0) { + throw new Error(browserErrors.join("\n")); + } + const timings = await page.evaluate( + () => globalThis.__CODEMIRROR_SQL_WORKER_PLACEMENT__, + ); + if ( + typeof timings !== "object" || + timings === null || + typeof timings.postgresql?.coldMs !== "number" || + typeof timings.bigquery?.coldMs !== "number" + ) { + throw new Error("Browser fixture returned malformed timing data"); + } + return { + browserVersion: browser.version(), + csp: CONTENT_SECURITY_POLICY, + timings, + }; + } finally { + if (browser !== undefined) { + await browser.close(); + } + await staticServer.close(); + } +} + +const reportPath = parseArguments(process.argv.slice(2)); + +try { + runPackageManager(["run", "build"], repository); + const packOutput = JSON.parse( + runPackageManager( + [ + "pack", + "--json", + "--pack-destination", + temporaryDirectory, + ], + repository, + true, + ), + ); + const manifest = Array.isArray(packOutput) ? packOutput[0] : packOutput; + if (!manifest || typeof manifest.filename !== "string") { + throw new Error("pnpm pack did not report an archive"); + } + const archive = join(temporaryDirectory, basename(manifest.filename)); + const fixtureDirectory = join(temporaryDirectory, "fixture"); + cpSync(fixtureSource, fixtureDirectory, { recursive: true }); + runPackageManager( + ["install", "--frozen-lockfile", "--ignore-scripts"], + fixtureDirectory, + ); + + const packageDirectory = join( + fixtureDirectory, + "node_modules", + "@marimo-team", + "codemirror-sql", + ); + mkdirSync(packageDirectory, { recursive: true }); + run( + "tar", + ["-xzf", archive, "-C", packageDirectory, "--strip-components=1"], + fixtureDirectory, + ); + const packedPackage = JSON.parse( + readFileSync(join(packageDirectory, "package.json"), "utf8"), + ); + if ( + packedPackage.name !== "@marimo-team/codemirror-sql" || + packedPackage.version !== manifest.version + ) { + throw new Error("Extracted package did not match the pnpm pack manifest"); + } + verifySsrImport(fixtureDirectory); + + runPackageManager(["run", "build:core"], fixtureDirectory); + runPackageManager(["run", "build:workers"], fixtureDirectory); + const coreDirectory = join(fixtureDirectory, "core-dist"); + const workersDirectory = join(fixtureDirectory, "workers-dist"); + const coreModuleCount = verifyCoreExcludesParser(coreDirectory); + const chromiumResult = await runChromium( + fixtureDirectory, + workersDirectory, + ); + const workerBundles = verifyWorkerAssets(workersDirectory); + if ( + chromiumResult.timings.lazyResources.beforeCreation.length !== 0 + ) { + throw new Error("Browser reported eager dialect asset loading"); + } + const report = { + bundles: { + core: bundleReport(coreDirectory), + workers: workerBundles, + }, + evidence: { + coreModuleCount, + coreParserModules: 0, + exactTarballSsrImport: true, + parserMarkerCount: PARSER_MARKERS.length, + strictSameOriginCsp: true, + }, + package: { + archive: basename(archive), + archiveSha256: sha256(archive), + name: packedPackage.name, + version: packedPackage.version, + }, + runtime: { + chromium: chromiumResult.browserVersion, + node: process.version, + platform: `${process.platform}-${process.arch}`, + }, + schemaVersion: 1, + timingsMs: chromiumResult.timings, + }; + const serializedReport = `${JSON.stringify(report, null, 2)}\n`; + process.stdout.write(serializedReport); + if (reportPath !== undefined) { + mkdirSync(dirname(reportPath), { recursive: true }); + writeFileSync(reportPath, serializedReport); + } +} finally { + if ( + basename(temporaryDirectory).startsWith( + "codemirror-sql-worker-placement-", + ) + ) { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md new file mode 100644 index 0000000..e333904 --- /dev/null +++ b/test/worker-placement/README.md @@ -0,0 +1,19 @@ +# Worker placement evidence fixture + +This fixture is copied into an isolated temporary directory and consumes the +exact tarball created by `pnpm pack`. It is intentionally not a workspace +package. + +The initial minified Vite 8 baseline was 66,211 gzip bytes for PostgreSQL, +49,492 gzip bytes for BigQuery, and 123,798 gzip/567,271 raw bytes for the +complete worker application. The fail-closed ceilings include small explicit +headroom over that measured packed-consumer baseline: + +- PostgreSQL named assets: 68 KiB gzip +- BigQuery named assets: 50 KiB gzip +- Complete worker application: 124 KiB gzip and 590 KiB raw + +These are provisional placement limits, not product bundle promises. The +orchestration script fails closed when they are exceeded, when the dialects no +longer have separate named assets, or when the core-only graph imports parser +modules. diff --git a/test/worker-placement/core.html b/test/worker-placement/core.html new file mode 100644 index 0000000..09c7af8 --- /dev/null +++ b/test/worker-placement/core.html @@ -0,0 +1,12 @@ + + + + + + codemirror-sql core-only placement fixture + + +
pending
+ + + diff --git a/test/worker-placement/package.json b/test/worker-placement/package.json new file mode 100644 index 0000000..ac7fe1c --- /dev/null +++ b/test/worker-placement/package.json @@ -0,0 +1,15 @@ +{ + "name": "codemirror-sql-worker-placement-fixture", + "private": true, + "type": "module", + "packageManager": "pnpm@11.4.0", + "scripts": { + "build:core": "vite build --config vite.core.config.mjs", + "build:workers": "vite build --config vite.workers.config.mjs" + }, + "dependencies": { + "node-sql-parser": "5.4.0", + "playwright": "1.61.1", + "vite": "8.0.13" + } +} diff --git a/test/worker-placement/pnpm-lock.yaml b/test/worker-placement/pnpm-lock.yaml new file mode 100644 index 0000000..465fb95 --- /dev/null +++ b/test/worker-placement/pnpm-lock.yaml @@ -0,0 +1,547 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + node-sql-parser: + specifier: 5.4.0 + version: 5.4.0 + playwright: + specifier: 1.61.1 + version: 1.61.1 + vite: + specifier: 8.0.13 + version: 8.0.13 + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/pegjs@0.10.6': + resolution: {integrity: sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw==} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-sql-parser@5.4.0: + resolution: {integrity: sha512-jVe6Z61gPcPjCElPZ6j8llB3wnqGcuQzefim1ERsqIakxnEy5JlzV7XKdO1KmacRG5TKwPc4vJTgSRQ0LfkbFw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.130.0': {} + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/pegjs@0.10.6': {} + + big-integer@1.6.52: {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.16: {} + + node-sql-parser@5.4.0: + dependencies: + '@types/pegjs': 0.10.6 + big-integer: 1.6.52 + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.23: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: + optional: true + + vite@8.0.13: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.23 + rolldown: 1.0.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/test/worker-placement/src/bigquery-worker.js b/test/worker-placement/src/bigquery-worker.js new file mode 100644 index 0000000..df95a28 --- /dev/null +++ b/test/worker-placement/src/bigquery-worker.js @@ -0,0 +1,5 @@ +import { installParserWorker } from "./parser-worker.js"; + +installParserWorker( + async () => await import("node-sql-parser/build/bigquery.js"), +); diff --git a/test/worker-placement/src/core.js b/test/worker-placement/src/core.js new file mode 100644 index 0000000..1e77877 --- /dev/null +++ b/test/worker-placement/src/core.js @@ -0,0 +1,20 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql/vnext"; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], +}); +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "SELECT 1", +}); + +if (!session.isCurrent(session.revision)) { + throw new Error("The packed core failed its revision identity check"); +} + +service.dispose(); +document.body.dataset.status = "passed"; +document.querySelector("#result").textContent = "core-only import passed"; diff --git a/test/worker-placement/src/parser-worker.js b/test/worker-placement/src/parser-worker.js new file mode 100644 index 0000000..43d5cec --- /dev/null +++ b/test/worker-placement/src/parser-worker.js @@ -0,0 +1,166 @@ +const GLOBAL_KEYS = ["NodeSQLParser", "global"]; +const MAX_STATEMENT_LENGTH = 16 * 1024; +const PARSER_OPTIONS = Object.freeze({ + parseOptions: Object.freeze({ + includeLocations: true, + }), + trimQuery: false, +}); + +function readOwnDataProperty(value, key) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + return { kind: "missing" }; + } + if (!("value" in descriptor)) { + return { kind: "invalid" }; + } + return { kind: "value", value: descriptor.value }; +} + +function moduleCandidates(moduleValue) { + if ( + (typeof moduleValue !== "object" || moduleValue === null) && + typeof moduleValue !== "function" + ) { + return [moduleValue]; + } + const candidates = [moduleValue]; + for (const key of ["default", "module.exports"]) { + const property = readOwnDataProperty(moduleValue, key); + if (property.kind === "value") { + candidates.push(property.value); + } + } + return candidates; +} + +function findParserConstructor(moduleValue) { + for (const candidate of moduleCandidates(moduleValue)) { + if (typeof candidate === "function") { + return candidate; + } + if (typeof candidate !== "object" || candidate === null) { + continue; + } + const parser = readOwnDataProperty(candidate, "Parser"); + if (parser.kind === "value" && typeof parser.value === "function") { + return parser.value; + } + } + throw new Error("The dialect bundle did not expose a Parser constructor"); +} + +function snapshotGlobals() { + return GLOBAL_KEYS.map((key) => ({ + descriptor: Object.getOwnPropertyDescriptor(globalThis, key), + key, + })); +} + +function restoreGlobals(snapshots) { + for (const { descriptor, key } of snapshots) { + if (descriptor === undefined) { + if (!Reflect.deleteProperty(globalThis, key)) { + throw new Error(`Could not remove worker global ${key}`); + } + } else { + Object.defineProperty(globalThis, key, descriptor); + } + } +} + +function assertDedicatedWorkerRealm() { + if ( + globalThis.self !== globalThis || + "window" in globalThis || + "document" in globalThis + ) { + throw new Error("Parser fixture did not start in a dedicated worker"); + } +} + +export function installParserWorker(loadModule) { + assertDedicatedWorkerRealm(); + let parserPromise; + + async function getParser() { + if (parserPromise === undefined) { + parserPromise = (async () => { + const snapshots = snapshotGlobals(); + let moduleValue; + let loadError; + try { + moduleValue = await loadModule(); + } catch (error) { + loadError = error; + } + restoreGlobals(snapshots); + if (loadError !== undefined) { + throw loadError; + } + const Parser = findParserConstructor(moduleValue); + const parser = Reflect.construct(Parser, []); + if ( + typeof parser !== "object" || + parser === null || + typeof parser.astify !== "function" + ) { + throw new Error("The dialect Parser did not expose astify"); + } + return parser; + })(); + } + return await parserPromise; + } + + globalThis.addEventListener("message", async (event) => { + const request = event.data; + if ( + typeof request !== "object" || + request === null || + !Number.isSafeInteger(request.id) || + request.id < 0 || + typeof request.text !== "string" || + request.text.length > MAX_STATEMENT_LENGTH + ) { + globalThis.postMessage({ + error: "invalid-request", + id: + typeof request === "object" && + request !== null && + Number.isSafeInteger(request.id) + ? request.id + : -1, + status: "failed", + }); + return; + } + + const startedAt = performance.now(); + try { + const parser = await getParser(); + const output = parser.astify(request.text, PARSER_OPTIONS); + const root = Array.isArray(output) ? output[0] : output; + if ( + typeof root !== "object" || + root === null || + typeof root.type !== "string" + ) { + throw new Error("The dialect parser returned no typed AST root"); + } + globalThis.postMessage({ + astType: root.type, + id: request.id, + parseMs: performance.now() - startedAt, + status: "parsed", + }); + } catch { + globalThis.postMessage({ + error: "parse-failed", + id: request.id, + status: "failed", + }); + } + }); +} diff --git a/test/worker-placement/src/postgresql-worker.js b/test/worker-placement/src/postgresql-worker.js new file mode 100644 index 0000000..539ab77 --- /dev/null +++ b/test/worker-placement/src/postgresql-worker.js @@ -0,0 +1,5 @@ +import { installParserWorker } from "./parser-worker.js"; + +installParserWorker( + async () => await import("node-sql-parser/build/postgresql.js"), +); diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js new file mode 100644 index 0000000..e03ae96 --- /dev/null +++ b/test/worker-placement/src/workers.js @@ -0,0 +1,162 @@ +import { + createSqlLanguageService, + duckdbDialect, +} from "@marimo-team/codemirror-sql/vnext"; + +const REQUEST_TIMEOUT_MS = 10_000; + +function request(worker, id, text) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Worker request ${id} timed out`)); + }, REQUEST_TIMEOUT_MS); + const onError = (event) => { + clearTimeout(timeout); + reject(new Error(event.message || `Worker request ${id} failed`)); + }; + const onMessage = (event) => { + if (event.data?.id !== id) { + return; + } + clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + if (event.data.status !== "parsed") { + reject(new Error(`Worker request ${id} did not parse`)); + return; + } + resolve(event.data); + }; + worker.addEventListener("error", onError, { once: true }); + worker.addEventListener("message", onMessage); + worker.postMessage({ id, text }); + }); +} + +async function measureWorker(url, text, expectedType) { + const startedAt = performance.now(); + const worker = url(); + try { + const cold = await request(worker, 1, text); + const coldMs = performance.now() - startedAt; + const warmStartedAt = performance.now(); + const warm = await request(worker, 2, text); + const warmRoundTripMs = performance.now() - warmStartedAt; + if (cold.astType !== expectedType || warm.astType !== expectedType) { + throw new Error( + `Expected ${expectedType}, received ${cold.astType}/${warm.astType}`, + ); + } + return { + coldMs, + coldParseMs: cold.parseMs, + warmParseMs: warm.parseMs, + warmRoundTripMs, + }; + } finally { + worker.terminate(); + } +} + +function createPostgresqlWorker() { + return new Worker( + new URL("./postgresql-worker.js", import.meta.url), + { + name: "codemirror-sql-postgresql-placement", + type: "module", + }, + ); +} + +function createBigQueryWorker() { + return new Worker( + new URL("./bigquery-worker.js", import.meta.url), + { + name: "codemirror-sql-bigquery-placement", + type: "module", + }, + ); +} + +function dialectResourceNames() { + return performance + .getEntriesByType("resource") + .map((entry) => entry.name) + .filter((name) => /(?:bigquery|postgresql)/i.test(name)); +} + +async function run() { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + service.dispose(); + + const sentinel = Object.freeze({ owner: "browser-main-thread" }); + const original = Object.getOwnPropertyDescriptor( + globalThis, + "NodeSQLParser", + ); + Object.defineProperty(globalThis, "NodeSQLParser", { + configurable: true, + value: sentinel, + }); + + try { + const beforeCreation = dialectResourceNames(); + if (beforeCreation.length !== 0) { + throw new Error( + `Dialect assets loaded before worker creation: ${beforeCreation.join(", ")}`, + ); + } + const postgresql = await measureWorker( + createPostgresqlWorker, + "SELECT 1 AS value", + "select", + ); + const afterPostgresql = dialectResourceNames(); + if ( + !afterPostgresql.some((name) => /postgresql/i.test(name)) || + afterPostgresql.some((name) => /bigquery/i.test(name)) + ) { + throw new Error( + "PostgreSQL creation did not load only PostgreSQL assets", + ); + } + const bigquery = await measureWorker( + createBigQueryWorker, + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + "select", + ); + const afterBigQuery = dialectResourceNames(); + if (!afterBigQuery.some((name) => /bigquery/i.test(name))) { + throw new Error("BigQuery creation did not load BigQuery assets"); + } + if (globalThis.NodeSQLParser !== sentinel) { + throw new Error("A parser bundle changed the browser main global"); + } + const report = Object.freeze({ + bigquery, + lazyResources: { + afterBigQuery, + afterPostgresql, + beforeCreation, + }, + postgresql, + }); + globalThis.__CODEMIRROR_SQL_WORKER_PLACEMENT__ = report; + document.body.dataset.status = "passed"; + document.querySelector("#result").textContent = JSON.stringify(report); + } finally { + if (original === undefined) { + Reflect.deleteProperty(globalThis, "NodeSQLParser"); + } else { + Object.defineProperty(globalThis, "NodeSQLParser", original); + } + } +} + +run().catch((error) => { + document.body.dataset.status = "failed"; + document.querySelector("#result").textContent = + error instanceof Error ? error.message : "unknown worker fixture failure"; +}); diff --git a/test/worker-placement/vite.core.config.mjs b/test/worker-placement/vite.core.config.mjs new file mode 100644 index 0000000..af2ee87 --- /dev/null +++ b/test/worker-placement/vite.core.config.mjs @@ -0,0 +1,36 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +function moduleTrace() { + return { + name: "worker-placement-core-module-trace", + generateBundle(_options, bundle) { + const moduleIds = new Set(); + for (const output of Object.values(bundle)) { + if (output.type === "chunk") { + for (const moduleId of Object.keys(output.modules)) { + moduleIds.add(moduleId.replaceAll("\\", "/")); + } + } + } + this.emitFile({ + fileName: "module-ids.json", + source: `${JSON.stringify([...moduleIds].sort(), null, 2)}\n`, + type: "asset", + }); + }, + }; +} + +export default defineConfig({ + base: "/", + build: { + emptyOutDir: true, + manifest: true, + outDir: "core-dist", + rollupOptions: { + input: resolve(import.meta.dirname, "core.html"), + }, + }, + plugins: [moduleTrace()], +}); diff --git a/test/worker-placement/vite.workers.config.mjs b/test/worker-placement/vite.workers.config.mjs new file mode 100644 index 0000000..99eaac2 --- /dev/null +++ b/test/worker-placement/vite.workers.config.mjs @@ -0,0 +1,17 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + base: "/", + build: { + emptyOutDir: true, + manifest: true, + outDir: "workers-dist", + rollupOptions: { + input: resolve(import.meta.dirname, "workers.html"), + }, + }, + worker: { + format: "es", + }, +}); diff --git a/test/worker-placement/workers.html b/test/worker-placement/workers.html new file mode 100644 index 0000000..7200e23 --- /dev/null +++ b/test/worker-placement/workers.html @@ -0,0 +1,12 @@ + + + + + + codemirror-sql worker placement fixture + + +
pending
+ + + From 650a733925704804746dd720bf0fdba8550f55df Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:13:22 +0800 Subject: [PATCH 2/6] docs: normalize ADR metadata spacing --- docs/adr/0004-isolated-parser-execution.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index c8c400d..b037d5a 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -1,6 +1,7 @@ # ADR 0004: Isolated Browser Parser Execution -Status: accepted for implementation, session wiring gated by evidence +Status: accepted for implementation, session wiring gated by evidence + Date: 2026-07-25 ## Context From 3065ed1813942cf78cc51bf8fdf3d6beefaa320a Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:26:14 +0800 Subject: [PATCH 3/6] test(vnext): harden worker placement evidence --- docs/adr/0004-isolated-parser-execution.md | 61 ++- scripts/worker-placement.mjs | 366 ++++++++++++++++-- test/worker-placement/README.md | 26 +- test/worker-placement/src/bigquery-worker.js | 5 - .../src/parser-worker-entry.js | 8 + test/worker-placement/src/parser-worker.js | 236 ++++++++--- .../worker-placement/src/postgresql-worker.js | 5 - test/worker-placement/src/workers.js | 247 ++++++++---- test/worker-placement/vite.workers.config.mjs | 45 +++ 9 files changed, 806 insertions(+), 193 deletions(-) delete mode 100644 test/worker-placement/src/bigquery-worker.js create mode 100644 test/worker-placement/src/parser-worker-entry.js delete mode 100644 test/worker-placement/src/postgresql-worker.js diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index b037d5a..11b8e4e 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -71,6 +71,13 @@ wall-clock deadline, worker crash, malformed protocol, or service disposal terminates the generation. The placement benchmark must compare drain versus restart under rapid edits before the executor policy is frozen. +The execution deadline belongs to the posted worker job, not to any attached +consumer. Consumer cancellation never clears it. A draining operation retains +the active lane until it returns or its generation is terminated; queued work +has a separate wait deadline. Service disposal always terminates immediately. +These rules prevent a cancelled hostile parse from occupying the only worker +indefinitely. + The safety deadline is separate from product latency targets. A deadline failure never upgrades parser authority and an active request is not automatically replayed after a crash or timeout. @@ -182,7 +189,8 @@ run in CI. Source-workspace success is not packaging evidence. ## Evidence required before session wiring -A production-shaped fixture built from the exact `npm pack` archive must prove: +A production-shaped fixture built alongside the exact `npm pack` archive must +prove: - Core-only import emits no parser or worker bytes. - PostgreSQL and BigQuery emit separate worker chunks. @@ -216,37 +224,50 @@ Safety timeouts are not evidence that these product targets are met. ### Initial packed-consumer baseline The placement harness introduced with this decision builds the exact packed -archive, consumes it from an isolated Vite 8 fixture, serves the production -output with a same-origin CSP, and runs it in Chromium. Its first local -Node 24 / Chromium 149 / arm64 macOS sample recorded: +archive, verifies its core import in an isolated fixture, and separately uses +fixture-owned worker code with the pinned `node-sql-parser` dependency to prove +consumer-side Vite 8 placement feasibility. It serves the production output +with a same-origin CSP and runs it in Chromium. + +The worker portion does not yet prove a packed optional parser integration; +that entry does not exist. The protocol PR must move the worker implementation +behind the packed package boundary and remove the fixture's direct parser +dependency before making a public packaging claim. + +The latest local Node 24 / Chromium 149 / arm64 macOS sample recorded: | Output | Raw | gzip | | --- | ---: | ---: | -| Core-only fixture | 24,462 B | 7,477 B | -| PostgreSQL grammar plus worker entry | 318,628 B | 66,211 B | -| BigQuery grammar plus worker entry | 222,769 B | 49,492 B | -| Complete worker fixture | 567,271 B | 123,798 B | +| Core-only fixture | 24,462 B | 7,475 B | +| PostgreSQL transitive worker graph | 320,495 B | 67,214 B | +| BigQuery transitive worker graph | 224,648 B | 50,205 B | +| Complete worker fixture | 549,003 B | 117,941 B | The core module trace contained no `node-sql-parser` module. No dialect -resource loaded before explicit construction. PostgreSQL and BigQuery were -emitted as separate assets and both parsed successfully without changing the -main-window parser sentinel. +resource loaded before explicit construction. A single static module worker +loaded PostgreSQL and then BigQuery through separate literal lazy imports; +both parsed successfully without changing the main-window parser sentinel. +The per-dialect figures conservatively include their complete static +transitive closures, with shared chunks also reported separately. + +One sequential cold/warm run on the shared worker measured: -Two consecutive cold/warm runs measured: +| Dialect | Grammar load and initialization | First parse | First round trip | Warm parse | Warm round trip | +| --- | ---: | ---: | ---: | ---: | ---: | +| PostgreSQL | 8.7 ms | 2.6 ms | 11.5 ms | 0.2 ms | 0.3 ms | +| BigQuery | 4.3 ms | 2.3 ms | 6.6 ms | 0.4 ms | 0.5 ms | -| Dialect | Cold request range | Warm parse | Warm round trip | -| --- | ---: | ---: | ---: | -| PostgreSQL | 17.0โ€“32.6 ms | 0.2 ms | 0.2โ€“0.3 ms | -| BigQuery | 10.1โ€“10.8 ms | 0.3 ms | 0.3 ms | +The worker ready handshake took 7.0 ms in that run. These numbers establish packaging feasibility and initial size guards. They are not percentile claims. Stable latency decisions require repeated, cross-platform samples over the representative and adversarial corpus. -The checked-in harness fails above 68 KiB gzip for the PostgreSQL assets, -50 KiB for BigQuery, or 124 KiB / 590 KiB for the complete worker fixture in -gzip/raw form. These ceilings include small measurement headroom and are -placement-spike guards, not the final optional-integration bundle budget. +The checked-in harness fails above 68 KiB gzip for the PostgreSQL transitive +graph, 50 KiB for the BigQuery transitive graph, or 120 KiB / 570 KiB for the +complete worker fixture in gzip/raw form. These ceilings include small +measurement headroom and are placement-spike guards, not the final +optional-integration bundle budget. ## Implementation sequence diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 6e8fb6f..2f6fd94 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -36,8 +36,8 @@ const PARSER_MARKERS = [ ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 124 * 1024; -const WORKER_TOTAL_RAW_LIMIT = 590 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 120 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 570 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], @@ -46,9 +46,6 @@ const MIME_TYPES = new Map([ ]); const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixtureSource = join(repository, "test", "worker-placement"); -const temporaryDirectory = mkdtempSync( - join(tmpdir(), "codemirror-sql-worker-placement-"), -); const packageManagerExecutable = process.env.npm_execpath; function parseArguments(arguments_) { @@ -137,45 +134,258 @@ function bundleReport(directory) { }; } +function requireModuleTrace(path) { + const trace = JSON.parse(readFileSync(path, "utf8")); + if ( + typeof trace !== "object" || + trace === null || + !Array.isArray(trace.chunks) + ) { + throw new Error(`${basename(path)} did not contain a chunk trace`); + } + return trace; +} + +function chunkMap(trace, workersDirectory) { + const chunks = new Map(); + for (const chunk of trace.chunks) { + if ( + typeof chunk !== "object" || + chunk === null || + typeof chunk.fileName !== "string" || + !Array.isArray(chunk.imports) || + !Array.isArray(chunk.dynamicImports) || + !Array.isArray(chunk.moduleIds) || + chunks.has(chunk.fileName) || + !existsSync(join(workersDirectory, chunk.fileName)) + ) { + throw new Error("Worker module trace contained an invalid chunk"); + } + chunks.set(chunk.fileName, chunk); + } + return chunks; +} + +function reachableChunks(chunks, roots, includeDynamicImports) { + const reachable = new Set(); + const pending = [...roots]; + while (pending.length > 0) { + const fileName = pending.pop(); + if (fileName === undefined || reachable.has(fileName)) { + continue; + } + const chunk = chunks.get(fileName); + if (chunk === undefined) { + throw new Error(`Chunk trace referenced missing chunk ${fileName}`); + } + reachable.add(fileName); + pending.push(...chunk.imports); + if (includeDynamicImports) { + pending.push(...chunk.dynamicImports); + } + } + return reachable; +} + +function metricsForFiles(report, fileNames) { + const filesByName = new Map( + report.files.map((file) => [file.file, file]), + ); + const files = [...fileNames].sort().map((fileName) => { + const file = filesByName.get(fileName); + if (file === undefined) { + throw new Error(`Bundle report omitted traced file ${fileName}`); + } + return file; + }); + return { + files: files.map((file) => file.file), + gzipBytes: files.reduce( + (total, file) => total + file.gzipBytes, + 0, + ), + rawBytes: files.reduce( + (total, file) => total + file.rawBytes, + 0, + ), + }; +} + function verifyWorkerAssets(workersDirectory) { const report = bundleReport(workersDirectory); - const javascriptFiles = report.files.filter((file) => - file.file.endsWith(".js"), + const pageTrace = requireModuleTrace( + join(workersDirectory, "page-module-trace.json"), + ); + const workerTrace = requireModuleTrace( + join(workersDirectory, "worker-module-trace.json"), + ); + const pageChunks = chunkMap(pageTrace, workersDirectory); + const workerChunks = chunkMap(workerTrace, workersDirectory); + const pageEntry = pageTrace.chunks.find( + (chunk) => chunk.isEntry === true, + ); + const workerEntry = workerTrace.chunks.find( + (chunk) => + chunk.isEntry === true && + chunk.facadeModuleId?.endsWith( + "src/parser-worker-entry.js", + ), + ); + if (pageEntry === undefined || workerEntry === undefined) { + throw new Error("Build traces omitted a page or parser worker entry"); + } + + const manifest = JSON.parse( + readFileSync( + join(workersDirectory, ".vite", "manifest.json"), + "utf8", + ), + ); + const manifestEntry = Object.values(manifest).find( + (entry) => entry?.isEntry === true, + ); + if ( + manifestEntry === undefined || + manifestEntry.file !== pageEntry.fileName + ) { + throw new Error("Vite manifest did not identify the traced page entry"); + } + const pageReachable = reachableChunks( + pageChunks, + [pageEntry.fileName], + true, + ); + const pageModuleIds = [...pageReachable].flatMap( + (fileName) => pageChunks.get(fileName)?.moduleIds ?? [], + ); + if ( + pageModuleIds.some((moduleId) => + moduleId.includes("/node-sql-parser/"), + ) + ) { + throw new Error("Page entry graph included a parser grammar"); + } + const pageSource = readFileSync( + join(workersDirectory, pageEntry.fileName), + "utf8", + ); + if (!pageSource.includes(basename(workerEntry.fileName))) { + throw new Error("Page entry did not reference the traced parser worker"); + } + + const allWorkerModuleIds = workerTrace.chunks.flatMap( + (chunk) => chunk.moduleIds, + ); + const nodeSqlParserModuleIds = allWorkerModuleIds.filter( + (moduleId) => moduleId.includes("/node-sql-parser/"), + ); + const postgresqlModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ), ); - const postgresqlFiles = javascriptFiles.filter((file) => - /postgresql/i.test(file.file), + const bigqueryModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + moduleId.endsWith("/node-sql-parser/build/bigquery.js"), ); - const bigqueryFiles = javascriptFiles.filter((file) => - /bigquery/i.test(file.file), + const unexpectedParserModuleIds = nodeSqlParserModuleIds.filter( + (moduleId) => + !moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ) && + !moduleId.endsWith( + "/node-sql-parser/build/bigquery.js", + ), ); - if (postgresqlFiles.length === 0 || bigqueryFiles.length === 0) { + if ( + postgresqlModuleIds.length === 0 || + bigqueryModuleIds.length === 0 || + unexpectedParserModuleIds.length > 0 + ) { throw new Error( - "Worker build did not emit separate dialect assets", + `Worker graph did not contain only the two exact deep builds: ${unexpectedParserModuleIds.join(", ")}`, ); } + + const postgresqlChunk = workerTrace.chunks.find((chunk) => + chunk.moduleIds.some((moduleId) => + moduleId.endsWith( + "/node-sql-parser/build/postgresql.js", + ), + ), + ); + const bigqueryChunk = workerTrace.chunks.find((chunk) => + chunk.moduleIds.some((moduleId) => + moduleId.endsWith("/node-sql-parser/build/bigquery.js"), + ), + ); + if ( + postgresqlChunk === undefined || + bigqueryChunk === undefined || + postgresqlChunk.fileName === bigqueryChunk.fileName + ) { + throw new Error("Dialect builds did not emit separate lazy chunks"); + } + const staticWorkerEntry = reachableChunks( + workerChunks, + [workerEntry.fileName], + false, + ); if ( - postgresqlFiles.some((postgresql) => - bigqueryFiles.some((bigquery) => bigquery.file === postgresql.file), - ) + staticWorkerEntry.has(postgresqlChunk.fileName) || + staticWorkerEntry.has(bigqueryChunk.fileName) ) { - throw new Error("PostgreSQL and BigQuery shared a dialect-named asset"); + throw new Error("Parser worker statically included a grammar chunk"); } - const postgresqlGzipBytes = postgresqlFiles.reduce( - (total, file) => total + file.gzipBytes, - 0, + const completeWorkerGraph = reachableChunks( + workerChunks, + [workerEntry.fileName], + true, ); - const bigqueryGzipBytes = bigqueryFiles.reduce( - (total, file) => total + file.gzipBytes, - 0, + if ( + !completeWorkerGraph.has(postgresqlChunk.fileName) || + !completeWorkerGraph.has(bigqueryChunk.fileName) + ) { + throw new Error("Parser worker could not reach both grammar chunks"); + } + + const postgresqlClosure = reachableChunks( + workerChunks, + [postgresqlChunk.fileName], + false, + ); + const bigqueryClosure = reachableChunks( + workerChunks, + [bigqueryChunk.fileName], + false, + ); + const sharedGrammarChunks = new Set( + [...postgresqlClosure].filter((fileName) => + bigqueryClosure.has(fileName), + ), ); - if (postgresqlGzipBytes > POSTGRESQL_GZIP_LIMIT) { + const postgresqlMetrics = metricsForFiles( + report, + postgresqlClosure, + ); + const bigqueryMetrics = metricsForFiles(report, bigqueryClosure); + const sharedMetrics = metricsForFiles( + report, + sharedGrammarChunks, + ); + const workerEntryMetrics = metricsForFiles( + report, + staticWorkerEntry, + ); + if (postgresqlMetrics.gzipBytes > POSTGRESQL_GZIP_LIMIT) { throw new Error( - `PostgreSQL assets exceeded ${POSTGRESQL_GZIP_LIMIT} gzip bytes: ${postgresqlGzipBytes}`, + `PostgreSQL graph exceeded ${POSTGRESQL_GZIP_LIMIT} gzip bytes: ${postgresqlMetrics.gzipBytes}`, ); } - if (bigqueryGzipBytes > BIGQUERY_GZIP_LIMIT) { + if (bigqueryMetrics.gzipBytes > BIGQUERY_GZIP_LIMIT) { throw new Error( - `BigQuery assets exceeded ${BIGQUERY_GZIP_LIMIT} gzip bytes: ${bigqueryGzipBytes}`, + `BigQuery graph exceeded ${BIGQUERY_GZIP_LIMIT} gzip bytes: ${bigqueryMetrics.gzipBytes}`, ); } if (report.gzipBytes > WORKER_TOTAL_GZIP_LIMIT) { @@ -192,16 +402,24 @@ function verifyWorkerAssets(workersDirectory) { ...report, dialects: { bigquery: { - files: bigqueryFiles.map((file) => file.file), - gzipBytes: bigqueryGzipBytes, + ...bigqueryMetrics, gzipLimit: BIGQUERY_GZIP_LIMIT, }, postgresql: { - files: postgresqlFiles.map((file) => file.file), - gzipBytes: postgresqlGzipBytes, + ...postgresqlMetrics, gzipLimit: POSTGRESQL_GZIP_LIMIT, }, }, + graph: { + allowedParserModuleIds: [ + "node-sql-parser/build/bigquery.js", + "node-sql-parser/build/postgresql.js", + ], + pageEntry: pageEntry.fileName, + parserWorkerEntry: workerEntry.fileName, + sharedGrammarChunks: sharedMetrics, + workerEntry: workerEntryMetrics, + }, limits: { gzipBytes: WORKER_TOTAL_GZIP_LIMIT, rawBytes: WORKER_TOTAL_RAW_LIMIT, @@ -226,11 +444,24 @@ function verifyCoreExcludesParser(coreDirectory) { `Core-only build included parser modules: ${parserModules.join(", ")}`, ); } + const workerModules = moduleIds.filter( + (moduleId) => + typeof moduleId === "string" && + /(?:^|[/\\])[^/\\]*worker[^/\\]*\.[cm]?[jt]s$/i.test(moduleId), + ); + if (workerModules.length > 0) { + throw new Error( + `Core-only build included worker modules: ${workerModules.join(", ")}`, + ); + } for (const path of listFiles(coreDirectory)) { const extension = extname(path); if (extension !== ".js" && extension !== ".json") { continue; } + if (/worker/i.test(basename(path))) { + throw new Error(`Core-only build emitted worker asset ${basename(path)}`); + } const contents = readFileSync(path, "utf8"); const marker = PARSER_MARKERS.find((candidate) => contents.includes(candidate), @@ -240,6 +471,11 @@ function verifyCoreExcludesParser(coreDirectory) { `Core-only output ${basename(path)} contained parser marker ${marker}`, ); } + if (extension === ".js" && /new\s+Worker\s*\(/.test(contents)) { + throw new Error( + `Core-only output ${basename(path)} contained a worker constructor`, + ); + } } return moduleIds.length; } @@ -338,6 +574,8 @@ async function runChromium(fixtureDirectory, workersDirectory) { const { chromium } = fixtureRequire("playwright"); const staticServer = await startStaticServer(workersDirectory); let browser; + let result; + let operationError; try { browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); @@ -382,25 +620,60 @@ async function runChromium(fixtureDirectory, workersDirectory) { if ( typeof timings !== "object" || timings === null || - typeof timings.postgresql?.coldMs !== "number" || - typeof timings.bigquery?.coldMs !== "number" + typeof timings.workerReadyMs !== "number" || + typeof timings.postgresql?.firstRequestRoundTripMs !== + "number" || + typeof timings.bigquery?.firstRequestRoundTripMs !== "number" ) { throw new Error("Browser fixture returned malformed timing data"); } - return { + result = { browserVersion: browser.version(), csp: CONTENT_SECURITY_POLICY, timings, }; - } finally { - if (browser !== undefined) { + } catch (error) { + operationError = error; + } + + const cleanupErrors = []; + if (browser !== undefined) { + try { await browser.close(); + } catch (error) { + cleanupErrors.push(error); } + } + try { await staticServer.close(); + } catch (error) { + cleanupErrors.push(error); + } + if (operationError !== undefined) { + if (cleanupErrors.length > 0) { + throw new AggregateError( + [operationError, ...cleanupErrors], + "Worker browser verification and cleanup failed", + ); + } + throw operationError; + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + cleanupErrors, + "Worker browser verification cleanup failed", + ); } + if (result === undefined) { + throw new Error("Worker browser verification produced no result"); + } + return result; } const reportPath = parseArguments(process.argv.slice(2)); +const temporaryDirectory = mkdtempSync( + join(tmpdir(), "codemirror-sql-worker-placement-"), +); try { runPackageManager(["run", "build"], repository); @@ -443,12 +716,24 @@ try { const packedPackage = JSON.parse( readFileSync(join(packageDirectory, "package.json"), "utf8"), ); + const fixturePackage = JSON.parse( + readFileSync(join(fixtureDirectory, "package.json"), "utf8"), + ); if ( packedPackage.name !== "@marimo-team/codemirror-sql" || - packedPackage.version !== manifest.version + packedPackage.version !== manifest.version || + packedPackage.dependencies?.["node-sql-parser"] !== "5.4.0" ) { throw new Error("Extracted package did not match the pnpm pack manifest"); } + if ( + fixturePackage.dependencies?.["node-sql-parser"] !== + packedPackage.dependencies["node-sql-parser"] + ) { + throw new Error( + "Fixture direct parser dependency did not match the packed transitive dependency", + ); + } verifySsrImport(fixtureDirectory); runPackageManager(["run", "build:core"], fixtureDirectory); @@ -462,9 +747,9 @@ try { ); const workerBundles = verifyWorkerAssets(workersDirectory); if ( - chromiumResult.timings.lazyResources.beforeCreation.length !== 0 + chromiumResult.timings.resources.mainBeforeCreation.length !== 0 ) { - throw new Error("Browser reported eager dialect asset loading"); + throw new Error("Browser reported eager parser worker loading"); } const report = { bundles: { @@ -475,6 +760,9 @@ try { coreModuleCount, coreParserModules: 0, exactTarballSsrImport: true, + exactNodeSqlParserDependency: "5.4.0", + fixtureDirectDependencyReason: + "The fixture installs dependencies before extracting the exact tarball", parserMarkerCount: PARSER_MARKERS.length, strictSameOriginCsp: true, }, diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index e333904..25a74d5 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -2,18 +2,24 @@ This fixture is copied into an isolated temporary directory and consumes the exact tarball created by `pnpm pack`. It is intentionally not a workspace -package. +package. Its direct `node-sql-parser` dependency is intentional: the frozen +fixture dependencies are installed before the exact tarball is extracted, and +the harness verifies that its exact `5.4.0` version matches the packed +package's dependency. -The initial minified Vite 8 baseline was 66,211 gzip bytes for PostgreSQL, -49,492 gzip bytes for BigQuery, and 123,798 gzip/567,271 raw bytes for the -complete worker application. The fail-closed ceilings include small explicit -headroom over that measured packed-consumer baseline: +The minified Vite 8 single-worker baseline is 67,214 gzip bytes for the +PostgreSQL transitive graph, 50,205 gzip bytes for the BigQuery transitive +graph, and 117,941 gzip/549,003 raw bytes for the complete worker build output. +The PostgreSQL and BigQuery figures each include their transitive shared +chunks; the report also identifies those shared chunks explicitly. The +fail-closed ceilings include small explicit headroom over that measured +packed-consumer baseline: -- PostgreSQL named assets: 68 KiB gzip -- BigQuery named assets: 50 KiB gzip -- Complete worker application: 124 KiB gzip and 590 KiB raw +- PostgreSQL transitive graph: 68 KiB gzip +- BigQuery transitive graph: 50 KiB gzip +- Complete worker build output: 120 KiB gzip and 570 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no -longer have separate named assets, or when the core-only graph imports parser -modules. +longer have separate lazy chunks, when their transitive reachability changes, +or when the page/core graphs import parser modules. diff --git a/test/worker-placement/src/bigquery-worker.js b/test/worker-placement/src/bigquery-worker.js deleted file mode 100644 index df95a28..0000000 --- a/test/worker-placement/src/bigquery-worker.js +++ /dev/null @@ -1,5 +0,0 @@ -import { installParserWorker } from "./parser-worker.js"; - -installParserWorker( - async () => await import("node-sql-parser/build/bigquery.js"), -); diff --git a/test/worker-placement/src/parser-worker-entry.js b/test/worker-placement/src/parser-worker-entry.js new file mode 100644 index 0000000..495b606 --- /dev/null +++ b/test/worker-placement/src/parser-worker-entry.js @@ -0,0 +1,8 @@ +import { installParserWorker } from "./parser-worker.js"; + +installParserWorker({ + bigquery: async () => + await import("node-sql-parser/build/bigquery.js"), + postgresql: async () => + await import("node-sql-parser/build/postgresql.js"), +}); diff --git a/test/worker-placement/src/parser-worker.js b/test/worker-placement/src/parser-worker.js index 43d5cec..79dc084 100644 --- a/test/worker-placement/src/parser-worker.js +++ b/test/worker-placement/src/parser-worker.js @@ -51,23 +51,84 @@ function findParserConstructor(moduleValue) { throw new Error("The dialect bundle did not expose a Parser constructor"); } -function snapshotGlobals() { +function snapshotGlobals(target) { return GLOBAL_KEYS.map((key) => ({ - descriptor: Object.getOwnPropertyDescriptor(globalThis, key), + descriptor: Object.getOwnPropertyDescriptor(target, key), key, })); } -function restoreGlobals(snapshots) { +function descriptorsEqual(left, right) { + if (left === undefined || right === undefined) { + return left === right; + } + if ( + left.configurable !== right.configurable || + left.enumerable !== right.enumerable + ) { + return false; + } + if ("value" in left || "value" in right) { + return ( + "value" in left && + "value" in right && + left.writable === right.writable && + Object.is(left.value, right.value) + ); + } + return ( + left.get === right.get && + left.set === right.set + ); +} + +function restoreGlobals(target, snapshots) { for (const { descriptor, key } of snapshots) { if (descriptor === undefined) { - if (!Reflect.deleteProperty(globalThis, key)) { + if (!Reflect.deleteProperty(target, key)) { throw new Error(`Could not remove worker global ${key}`); } } else { - Object.defineProperty(globalThis, key, descriptor); + Object.defineProperty(target, key, descriptor); } } + return Object.fromEntries( + snapshots.map(({ descriptor, key }) => [ + key, + descriptorsEqual( + descriptor, + Object.getOwnPropertyDescriptor(target, key), + ), + ]), + ); +} + +function createGuardedModuleLoader(target) { + let poisoned = false; + return async (loadModule) => { + if (poisoned) { + throw new Error("The guarded module loader is poisoned"); + } + const snapshots = snapshotGlobals(target); + let moduleValue; + let loadError; + try { + moduleValue = await loadModule(); + } catch (error) { + loadError = error; + } + let descriptorEquality; + try { + descriptorEquality = restoreGlobals(target, snapshots); + } catch (error) { + poisoned = true; + throw error; + } + if (loadError !== undefined) { + throw loadError; + } + return { descriptorEquality, moduleValue }; + }; } function assertDedicatedWorkerRealm() { @@ -80,38 +141,86 @@ function assertDedicatedWorkerRealm() { } } -export function installParserWorker(loadModule) { +function resourceEntries() { + return performance.getEntriesByType("resource").map((entry) => ({ + decodedBodySize: entry.decodedBodySize, + encodedBodySize: entry.encodedBodySize, + initiatorType: entry.initiatorType, + name: entry.name, + transferSize: entry.transferSize, + })); +} + +async function syntheticCleanupPoisonEvidence() { + const target = {}; + const load = createGuardedModuleLoader(target); + let evaluations = 0; + let cleanupFailed = false; + try { + await load(async () => { + evaluations += 1; + Object.defineProperty(target, "NodeSQLParser", { + configurable: false, + value: "synthetic-pollution", + }); + return {}; + }); + } catch { + cleanupFailed = true; + } + let poisonedRetryFailed = false; + try { + await load(async () => { + evaluations += 1; + return {}; + }); + } catch { + poisonedRetryFailed = true; + } + return { + cleanupFailed, + evaluations, + poisonedRetryFailed, + }; +} + +export function installParserWorker(moduleLoaders) { assertDedicatedWorkerRealm(); - let parserPromise; + const guardedLoad = createGuardedModuleLoader(globalThis); + const parsers = new Map(); - async function getParser() { - if (parserPromise === undefined) { - parserPromise = (async () => { - const snapshots = snapshotGlobals(); - let moduleValue; - let loadError; - try { - moduleValue = await loadModule(); - } catch (error) { - loadError = error; - } - restoreGlobals(snapshots); - if (loadError !== undefined) { - throw loadError; - } - const Parser = findParserConstructor(moduleValue); - const parser = Reflect.construct(Parser, []); - if ( - typeof parser !== "object" || - parser === null || - typeof parser.astify !== "function" - ) { - throw new Error("The dialect Parser did not expose astify"); - } - return parser; - })(); + async function getParser(grammar) { + const cached = parsers.get(grammar); + if (cached !== undefined) { + return { + cached: true, + grammarLoadAndInitMs: 0, + ...cached, + }; + } + const startedAt = performance.now(); + const loadModule = moduleLoaders[grammar]; + const { descriptorEquality, moduleValue } = + await guardedLoad(loadModule); + const Parser = findParserConstructor(moduleValue); + const parser = Reflect.construct(Parser, []); + if ( + typeof parser !== "object" || + parser === null || + typeof parser.astify !== "function" + ) { + throw new Error("The dialect Parser did not expose astify"); } - return await parserPromise; + const initialized = { + descriptorEquality, + parser, + }; + parsers.set(grammar, initialized); + return { + cached: false, + grammarLoadAndInitMs: performance.now() - startedAt, + ...initialized, + }; } globalThis.addEventListener("message", async (event) => { @@ -120,27 +229,51 @@ export function installParserWorker(loadModule) { typeof request !== "object" || request === null || !Number.isSafeInteger(request.id) || - request.id < 0 || + request.id < 0 + ) { + globalThis.postMessage({ + error: "invalid-request", + id: -1, + kind: "result", + resources: resourceEntries(), + status: "failed", + }); + return; + } + if (request.kind === "test-cleanup-poison") { + globalThis.postMessage({ + evidence: await syntheticCleanupPoisonEvidence(), + id: request.id, + kind: "cleanup-poison-result", + resources: resourceEntries(), + }); + return; + } + if ( + request.kind !== "parse" || + (request.grammar !== "postgresql" && + request.grammar !== "bigquery") || typeof request.text !== "string" || request.text.length > MAX_STATEMENT_LENGTH ) { globalThis.postMessage({ error: "invalid-request", - id: - typeof request === "object" && - request !== null && - Number.isSafeInteger(request.id) - ? request.id - : -1, + id: request.id, + kind: "result", + resources: resourceEntries(), status: "failed", }); return; } - const startedAt = performance.now(); try { - const parser = await getParser(); - const output = parser.astify(request.text, PARSER_OPTIONS); + const loaded = await getParser(request.grammar); + const astifyStartedAt = performance.now(); + const output = loaded.parser.astify( + request.text, + PARSER_OPTIONS, + ); + const astifyMs = performance.now() - astifyStartedAt; const root = Array.isArray(output) ? output[0] : output; if ( typeof root !== "object" || @@ -151,16 +284,29 @@ export function installParserWorker(loadModule) { } globalThis.postMessage({ astType: root.type, + astifyMs, + descriptorEquality: loaded.descriptorEquality, + grammar: request.grammar, + grammarLoadAndInitMs: loaded.grammarLoadAndInitMs, id: request.id, - parseMs: performance.now() - startedAt, + kind: "result", + moduleCached: loaded.cached, + resources: resourceEntries(), status: "parsed", }); } catch { globalThis.postMessage({ error: "parse-failed", id: request.id, + kind: "result", + resources: resourceEntries(), status: "failed", }); } }); + + globalThis.postMessage({ + kind: "ready", + resources: resourceEntries(), + }); } diff --git a/test/worker-placement/src/postgresql-worker.js b/test/worker-placement/src/postgresql-worker.js deleted file mode 100644 index 539ab77..0000000 --- a/test/worker-placement/src/postgresql-worker.js +++ /dev/null @@ -1,5 +0,0 @@ -import { installParserWorker } from "./parser-worker.js"; - -installParserWorker( - async () => await import("node-sql-parser/build/postgresql.js"), -); diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js index e03ae96..abbea57 100644 --- a/test/worker-placement/src/workers.js +++ b/test/worker-placement/src/workers.js @@ -5,84 +5,137 @@ import { const REQUEST_TIMEOUT_MS = 10_000; -function request(worker, id, text) { +function request(worker, requestValue) { return new Promise((resolve, reject) => { + const startedAt = performance.now(); const timeout = setTimeout(() => { - reject(new Error(`Worker request ${id} timed out`)); + reject(new Error(`Worker request ${requestValue.id} timed out`)); }, REQUEST_TIMEOUT_MS); const onError = (event) => { clearTimeout(timeout); - reject(new Error(event.message || `Worker request ${id} failed`)); + reject( + new Error( + event.message || + `Worker request ${requestValue.id} failed`, + ), + ); }; const onMessage = (event) => { - if (event.data?.id !== id) { + if (event.data?.id !== requestValue.id) { return; } clearTimeout(timeout); worker.removeEventListener("error", onError); worker.removeEventListener("message", onMessage); - if (event.data.status !== "parsed") { - reject(new Error(`Worker request ${id} did not parse`)); - return; - } - resolve(event.data); + resolve({ + response: event.data, + roundTripMs: performance.now() - startedAt, + }); }; worker.addEventListener("error", onError, { once: true }); worker.addEventListener("message", onMessage); - worker.postMessage({ id, text }); + worker.postMessage(requestValue); }); } -async function measureWorker(url, text, expectedType) { - const startedAt = performance.now(); - const worker = url(); - try { - const cold = await request(worker, 1, text); - const coldMs = performance.now() - startedAt; - const warmStartedAt = performance.now(); - const warm = await request(worker, 2, text); - const warmRoundTripMs = performance.now() - warmStartedAt; - if (cold.astType !== expectedType || warm.astType !== expectedType) { - throw new Error( - `Expected ${expectedType}, received ${cold.astType}/${warm.astType}`, +function waitForReady(worker) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Parser worker ready handshake timed out")); + }, REQUEST_TIMEOUT_MS); + const onError = (event) => { + clearTimeout(timeout); + reject( + new Error(event.message || "Parser worker startup failed"), ); - } - return { - coldMs, - coldParseMs: cold.parseMs, - warmParseMs: warm.parseMs, - warmRoundTripMs, }; - } finally { - worker.terminate(); - } -} - -function createPostgresqlWorker() { - return new Worker( - new URL("./postgresql-worker.js", import.meta.url), - { - name: "codemirror-sql-postgresql-placement", - type: "module", - }, - ); + const onMessage = (event) => { + if (event.data?.kind !== "ready") { + return; + } + clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + resolve(event.data); + }; + worker.addEventListener("error", onError, { once: true }); + worker.addEventListener("message", onMessage); + }); } -function createBigQueryWorker() { +function createParserWorker() { return new Worker( - new URL("./bigquery-worker.js", import.meta.url), + new URL("./parser-worker-entry.js", import.meta.url), { - name: "codemirror-sql-bigquery-placement", + name: "codemirror-sql-parser-placement", type: "module", }, ); } -function dialectResourceNames() { +function parserResourceNames() { return performance .getEntriesByType("resource") .map((entry) => entry.name) - .filter((name) => /(?:bigquery|postgresql)/i.test(name)); + .filter((name) => /parser-worker/i.test(name)); +} + +function requireParsed(result, expectedGrammar) { + if ( + result.response.status !== "parsed" || + result.response.kind !== "result" || + result.response.grammar !== expectedGrammar || + result.response.astType !== "select" + ) { + throw new Error(`${expectedGrammar} worker request did not parse`); + } + if ( + result.response.descriptorEquality.NodeSQLParser !== true || + result.response.descriptorEquality.global !== true + ) { + throw new Error( + `${expectedGrammar} worker globals were not restored exactly`, + ); + } +} + +async function measureGrammar(worker, grammar, text, firstId) { + const first = await request(worker, { + grammar, + id: firstId, + kind: "parse", + text, + }); + requireParsed(first, grammar); + if ( + first.response.moduleCached !== false || + first.response.grammarLoadAndInitMs < 0 + ) { + throw new Error(`${grammar} first request did not load its grammar`); + } + const warm = await request(worker, { + grammar, + id: firstId + 1, + kind: "parse", + text, + }); + requireParsed(warm, grammar); + if ( + warm.response.moduleCached !== true || + warm.response.grammarLoadAndInitMs !== 0 + ) { + throw new Error(`${grammar} warm request did not reuse its parser`); + } + return { + astifyMs: first.response.astifyMs, + firstRequestRoundTripMs: first.roundTripMs, + grammarLoadAndInitMs: + first.response.grammarLoadAndInitMs, + resourcesAfterFirstRequest: first.response.resources, + warmAstifyMs: warm.response.astifyMs, + warmRequestRoundTripMs: warm.roundTripMs, + warmResources: warm.response.resources, + }; } async function run() { @@ -101,52 +154,106 @@ async function run() { value: sentinel, }); + let worker; try { - const beforeCreation = dialectResourceNames(); - if (beforeCreation.length !== 0) { + const mainResourcesBeforeCreation = parserResourceNames(); + if (mainResourcesBeforeCreation.length !== 0) { throw new Error( - `Dialect assets loaded before worker creation: ${beforeCreation.join(", ")}`, + `Parser worker loaded before creation: ${mainResourcesBeforeCreation.join(", ")}`, ); } - const postgresql = await measureWorker( - createPostgresqlWorker, + const workerStartedAt = performance.now(); + worker = createParserWorker(); + const ready = await waitForReady(worker); + const workerReadyMs = performance.now() - workerStartedAt; + const mainResourcesAfterReady = parserResourceNames(); + if (mainResourcesAfterReady.length !== 1) { + throw new Error("Parser worker entry did not load exactly once"); + } + + const postgresql = await measureGrammar( + worker, + "postgresql", "SELECT 1 AS value", - "select", + 1, ); - const afterPostgresql = dialectResourceNames(); + const postgresqlResourceNames = + postgresql.resourcesAfterFirstRequest.map( + (resource) => resource.name, + ); if ( - !afterPostgresql.some((name) => /postgresql/i.test(name)) || - afterPostgresql.some((name) => /bigquery/i.test(name)) + !postgresqlResourceNames.some((name) => + /postgresql/i.test(name), + ) || + postgresqlResourceNames.some((name) => + /bigquery/i.test(name), + ) ) { throw new Error( - "PostgreSQL creation did not load only PostgreSQL assets", + "PostgreSQL request did not load only PostgreSQL resources", ); } - const bigquery = await measureWorker( - createBigQueryWorker, + + const bigquery = await measureGrammar( + worker, + "bigquery", "SELECT `project.dataset.table`.id FROM `project.dataset.table`", - "select", + 3, ); - const afterBigQuery = dialectResourceNames(); - if (!afterBigQuery.some((name) => /bigquery/i.test(name))) { - throw new Error("BigQuery creation did not load BigQuery assets"); + const bigQueryResourceNames = + bigquery.resourcesAfterFirstRequest.map( + (resource) => resource.name, + ); + if ( + !bigQueryResourceNames.some((name) => + /bigquery/i.test(name), + ) || + !bigQueryResourceNames.some((name) => + /postgresql/i.test(name), + ) + ) { + throw new Error( + "BigQuery request did not retain both lazy grammar resources", + ); + } + + const cleanupPoison = await request(worker, { + id: 5, + kind: "test-cleanup-poison", + }); + if ( + cleanupPoison.response.kind !== + "cleanup-poison-result" || + cleanupPoison.response.evidence.cleanupFailed !== true || + cleanupPoison.response.evidence.poisonedRetryFailed !== true || + cleanupPoison.response.evidence.evaluations !== 1 + ) { + throw new Error( + "Synthetic cleanup failure did not poison its loader", + ); } if (globalThis.NodeSQLParser !== sentinel) { throw new Error("A parser bundle changed the browser main global"); } const report = Object.freeze({ bigquery, - lazyResources: { - afterBigQuery, - afterPostgresql, - beforeCreation, - }, + cleanupPoison: cleanupPoison.response.evidence, postgresql, + resources: { + mainAfterReady: mainResourcesAfterReady, + mainBeforeCreation: mainResourcesBeforeCreation, + workerAtReady: ready.resources, + }, + workerReadyMs, }); globalThis.__CODEMIRROR_SQL_WORKER_PLACEMENT__ = report; document.body.dataset.status = "passed"; - document.querySelector("#result").textContent = JSON.stringify(report); + document.querySelector("#result").textContent = + JSON.stringify(report); } finally { + if (worker !== undefined) { + worker.terminate(); + } if (original === undefined) { Reflect.deleteProperty(globalThis, "NodeSQLParser"); } else { @@ -158,5 +265,7 @@ async function run() { run().catch((error) => { document.body.dataset.status = "failed"; document.querySelector("#result").textContent = - error instanceof Error ? error.message : "unknown worker fixture failure"; + error instanceof Error + ? error.message + : "unknown worker fixture failure"; }); diff --git a/test/worker-placement/vite.workers.config.mjs b/test/worker-placement/vite.workers.config.mjs index 99eaac2..6883503 100644 --- a/test/worker-placement/vite.workers.config.mjs +++ b/test/worker-placement/vite.workers.config.mjs @@ -1,6 +1,47 @@ import { resolve } from "node:path"; import { defineConfig } from "vite"; +const fixtureRoot = `${import.meta.dirname.replaceAll("\\", "/")}/`; + +function normalizeModuleId(moduleId) { + const normalized = moduleId.replaceAll("\\", "/"); + return normalized.startsWith(fixtureRoot) + ? normalized.slice(fixtureRoot.length) + : normalized; +} + +function moduleTrace(fileName) { + return { + name: `worker-placement-${fileName}`, + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .filter((output) => output.type === "chunk") + .map((chunk) => ({ + dynamicImports: [...chunk.dynamicImports].sort(), + facadeModuleId: + chunk.facadeModuleId === null + ? null + : normalizeModuleId(chunk.facadeModuleId), + fileName: chunk.fileName, + imports: [...chunk.imports].sort(), + isDynamicEntry: chunk.isDynamicEntry, + isEntry: chunk.isEntry, + moduleIds: Object.keys(chunk.modules) + .map(normalizeModuleId) + .sort(), + })) + .sort((left, right) => + left.fileName.localeCompare(right.fileName), + ); + this.emitFile({ + fileName, + source: `${JSON.stringify({ chunks }, null, 2)}\n`, + type: "asset", + }); + }, + }; +} + export default defineConfig({ base: "/", build: { @@ -11,7 +52,11 @@ export default defineConfig({ input: resolve(import.meta.dirname, "workers.html"), }, }, + plugins: [moduleTrace("page-module-trace.json")], worker: { format: "es", + plugins: () => [ + moduleTrace("worker-module-trace.json"), + ], }, }); From fd6bc39a78a9fc8faa243140114b7a83be4658cf Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:29:48 +0800 Subject: [PATCH 4/6] test(vnext): close worker evidence gaps --- docs/adr/0004-isolated-parser-execution.md | 14 ++--- scripts/worker-placement.mjs | 58 ++++++++++++-------- test/worker-placement/README.md | 6 +- test/worker-placement/src/parser-worker.js | 64 +++++++++++++++++++--- test/worker-placement/src/workers.js | 23 +++++--- test/worker-placement/vite.core.config.mjs | 26 +++++---- 6 files changed, 130 insertions(+), 61 deletions(-) diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index 11b8e4e..e930196 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -238,10 +238,10 @@ The latest local Node 24 / Chromium 149 / arm64 macOS sample recorded: | Output | Raw | gzip | | --- | ---: | ---: | -| Core-only fixture | 24,462 B | 7,475 B | -| PostgreSQL transitive worker graph | 320,495 B | 67,214 B | -| BigQuery transitive worker graph | 224,648 B | 50,205 B | -| Complete worker fixture | 549,003 B | 117,941 B | +| Core-only fixture | 24,680 B | 7,572 B | +| PostgreSQL transitive worker graph | 321,156 B | 67,396 B | +| BigQuery transitive worker graph | 225,309 B | 50,389 B | +| Complete worker fixture | 549,885 B | 118,160 B | The core module trace contained no `node-sql-parser` module. No dialect resource loaded before explicit construction. A single static module worker @@ -254,10 +254,10 @@ One sequential cold/warm run on the shared worker measured: | Dialect | Grammar load and initialization | First parse | First round trip | Warm parse | Warm round trip | | --- | ---: | ---: | ---: | ---: | ---: | -| PostgreSQL | 8.7 ms | 2.6 ms | 11.5 ms | 0.2 ms | 0.3 ms | -| BigQuery | 4.3 ms | 2.3 ms | 6.6 ms | 0.4 ms | 0.5 ms | +| PostgreSQL | 8.2 ms | 2.3 ms | 10.7 ms | 0.2 ms | 0.2 ms | +| BigQuery | 4.3 ms | 2.2 ms | 6.7 ms | 0.3 ms | 0.3 ms | -The worker ready handshake took 7.0 ms in that run. +The worker ready handshake took 7.4 ms in that run. These numbers establish packaging feasibility and initial size guards. They are not percentile claims. Stable latency decisions require repeated, diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 2f6fd94..f5ac3df 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -428,30 +428,43 @@ function verifyWorkerAssets(workersDirectory) { } function verifyCoreExcludesParser(coreDirectory) { - const moduleIds = JSON.parse( - readFileSync(join(coreDirectory, "module-ids.json"), "utf8"), + const trace = requireModuleTrace( + join(coreDirectory, "core-module-trace.json"), ); - if (!Array.isArray(moduleIds)) { - throw new Error("Core module trace was not an array"); + const chunks = chunkMap(trace, coreDirectory); + const entries = trace.chunks.filter( + (chunk) => chunk.isEntry === true, + ); + if (entries.length !== 1) { + throw new Error("Core module trace did not contain exactly one entry"); } - const parserModules = moduleIds.filter( - (moduleId) => - typeof moduleId === "string" && - moduleId.includes("/node-sql-parser/"), + const reachable = reachableChunks( + chunks, + [entries[0].fileName], + true, ); - if (parserModules.length > 0) { + const javascriptFiles = listFiles(coreDirectory) + .filter((path) => extname(path) === ".js") + .map((path) => + path.slice(coreDirectory.length + 1).split(sep).join("/"), + ); + const orphanJavascript = javascriptFiles.filter( + (fileName) => !reachable.has(fileName), + ); + if (orphanJavascript.length > 0) { throw new Error( - `Core-only build included parser modules: ${parserModules.join(", ")}`, + `Core-only build emitted unreachable JavaScript: ${orphanJavascript.join(", ")}`, ); } - const workerModules = moduleIds.filter( + const moduleIds = trace.chunks.flatMap((chunk) => chunk.moduleIds); + const parserModules = moduleIds.filter( (moduleId) => typeof moduleId === "string" && - /(?:^|[/\\])[^/\\]*worker[^/\\]*\.[cm]?[jt]s$/i.test(moduleId), + moduleId.includes("/node-sql-parser/"), ); - if (workerModules.length > 0) { + if (parserModules.length > 0) { throw new Error( - `Core-only build included worker modules: ${workerModules.join(", ")}`, + `Core-only build included parser modules: ${parserModules.join(", ")}`, ); } for (const path of listFiles(coreDirectory)) { @@ -459,9 +472,6 @@ function verifyCoreExcludesParser(coreDirectory) { if (extension !== ".js" && extension !== ".json") { continue; } - if (/worker/i.test(basename(path))) { - throw new Error(`Core-only build emitted worker asset ${basename(path)}`); - } const contents = readFileSync(path, "utf8"); const marker = PARSER_MARKERS.find((candidate) => contents.includes(candidate), @@ -471,13 +481,8 @@ function verifyCoreExcludesParser(coreDirectory) { `Core-only output ${basename(path)} contained parser marker ${marker}`, ); } - if (extension === ".js" && /new\s+Worker\s*\(/.test(contents)) { - throw new Error( - `Core-only output ${basename(path)} contained a worker constructor`, - ); - } } - return moduleIds.length; + return new Set(moduleIds).size; } function verifySsrImport(fixtureDirectory) { @@ -697,7 +702,12 @@ try { const fixtureDirectory = join(temporaryDirectory, "fixture"); cpSync(fixtureSource, fixtureDirectory, { recursive: true }); runPackageManager( - ["install", "--frozen-lockfile", "--ignore-scripts"], + [ + "install", + "--frozen-lockfile", + "--ignore-scripts", + "--offline", + ], fixtureDirectory, ); diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index 25a74d5..b498563 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -7,9 +7,9 @@ fixture dependencies are installed before the exact tarball is extracted, and the harness verifies that its exact `5.4.0` version matches the packed package's dependency. -The minified Vite 8 single-worker baseline is 67,214 gzip bytes for the -PostgreSQL transitive graph, 50,205 gzip bytes for the BigQuery transitive -graph, and 117,941 gzip/549,003 raw bytes for the complete worker build output. +The minified Vite 8 single-worker baseline is 67,396 gzip bytes for the +PostgreSQL transitive graph, 50,389 gzip bytes for the BigQuery transitive +graph, and 118,160 gzip/549,885 raw bytes for the complete worker build output. The PostgreSQL and BigQuery figures each include their transitive shared chunks; the report also identifies those shared chunks explicitly. The fail-closed ceilings include small explicit headroom over that measured diff --git a/test/worker-placement/src/parser-worker.js b/test/worker-placement/src/parser-worker.js index 79dc084..89cc653 100644 --- a/test/worker-placement/src/parser-worker.js +++ b/test/worker-placement/src/parser-worker.js @@ -151,14 +151,57 @@ function resourceEntries() { })); } -async function syntheticCleanupPoisonEvidence() { +async function syntheticCleanupEvidence() { + const successfulTarget = {}; + const originalNodeSqlParser = Object.freeze({ + owner: "original-node-sql-parser", + }); + const originalGlobal = () => "original-global"; + Object.defineProperties(successfulTarget, { + NodeSQLParser: { + configurable: true, + enumerable: true, + value: originalNodeSqlParser, + writable: false, + }, + global: { + configurable: true, + enumerable: false, + get: originalGlobal, + }, + }); + const successfulLoad = createGuardedModuleLoader(successfulTarget); + let successfulEvaluations = 0; + const successful = await successfulLoad(async () => { + successfulEvaluations += 1; + Object.defineProperties(successfulTarget, { + NodeSQLParser: { + configurable: true, + enumerable: false, + value: "temporary-node-sql-parser", + writable: true, + }, + global: { + configurable: true, + enumerable: true, + value: "temporary-global", + writable: true, + }, + }); + return "first-load"; + }); + const reusable = await successfulLoad(async () => { + successfulEvaluations += 1; + return "second-load"; + }); + const target = {}; const load = createGuardedModuleLoader(target); - let evaluations = 0; + let poisonedEvaluations = 0; let cleanupFailed = false; try { await load(async () => { - evaluations += 1; + poisonedEvaluations += 1; Object.defineProperty(target, "NodeSQLParser", { configurable: false, value: "synthetic-pollution", @@ -171,7 +214,7 @@ async function syntheticCleanupPoisonEvidence() { let poisonedRetryFailed = false; try { await load(async () => { - evaluations += 1; + poisonedEvaluations += 1; return {}; }); } catch { @@ -179,8 +222,13 @@ async function syntheticCleanupPoisonEvidence() { } return { cleanupFailed, - evaluations, + poisonedEvaluations, poisonedRetryFailed, + successfulDescriptorEquality: successful.descriptorEquality, + successfulEvaluations, + successfulModuleValues: + successful.moduleValue === "first-load" && + reusable.moduleValue === "second-load", }; } @@ -240,11 +288,11 @@ export function installParserWorker(moduleLoaders) { }); return; } - if (request.kind === "test-cleanup-poison") { + if (request.kind === "test-cleanup") { globalThis.postMessage({ - evidence: await syntheticCleanupPoisonEvidence(), + evidence: await syntheticCleanupEvidence(), id: request.id, - kind: "cleanup-poison-result", + kind: "cleanup-result", resources: resourceEntries(), }); return; diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js index abbea57..0e155db 100644 --- a/test/worker-placement/src/workers.js +++ b/test/worker-placement/src/workers.js @@ -217,19 +217,24 @@ async function run() { ); } - const cleanupPoison = await request(worker, { + const cleanup = await request(worker, { id: 5, - kind: "test-cleanup-poison", + kind: "test-cleanup", }); if ( - cleanupPoison.response.kind !== - "cleanup-poison-result" || - cleanupPoison.response.evidence.cleanupFailed !== true || - cleanupPoison.response.evidence.poisonedRetryFailed !== true || - cleanupPoison.response.evidence.evaluations !== 1 + cleanup.response.kind !== "cleanup-result" || + cleanup.response.evidence.cleanupFailed !== true || + cleanup.response.evidence.poisonedRetryFailed !== true || + cleanup.response.evidence.poisonedEvaluations !== 1 || + cleanup.response.evidence.successfulEvaluations !== 2 || + cleanup.response.evidence.successfulModuleValues !== true || + cleanup.response.evidence.successfulDescriptorEquality + .NodeSQLParser !== true || + cleanup.response.evidence.successfulDescriptorEquality.global !== + true ) { throw new Error( - "Synthetic cleanup failure did not poison its loader", + "Synthetic cleanup did not prove restoration and poisoning", ); } if (globalThis.NodeSQLParser !== sentinel) { @@ -237,7 +242,7 @@ async function run() { } const report = Object.freeze({ bigquery, - cleanupPoison: cleanupPoison.response.evidence, + cleanup: cleanup.response.evidence, postgresql, resources: { mainAfterReady: mainResourcesAfterReady, diff --git a/test/worker-placement/vite.core.config.mjs b/test/worker-placement/vite.core.config.mjs index af2ee87..a263377 100644 --- a/test/worker-placement/vite.core.config.mjs +++ b/test/worker-placement/vite.core.config.mjs @@ -5,17 +5,23 @@ function moduleTrace() { return { name: "worker-placement-core-module-trace", generateBundle(_options, bundle) { - const moduleIds = new Set(); - for (const output of Object.values(bundle)) { - if (output.type === "chunk") { - for (const moduleId of Object.keys(output.modules)) { - moduleIds.add(moduleId.replaceAll("\\", "/")); - } - } - } + const chunks = Object.values(bundle) + .filter((output) => output.type === "chunk") + .map((chunk) => ({ + dynamicImports: [...chunk.dynamicImports].sort(), + fileName: chunk.fileName, + imports: [...chunk.imports].sort(), + isEntry: chunk.isEntry, + moduleIds: Object.keys(chunk.modules) + .map((moduleId) => moduleId.replaceAll("\\", "/")) + .sort(), + })) + .sort((left, right) => + left.fileName.localeCompare(right.fileName), + ); this.emitFile({ - fileName: "module-ids.json", - source: `${JSON.stringify([...moduleIds].sort(), null, 2)}\n`, + fileName: "core-module-trace.json", + source: `${JSON.stringify({ chunks }, null, 2)}\n`, type: "asset", }); }, From bd5a08f9c365283b0941d5e30c9328b954b91da5 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:36:52 +0800 Subject: [PATCH 5/6] test(vnext): make worker evidence hermetic --- docs/adr/0004-isolated-parser-execution.md | 10 +- test/worker-placement/README.md | 4 +- test/worker-placement/pnpm-lock.yaml | 119 +++++++++++---------- test/worker-placement/pnpm-workspace.yaml | 7 ++ test/worker-placement/vite.core.config.mjs | 11 +- 5 files changed, 87 insertions(+), 64 deletions(-) create mode 100644 test/worker-placement/pnpm-workspace.yaml diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index e930196..51db8fa 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -234,11 +234,11 @@ that entry does not exist. The protocol PR must move the worker implementation behind the packed package boundary and remove the fixture's direct parser dependency before making a public packaging claim. -The latest local Node 24 / Chromium 149 / arm64 macOS sample recorded: +A representative local Node 24 / Chromium 149 / arm64 macOS sample recorded: | Output | Raw | gzip | | --- | ---: | ---: | -| Core-only fixture | 24,680 B | 7,572 B | +| Core-only fixture | 24,056 B | 7,497 B | | PostgreSQL transitive worker graph | 321,156 B | 67,396 B | | BigQuery transitive worker graph | 225,309 B | 50,389 B | | Complete worker fixture | 549,885 B | 118,160 B | @@ -254,10 +254,10 @@ One sequential cold/warm run on the shared worker measured: | Dialect | Grammar load and initialization | First parse | First round trip | Warm parse | Warm round trip | | --- | ---: | ---: | ---: | ---: | ---: | -| PostgreSQL | 8.2 ms | 2.3 ms | 10.7 ms | 0.2 ms | 0.2 ms | -| BigQuery | 4.3 ms | 2.2 ms | 6.7 ms | 0.3 ms | 0.3 ms | +| PostgreSQL | 8.0 ms | 2.4 ms | 10.6 ms | 0.1 ms | 0.3 ms | +| BigQuery | 4.2 ms | 2.5 ms | 6.7 ms | 0.2 ms | 0.4 ms | -The worker ready handshake took 7.4 ms in that run. +The worker ready handshake took 10.3 ms in that run. These numbers establish packaging feasibility and initial size guards. They are not percentile claims. Stable latency decisions require repeated, diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index b498563..3719c3d 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -5,7 +5,9 @@ exact tarball created by `pnpm pack`. It is intentionally not a workspace package. Its direct `node-sql-parser` dependency is intentional: the frozen fixture dependencies are installed before the exact tarball is extracted, and the harness verifies that its exact `5.4.0` version matches the packed -package's dependency. +package's dependency. The fixture workspace pins Vite's floating transitive +versions to the exact versions in the root lock, so the nested frozen install +can run offline after a clean root CI install. The minified Vite 8 single-worker baseline is 67,396 gzip bytes for the PostgreSQL transitive graph, 50,389 gzip bytes for the BigQuery transitive diff --git a/test/worker-placement/pnpm-lock.yaml b/test/worker-placement/pnpm-lock.yaml index 465fb95..73abec9 100644 --- a/test/worker-placement/pnpm-lock.yaml +++ b/test/worker-placement/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + lightningcss: 1.32.0 + nanoid: 3.3.15 + postcss: 8.5.16 + importers: .: @@ -169,82 +174,82 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -269,8 +274,8 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.23: - resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} rolldown@1.0.1: @@ -431,56 +436,56 @@ snapshots: fsevents@2.3.3: optional: true - lightningcss-android-arm64@1.33.0: + lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.33.0: + lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.33.0: + lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.33.0: + lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.33.0: + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.33.0: + lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.33.0: + lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.33.0: + lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.33.0: + lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.33.0: + lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.33.0: + lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.33.0: + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.33.0 - lightningcss-darwin-arm64: 1.33.0 - lightningcss-darwin-x64: 1.33.0 - lightningcss-freebsd-x64: 1.33.0 - lightningcss-linux-arm-gnueabihf: 1.33.0 - lightningcss-linux-arm64-gnu: 1.33.0 - lightningcss-linux-arm64-musl: 1.33.0 - lightningcss-linux-x64-gnu: 1.33.0 - lightningcss-linux-x64-musl: 1.33.0 - lightningcss-win32-arm64-msvc: 1.33.0 - lightningcss-win32-x64-msvc: 1.33.0 - - nanoid@3.3.16: {} + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.15: {} node-sql-parser@5.4.0: dependencies: @@ -499,9 +504,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.23: + postcss@8.5.16: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -538,9 +543,9 @@ snapshots: vite@8.0.13: dependencies: - lightningcss: 1.33.0 + lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.23 + postcss: 8.5.16 rolldown: 1.0.1 tinyglobby: 0.2.17 optionalDependencies: diff --git a/test/worker-placement/pnpm-workspace.yaml b/test/worker-placement/pnpm-workspace.yaml new file mode 100644 index 0000000..43ce887 --- /dev/null +++ b/test/worker-placement/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - . + +overrides: + lightningcss: 1.32.0 + nanoid: 3.3.15 + postcss: 8.5.16 diff --git a/test/worker-placement/vite.core.config.mjs b/test/worker-placement/vite.core.config.mjs index a263377..7d6cb5d 100644 --- a/test/worker-placement/vite.core.config.mjs +++ b/test/worker-placement/vite.core.config.mjs @@ -1,6 +1,15 @@ import { resolve } from "node:path"; import { defineConfig } from "vite"; +const fixtureRoot = `${import.meta.dirname.replaceAll("\\", "/")}/`; + +function normalizeModuleId(moduleId) { + const normalized = moduleId.replaceAll("\\", "/"); + return normalized.startsWith(fixtureRoot) + ? normalized.slice(fixtureRoot.length) + : normalized; +} + function moduleTrace() { return { name: "worker-placement-core-module-trace", @@ -13,7 +22,7 @@ function moduleTrace() { imports: [...chunk.imports].sort(), isEntry: chunk.isEntry, moduleIds: Object.keys(chunk.modules) - .map((moduleId) => moduleId.replaceAll("\\", "/")) + .map(normalizeModuleId) .sort(), })) .sort((left, right) => From 8b1ea202c38fda9659d3073afd5d625f170a2254 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Sat, 25 Jul 2026 02:42:29 +0800 Subject: [PATCH 6/6] fix(test): clean up worker fixture listeners --- docs/adr/0004-isolated-parser-execution.md | 2 +- test/worker-placement/README.md | 2 +- test/worker-placement/src/workers.js | 36 +++++++++++++--------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index 51db8fa..e501ef5 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -241,7 +241,7 @@ A representative local Node 24 / Chromium 149 / arm64 macOS sample recorded: | Core-only fixture | 24,056 B | 7,497 B | | PostgreSQL transitive worker graph | 321,156 B | 67,396 B | | BigQuery transitive worker graph | 225,309 B | 50,389 B | -| Complete worker fixture | 549,885 B | 118,160 B | +| Complete worker fixture | 549,893 B | 118,170 B | The core module trace contained no `node-sql-parser` module. No dialect resource loaded before explicit construction. A single static module worker diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index 3719c3d..3916cb0 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -11,7 +11,7 @@ can run offline after a clean root CI install. The minified Vite 8 single-worker baseline is 67,396 gzip bytes for the PostgreSQL transitive graph, 50,389 gzip bytes for the BigQuery transitive -graph, and 118,160 gzip/549,885 raw bytes for the complete worker build output. +graph, and 118,170 gzip/549,893 raw bytes for the complete worker build output. The PostgreSQL and BigQuery figures each include their transitive shared chunks; the report also identifies those shared chunks explicitly. The fail-closed ceilings include small explicit headroom over that measured diff --git a/test/worker-placement/src/workers.js b/test/worker-placement/src/workers.js index 0e155db..f7f4e59 100644 --- a/test/worker-placement/src/workers.js +++ b/test/worker-placement/src/workers.js @@ -8,11 +8,13 @@ const REQUEST_TIMEOUT_MS = 10_000; function request(worker, requestValue) { return new Promise((resolve, reject) => { const startedAt = performance.now(); - const timeout = setTimeout(() => { - reject(new Error(`Worker request ${requestValue.id} timed out`)); - }, REQUEST_TIMEOUT_MS); - const onError = (event) => { + const cleanup = () => { clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + }; + const onError = (event) => { + cleanup(); reject( new Error( event.message || @@ -24,14 +26,16 @@ function request(worker, requestValue) { if (event.data?.id !== requestValue.id) { return; } - clearTimeout(timeout); - worker.removeEventListener("error", onError); - worker.removeEventListener("message", onMessage); + cleanup(); resolve({ response: event.data, roundTripMs: performance.now() - startedAt, }); }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Worker request ${requestValue.id} timed out`)); + }, REQUEST_TIMEOUT_MS); worker.addEventListener("error", onError, { once: true }); worker.addEventListener("message", onMessage); worker.postMessage(requestValue); @@ -40,11 +44,13 @@ function request(worker, requestValue) { function waitForReady(worker) { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Parser worker ready handshake timed out")); - }, REQUEST_TIMEOUT_MS); - const onError = (event) => { + const cleanup = () => { clearTimeout(timeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("message", onMessage); + }; + const onError = (event) => { + cleanup(); reject( new Error(event.message || "Parser worker startup failed"), ); @@ -53,11 +59,13 @@ function waitForReady(worker) { if (event.data?.kind !== "ready") { return; } - clearTimeout(timeout); - worker.removeEventListener("error", onError); - worker.removeEventListener("message", onMessage); + cleanup(); resolve(event.data); }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Parser worker ready handshake timed out")); + }, REQUEST_TIMEOUT_MS); worker.addEventListener("error", onError, { once: true }); worker.addEventListener("message", onMessage); });