diff --git a/docs/adr/0004-isolated-parser-execution.md b/docs/adr/0004-isolated-parser-execution.md index c9f0131..2853504 100644 --- a/docs/adr/0004-isolated-parser-execution.md +++ b/docs/adr/0004-isolated-parser-execution.md @@ -55,21 +55,26 @@ 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: +The private browser 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. +- Worker construction is lazy and the executor owns 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. +- Executor disposal terminates the worker and settles every pending consumer. + +The executor and its worker factory remain package-private. No package export, +`/vnext` export, language-service module, session ownership, or public +configuration surface is introduced by this slice. 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. +terminates the generation. Never-posted queued requests may continue on a +fresh generation, but a posted request is never replayed. The execution deadline belongs to the posted worker job, not to any attached consumer. Consumer cancellation never clears it. A draining operation retains @@ -154,16 +159,20 @@ Messages do not contain: The wire does not transport an independently supplied retryability boolean. The host treats only `module-load` as retryable; `backend` and -`malformed-output` are terminal. A module-load failure closes the current -worker generation so a retry cannot reuse a rejected dynamic-import realm. +`malformed-output` are terminal. Every worker-reported failure retires the +current generation. In particular, `backend` cannot distinguish an ordinary +parse failure from descriptor-cleanup poisoning that closes the endpoint, and +`module-load` must not reuse a rejected dynamic-import realm. Never-posted +queued requests may continue on a fresh generation; the failed posted request +is never replayed. 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. +A future statement coordinator will construct 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 @@ -211,7 +220,7 @@ prove: recorded. - Raw and gzip worker sizes are recorded. -The executor and semantic slices additionally require: +The semantic and session-integration slices additionally require: - Main-thread long-task and event-loop responsiveness evidence. - Malformed message, crash, timeout, late-event, and restart tests. @@ -280,7 +289,7 @@ optional-integration bundle budget. 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. +3. Add the minimal browser worker and private bounded 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. diff --git a/docs/vnext/node-sql-parser-adapter.md b/docs/vnext/node-sql-parser-adapter.md index a65006f..17995ea 100644 --- a/docs/vnext/node-sql-parser-adapter.md +++ b/docs/vnext/node-sql-parser-adapter.md @@ -69,7 +69,7 @@ parsing, and output normalization. The package contains a production-shaped but private module-worker endpoint. It is not exported from the package and is not reachable through `/vnext`. There is no public worker constructor, executor, queue, language-service -module, or session integration yet. +module, or session integration. The endpoint: @@ -88,16 +88,27 @@ The endpoint: exact. The endpoint accepts only one request at a time. Overlap and malformed messages -fail closed instead of creating an implicit worker-side queue. The future -service-owned executor is responsible for serialization, correlation, -deadlines, cancellation, generation replacement, and disposal. +fail closed instead of creating an implicit worker-side queue. A private +main-realm executor now provides bounded FIFO admission, serialization, +correlation, startup/queue/execution deadlines, prompt consumer cancellation, +generation replacement, and disposal. It creates the production module worker +lazily and keeps cancelled posted work in a draining lane until the worker +responds or its safety deadline retires that generation. Posted work is never +replayed. Every worker-reported failure retires the generation because a +`backend` failure may be indistinguishable from endpoint cleanup poisoning; +never-posted queued work retains its original deadline on the replacement. + +The executor remains implementation infrastructure only. It is not exported +from the root package or `/vnext`, is not owned by `SqlLanguageService`, and +does not yet create authenticated syntax analyses or relation facts for a +session. Direct Chromium tests construct this source module worker and exercise both -real grammar builds. The separate worker-placement fixture remains -diagnostic packaging evidence: it records resource timing, emitted chunk -reachability, and bundle sizes with a fixture-owned protocol. It is not the -public integration boundary and must not be read as evidence that an executor -or session API already exists. +real grammar builds, including the private executor's production worker path. +The separate worker-placement fixture remains diagnostic packaging evidence: +it records resource timing, emitted chunk reachability, and bundle sizes with +a fixture-owned protocol. It is not the public integration boundary and must +not be read as evidence that a session API already exists. Approximate local Node 24 arm64 measurements for the installed package were: diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index f794f27..a10c94e 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -170,6 +170,8 @@ try { throw new Error("Packed manifest does not declare node-sql-parser"); } const privateWorkerArtifacts = [ + "dist/vnext/node-sql-parser-browser-executor.d.ts", + "dist/vnext/node-sql-parser-browser-executor.js", "dist/vnext/node-sql-parser-browser-worker.d.ts", "dist/vnext/node-sql-parser-browser-worker.js", "dist/vnext/node-sql-parser-browser-worker-endpoint.d.ts", diff --git a/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..9e86078 --- /dev/null +++ b/src/vnext/__tests__/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,2576 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { + createNodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutorDeadlineScheduler, + type NodeSqlParserBrowserExecutorEventType, + type NodeSqlParserBrowserExecutorOptions, + type NodeSqlParserBrowserExecutorOutcome, + type NodeSqlParserBrowserExecutorSubmission, + type NodeSqlParserBrowserExecutorWorker, +} from "../node-sql-parser-browser-executor.js"; +import { + decodeNodeSqlParserWireRequest, + encodeNodeSqlParserWireBackendOutcome, + encodeNodeSqlParserWireReady, + type NodeSqlParserWireMessage, + type NodeSqlParserWireRequest, +} from "../node-sql-parser-wire.js"; + +type WorkerListener = (event: unknown) => void; + +interface ScheduledDeadline { + readonly callback: () => void; + readonly deadline: number; + readonly handle: number; +} + +class ManualDeadlineScheduler + implements NodeSqlParserBrowserExecutorDeadlineScheduler +{ + readonly clearedHandles: number[] = []; + readonly scheduledDelays: number[] = []; + #deadlines = new Map(); + #nextHandle = 1; + #now = 0; + + clearTimeout(handle: unknown): void { + if (typeof handle !== "number") { + throw new TypeError("test scheduler received an invalid handle"); + } + this.clearedHandles.push(handle); + this.#deadlines.delete(handle); + } + + setTimeout(callback: () => void, delayMs: number): unknown { + const handle = this.#nextHandle; + this.#nextHandle += 1; + this.scheduledDelays.push(delayMs); + this.#deadlines.set(handle, { + callback, + deadline: this.#now + delayMs, + handle, + }); + return handle; + } + + advanceBy(milliseconds: number): void { + const target = this.#now + milliseconds; + for (;;) { + const next = [...this.#deadlines.values()] + .filter(({ deadline }) => deadline <= target) + .sort( + (left, right) => + left.deadline - right.deadline || + left.handle - right.handle, + )[0]; + if (next === undefined) { + break; + } + this.#now = next.deadline; + this.#deadlines.delete(next.handle); + next.callback(); + } + this.#now = target; + } + + pendingCount(): number { + return this.#deadlines.size; + } +} + +interface FakeWorkerFailures { + readonly add?: NodeSqlParserBrowserExecutorEventType; + readonly post?: boolean; + readonly retainRemovedListeners?: boolean; + readonly remove?: NodeSqlParserBrowserExecutorEventType; + readonly terminate?: boolean; +} + +class FakeWorker implements NodeSqlParserBrowserExecutorWorker { + readonly posted: unknown[] = []; + readonly removed: NodeSqlParserBrowserExecutorEventType[] = []; + readonly failures: FakeWorkerFailures; + #addHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #beforeAddHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #postHook: ((message: unknown) => void) | undefined; + #removeHook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined; + #terminateHook: (() => void) | undefined; + #listeners = new Map< + NodeSqlParserBrowserExecutorEventType, + Set + >(); + #terminateCalls = 0; + + constructor(failures: FakeWorkerFailures = {}) { + this.failures = failures; + } + + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: WorkerListener, + ): void { + this.#beforeAddHook?.(type); + if (this.failures.add === type) { + throw new Error("private addEventListener failure"); + } + const listeners = this.#listeners.get(type) ?? new Set(); + listeners.add(listener); + this.#listeners.set(type, listeners); + this.#addHook?.(type); + } + + dispatch(type: NodeSqlParserBrowserExecutorEventType, event: unknown): void { + for (const listener of this.#listeners.get(type) ?? []) { + listener(event); + } + } + + emit(message: unknown): void { + this.dispatch("message", { data: message }); + } + + listenerCount(type?: NodeSqlParserBrowserExecutorEventType): number { + if (type !== undefined) { + return this.#listeners.get(type)?.size ?? 0; + } + return [...this.#listeners.values()].reduce( + (count, listeners) => count + listeners.size, + 0, + ); + } + + postMessage(message: unknown): void { + if (this.failures.post) { + throw new Error("private postMessage failure"); + } + this.posted.push(message); + this.#postHook?.(message); + } + + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: WorkerListener, + ): void { + this.removed.push(type); + if (!this.failures.retainRemovedListeners) { + this.#listeners.get(type)?.delete(listener); + } + this.#removeHook?.(type); + if (this.failures.remove === type) { + throw new Error("private removeEventListener failure"); + } + } + + terminate(): void { + this.#terminateCalls += 1; + this.#terminateHook?.(); + if (this.failures.terminate) { + throw new Error("private terminate failure"); + } + } + + terminateCalls(): number { + return this.#terminateCalls; + } + + setPostHook(hook: ((message: unknown) => void) | undefined): void { + this.#postHook = hook; + } + + setRemoveHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#removeHook = hook; + } + + setTerminateHook(hook: (() => void) | undefined): void { + this.#terminateHook = hook; + } + + setAddHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#addHook = hook; + } + + setBeforeAddHook( + hook: + | ((type: NodeSqlParserBrowserExecutorEventType) => void) + | undefined, + ): void { + this.#beforeAddHook = hook; + } +} + +class FakeWorkerFactory { + readonly created: FakeWorker[] = []; + readonly queued: FakeWorker[] = []; + #failure: Error | undefined; + + create = (): FakeWorker => { + if (this.#failure !== undefined) { + throw this.#failure; + } + const worker = this.queued.shift() ?? new FakeWorker(); + this.created.push(worker); + return worker; + }; + + enqueue(worker: FakeWorker): void { + this.queued.push(worker); + } + + fail(error = new Error("private worker factory failure")): void { + this.#failure = error; + } + + recover(): void { + this.#failure = undefined; + } +} + +interface Harness { + readonly factory: FakeWorkerFactory; + readonly options: NodeSqlParserBrowserExecutorOptions; + readonly scheduler: ManualDeadlineScheduler; +} + +function harness( + overrides: Partial = {}, +): Harness { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + return { + factory, + options: { + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + ...overrides, + }, + scheduler, + }; +} + +function postedRequest(worker: FakeWorker, index = 0): NodeSqlParserWireRequest { + const request = decodeNodeSqlParserWireRequest(worker.posted[index]); + if (request === null) { + throw new Error("test worker did not receive a valid request"); + } + return request; +} + +function createdWorker( + factory: FakeWorkerFactory, + index = 0, +): FakeWorker { + const worker = factory.created[index]; + if (worker === undefined) { + throw new Error(`test worker generation ${index} was not created`); + } + return worker; +} + +function ready(worker: FakeWorker): void { + worker.emit(encodeNodeSqlParserWireReady()); +} + +function respond( + worker: FakeWorker, + outcome: + | { readonly kind: "parsed"; readonly statementKind: "query" } + | { readonly kind: "syntax-rejected" } + | { + readonly kind: "unsupported"; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly kind: "failed"; + readonly code: "backend" | "malformed-output" | "module-load"; + }, + index = 0, +): void { + const backendOutcome = + outcome.kind === "parsed" + ? { + ...outcome, + root: Object.freeze({}), + } + : outcome.kind === "failed" + ? { + ...outcome, + retryable: outcome.code === "module-load", + } + : outcome; + worker.emit( + encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker, index).requestId, + backendOutcome, + ), + ); +} + +async function outcome( + submission: NodeSqlParserBrowserExecutorSubmission, +): Promise { + return submission.result; +} + +async function expectPending( + submission: NodeSqlParserBrowserExecutorSubmission, +): Promise { + const sentinel = Symbol("pending"); + expect( + await Promise.race([ + submission.result, + Promise.resolve(sentinel), + ]), + ).toBe(sentinel); +} + +describe("node-sql-parser browser executor admission", () => { + it("starts lazily, waits for ready, and sends a frozen closed request", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + + expect(factory.created).toHaveLength(0); + expect(scheduler.pendingCount()).toBe(0); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + expect(worker.posted).toHaveLength(0); + expect(worker.listenerCount()).toBe(3); + expect(scheduler.scheduledDelays).toStrictEqual([20, 10]); + + ready(worker); + const request = postedRequest(worker); + expect(request).toStrictEqual({ + grammar: "postgresql", + kind: "parse", + protocolVersion: 1, + requestId: 1, + text: "SELECT 1", + }); + expect(Object.isFrozen(request)).toBe(true); + + respond(worker, { + kind: "parsed", + statementKind: "query", + }); + const result = await outcome(submission); + expect(result).toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(Object.isFrozen(result)).toBe(true); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("runs one request at a time in FIFO order across grammars", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 3, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const second = executor.submit({ + grammar: "bigquery", + text: "SELECT 2", + }); + const third = executor.submit({ + grammar: "postgresql", + text: "SELECT 3", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(worker.posted).toHaveLength(1); + expect(postedRequest(worker).text).toBe("SELECT 1"); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1)).toMatchObject({ + grammar: "bigquery", + text: "SELECT 2", + }); + + respond(worker, { + kind: "unsupported", + reason: "multiple-statements", + }, 1); + expect(await outcome(second)).toStrictEqual({ + kind: "unsupported", + reason: "multiple-statements", + }); + expect(postedRequest(worker, 2).text).toBe("SELECT 3"); + + respond(worker, { + kind: "parsed", + statementKind: "query", + }, 2); + expect(await outcome(third)).toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + }); + + it("becomes terminal when the request ID space is exhausted", async () => { + const { factory, options, scheduler } = harness({ + maxQueuedRequests: 3, + requestIdStart: Number.MAX_SAFE_INTEGER, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const lastIdentified = executor.submit({ + grammar: "postgresql", + text: "SELECT last", + }); + const exhausted = executor.submit({ + grammar: "postgresql", + text: "SELECT exhausted", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(postedRequest(worker).requestId).toBe( + Number.MAX_SAFE_INTEGER, + ); + respond(worker, { kind: "syntax-rejected" }); + + expect(await outcome(lastIdentified)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(await outcome(exhausted)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + executor.dispose(); + const disposed = executor.submit({ + grammar: "postgresql", + text: "SELECT disposed", + }); + expect(await outcome(disposed)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + }); + + it("handles a response dispatched synchronously from postMessage", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker(); + factory.enqueue(worker); + worker.setPostHook((value) => { + const request = decodeNodeSqlParserWireRequest(value); + if (request === null) { + throw new Error("expected a valid request"); + } + worker.emit( + encodeNodeSqlParserWireBackendOutcome(request.requestId, { + kind: "syntax-rejected", + }), + ); + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("drains a large bounded FIFO under synchronous terminal responses without recursion", async () => { + const requestCount = 20_000; + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + factory.enqueue(worker); + worker.setPostHook((value) => { + const request = decodeNodeSqlParserWireRequest(value); + if (request === null) { + throw new Error("expected a valid request"); + } + worker.emit( + encodeNodeSqlParserWireBackendOutcome(request.requestId, { + kind: "syntax-rejected", + }), + ); + }); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 100, + maxQueuedRequests: requestCount, + maxQueuedTextUnits: 1_000_000, + queueDeadlineMs: 1_000_000, + startupDeadlineMs: 100, + workerFactory: factory.create, + }); + const submissions = Array.from( + { length: requestCount }, + (_, index) => + executor.submit({ + grammar: index % 2 === 0 ? "postgresql" : "bigquery", + text: `request-${index}`, + }), + ); + + ready(worker); + const results = await Promise.all( + submissions.map((submission) => submission.result), + ); + expect(results).toHaveLength(requestCount); + expect( + results.every( + (result) => result.kind === "syntax-rejected", + ), + ).toBe(true); + expect(worker.posted).toHaveLength(requestCount); + for (let index = 0; index < requestCount; index += 1) { + const request = decodeNodeSqlParserWireRequest( + worker.posted[index], + ); + if (request === null) { + throw new Error(`invalid request at FIFO index ${index}`); + } + expect(request.requestId).toBe(index + 1); + expect(request.text).toBe(`request-${index}`); + } + expect(scheduler.pendingCount()).toBe(0); + }, 10_000); + + it("bounds queued count without disturbing admitted work", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 1, + maxQueuedTextUnits: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const rejected = executor.submit({ + grammar: "postgresql", + text: "rejected", + }); + + expect(await outcome(rejected)).toStrictEqual({ + code: "queue-limit", + kind: "failed", + }); + expect(postedRequest(worker).text).toBe("active"); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(active)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(worker, 1).text).toBe("queued"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("bounds retained queued UTF-16 units independently of count", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 4, + maxQueuedTextUnits: 4, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "a", + }); + const worker = createdWorker(factory); + ready(worker); + const twoAstralUnits = executor.submit({ + grammar: "postgresql", + text: "😀", + }); + const twoMore = executor.submit({ + grammar: "postgresql", + text: "xy", + }); + const overLimit = executor.submit({ + grammar: "postgresql", + text: "z", + }); + + expect(await outcome(overLimit)).toStrictEqual({ + code: "queue-limit", + kind: "failed", + }); + respond(worker, { kind: "syntax-rejected" }); + await outcome(active); + expect(postedRequest(worker, 1).text).toBe("😀"); + respond(worker, { kind: "syntax-rejected" }, 1); + await outcome(twoAstralUnits); + expect(postedRequest(worker, 2).text).toBe("xy"); + respond(worker, { kind: "syntax-rejected" }, 2); + await outcome(twoMore); + }); + + it("rejects an oversized input without creating a worker", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "x".repeat(16 * 1024 + 1), + }); + + expect(await outcome(submission)).toStrictEqual({ + kind: "unsupported", + reason: "resource-limit", + }); + expect(factory.created).toHaveLength(0); + }); +}); + +describe("node-sql-parser browser executor deadlines and cancellation", () => { + it("contains disposal reentrancy while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + executor?.dispose(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.posted).toHaveLength(0); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(manual.pendingCount()).toBe(0); + }); + + it("drains cancellation reentrancy while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let first: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + first?.cancel(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(first)).toStrictEqual({ + kind: "cancelled", + }); + expect(worker.posted).toHaveLength(1); + expect(postedRequest(worker).text).toBe("first"); + await expectPending(second); + + respond(worker, { kind: "syntax-rejected" }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1).text).toBe("second"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(second)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }); + + it("preserves FIFO for nested submission while clearing a promoted queue deadline", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (handle === 1) { + nested = executor?.submit({ + grammar: "bigquery", + text: "nested", + }); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const worker = createdWorker(factory); + + ready(worker); + if (nested === undefined) { + throw new Error("nested submission was not created"); + } + expect(postedRequest(worker)).toMatchObject({ + requestId: 1, + text: "first", + }); + expect(worker.posted).toHaveLength(1); + + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(worker, 1)).toMatchObject({ + requestId: 2, + text: "nested", + }); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(nested)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }); + + it("applies a startup deadline and retires a silent worker", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + scheduler.advanceBy(9); + await expectPending(submission); + scheduler.advanceBy(1); + expect(await outcome(submission)).toStrictEqual({ + code: "startup-timeout", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("measures queue time from admission without resetting on ready", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 20, + startupDeadlineMs: 50, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "SELECT 2", + }); + const worker = createdWorker(factory); + + scheduler.advanceBy(15); + ready(worker); + expect(worker.posted).toHaveLength(1); + scheduler.advanceBy(5); + expect(await outcome(second)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + await expectPending(first); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("starts the execution deadline before posting and retires on timeout", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + ready(worker); + + scheduler.advanceBy(29); + await expectPending(submission); + scheduler.advanceBy(1); + expect(await outcome(submission)).toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + }); + + it("cancels queued work promptly and releases its queue capacity", async () => { + const { factory, options } = harness({ + maxQueuedRequests: 1, + maxQueuedTextUnits: 6, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + const cancelled = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + + cancelled.cancel(); + cancelled.cancel(); + expect(await outcome(cancelled)).toStrictEqual({ + kind: "cancelled", + }); + const replacement = executor.submit({ + grammar: "postgresql", + text: "next", + }); + + respond(worker, { kind: "syntax-rejected" }); + await outcome(active); + expect(postedRequest(worker, 1).text).toBe("next"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(replacement)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("cancels active work promptly but drains its response before advancing", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const worker = createdWorker(factory); + ready(worker); + + active.cancel(); + active.cancel(); + expect(await outcome(active)).toStrictEqual({ + kind: "cancelled", + }); + expect(worker.posted).toHaveLength(1); + expect(scheduler.pendingCount()).toBeGreaterThan(0); + + respond(worker, { kind: "syntax-rejected" }); + expect(worker.posted).toHaveLength(2); + expect(postedRequest(worker, 1).text).toBe("queued"); + respond(worker, { kind: "syntax-rejected" }, 1); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("keeps the execution safety deadline after active cancellation", async () => { + const { factory, options, scheduler } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + active.cancel(); + await outcome(active); + + scheduler.advanceBy(30); + expect(firstWorker.terminateCalls()).toBe(1); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("does not reset a queued deadline across generation replacement", async () => { + const { factory, options, scheduler } = harness({ + executionDeadlineMs: 200, + queueDeadlineMs: 100, + startupDeadlineMs: 200, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + + scheduler.advanceBy(60); + firstWorker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + createdWorker(factory, 1); + scheduler.advanceBy(39); + await expectPending(queued); + scheduler.advanceBy(1); + expect(await outcome(queued)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + executor.dispose(); + }); + + it("cancels during startup without ever posting the request", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + submission.cancel(); + expect(await outcome(submission)).toStrictEqual({ + kind: "cancelled", + }); + ready(worker); + expect(worker.posted).toHaveLength(0); + executor.dispose(); + }); +}); + +describe("node-sql-parser browser executor hostile worker handling", () => { + it.each(["clear", "remove", "terminate"] as const)( + "isolates original startup waiters from %s cleanup reentrancy", + async (boundary) => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const firstWorker = new FakeWorker(); + const replacementWorker = new FakeWorker(); + factory.enqueue(firstWorker); + factory.enqueue(replacementWorker); + replacementWorker.setAddHook((type) => { + if (type === "message") { + ready(replacementWorker); + } + }); + let executor: + | ReturnType + | undefined; + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + let cleanupDepth = 0; + let replacementCreatedDuringCleanup = false; + const submitNested = () => { + if (nested === undefined) { + nested = executor?.submit({ + grammar: "bigquery", + text: "nested", + }); + } + }; + const submitNestedDuringCleanup = () => { + cleanupDepth += 1; + try { + submitNested(); + } finally { + cleanupDepth -= 1; + } + }; + if (boundary === "remove") { + firstWorker.setRemoveHook((type) => { + if (type === "message") { + submitNestedDuringCleanup(); + } + }); + } + if (boundary === "terminate") { + firstWorker.setTerminateHook( + submitNestedDuringCleanup, + ); + } + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(handle): void { + if (boundary === "clear" && handle === 2) { + submitNestedDuringCleanup(); + } + manual.clearTimeout(handle); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 3, + maxQueuedTextUnits: 100, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => { + if ( + cleanupDepth > 0 && + factory.created.length > 0 + ) { + replacementCreatedDuringCleanup = true; + } + return factory.create(); + }, + }); + const originalFirst = executor.submit({ + grammar: "postgresql", + text: "original-first", + }); + const originalSecond = executor.submit({ + grammar: "postgresql", + text: "original-second", + }); + + firstWorker.dispatch("error", {}); + if (nested === undefined) { + throw new Error( + `${boundary} cleanup did not create nested work`, + ); + } + expect(await outcome(originalFirst)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(originalSecond)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(firstWorker.posted).toHaveLength(0); + expect(firstWorker.terminateCalls()).toBe(1); + expect(replacementCreatedDuringCleanup).toBe(false); + expect(factory.created).toHaveLength(2); + expect(replacementWorker.posted).toHaveLength(1); + expect(postedRequest(replacementWorker)).toMatchObject({ + grammar: "bigquery", + requestId: 1, + text: "nested", + }); + + respond(replacementWorker, { kind: "syntax-rejected" }); + expect(await outcome(nested)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(manual.pendingCount()).toBe(0); + }, + ); + + it("rejects a worker identity reused across generations", async () => { + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + let factoryCalls = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => { + factoryCalls += 1; + return worker; + }, + }); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + ready(worker); + + worker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(factoryCalls).toBe(2); + expect(worker.posted).toHaveLength(1); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it.each([ + undefined, + null, + {}, + { data: null }, + { data: { kind: "ready", protocolVersion: 2 } }, + { + data: { + extra: true, + kind: "ready", + protocolVersion: 1, + }, + }, + ])("fails closed for malformed message event %#", async (event) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + worker.dispatch("message", event); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("fails closed when a message data getter throws", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch("message", { + get data() { + throw new Error("private data getter failure"); + }, + }); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + }); + + it("retires on unsolicited and duplicate ready messages", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("retires when the worker reports a protocol error", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + worker.emit({ + code: "invalid-request", + kind: "protocol-error", + protocolVersion: 1, + } satisfies NodeSqlParserWireMessage); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); + + it("retires on a mismatched response and never replays active work", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + const activeId = postedRequest(firstWorker).requestId; + + firstWorker.emit({ + kind: "syntax-rejected", + protocolVersion: 1, + requestId: activeId + 1, + } satisfies NodeSqlParserWireMessage); + expect(await outcome(active)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(secondWorker.posted).toHaveLength(1); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect( + firstWorker.posted.filter( + (value) => + decodeNodeSqlParserWireRequest(value)?.text === "active", + ), + ).toHaveLength(1); + }); + + it("ignores late messages from a retired generation", async () => { + const { factory, options } = harness(); + const retainedWorker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + }); + factory.enqueue(retainedWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + firstWorker.dispatch("error", new Error("private worker error")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + + firstWorker.emit(encodeNodeSqlParserWireReady()); + firstWorker.dispatch( + "message", + new Proxy( + {}, + { + get() { + throw new Error("must not inspect retired event"); + }, + }, + ), + ); + let staleFailureInspections = 0; + firstWorker.dispatch("error", { + get preventDefault() { + staleFailureInspections += 1; + throw new Error("must not inspect retired failure event"); + }, + }); + expect(staleFailureInspections).toBe(0); + expect(secondWorker.posted).toHaveLength(0); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it.each(["error", "messageerror"] as const)( + "retires on a worker %s event without leaking event details", + async (eventType) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch( + eventType, + new Error("private event detail"), + ); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it.each(["remove", "terminate"] as const)( + "keeps worker-failure ownership when %s cleanup reentrantly cancels active work", + async (boundary) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + if (boundary === "remove") { + worker.setRemoveHook((type) => { + if (type === "message") { + active.cancel(); + } + }); + } else { + worker.setTerminateHook(active.cancel); + } + + worker.dispatch("error", {}); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("safely prevents the default handling of worker failures", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + let prevented = 0; + + worker.dispatch("error", { + preventDefault() { + prevented += 1; + }, + }); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(prevented).toBe(1); + }); + + it("retires before preventDefault can reentrantly deliver an active response", async () => { + const { factory, options } = harness(); + const firstWorker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + }); + factory.enqueue(firstWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + ready(firstWorker); + const staleResponse = encodeNodeSqlParserWireBackendOutcome( + postedRequest(firstWorker).requestId, + { kind: "syntax-rejected" }, + ); + let prevented = 0; + let workerCountDuringPrevention = 0; + + firstWorker.dispatch("error", { + preventDefault() { + prevented += 1; + workerCountDuringPrevention = factory.created.length; + firstWorker.emit(staleResponse); + }, + }); + expect(prevented).toBe(1); + expect(workerCountDuringPrevention).toBe(1); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(firstWorker.terminateCalls()).toBe(1); + expect(firstWorker.posted).toHaveLength(1); + + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker)).toMatchObject({ + requestId: 2, + text: "queued", + }); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("accepts primitive worker failure events without inspection", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const worker = createdWorker(factory); + + worker.dispatch("error", "private event detail"); + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + }); + + it.each(["backend", "malformed-output", "module-load"] as const)( + "retires after a valid %s failure and preserves never-posted work", + async (code) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "bigquery", + text: "queued", + }); + const firstWorker = createdWorker(factory); + ready(firstWorker); + + respond(firstWorker, { code, kind: "failed" }); + expect(await outcome(active)).toStrictEqual({ + code, + kind: "failed", + }); + expect(firstWorker.terminateCalls()).toBe(1); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker)).toMatchObject({ + grammar: "bigquery", + text: "queued", + }); + expect(postedRequest(secondWorker).requestId).toBe( + postedRequest(firstWorker).requestId + 1, + ); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }, + ); + + it("retires on a duplicate terminal response", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + const duplicate = encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker).requestId, + { kind: "syntax-rejected" }, + ); + + worker.emit(duplicate); + await outcome(first); + expect(worker.posted).toHaveLength(2); + worker.emit(duplicate); + expect(await outcome(second)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }); +}); + +describe("node-sql-parser browser executor host failure containment", () => { + it("contains worker factory failure and settles the startup waiter", async () => { + const { factory, options, scheduler } = harness(); + factory.fail(); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("rejects a non-object worker factory result", async () => { + const { factory, options, scheduler } = harness(); + const workerFactory = new Proxy(factory.create, { + apply() { + return () => undefined; + }, + }); + const executor = createNodeSqlParserBrowserExecutor({ + ...options, + workerFactory, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("can create a fresh generation on a later submission after factory failure", async () => { + const { factory, options } = harness(); + factory.fail(); + const executor = createNodeSqlParserBrowserExecutor(options); + const failed = executor.submit({ + grammar: "postgresql", + text: "first", + }); + expect(await outcome(failed)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + + factory.recover(); + const recovered = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(recovered)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("contains disposal reentrancy from the worker factory", async () => { + const worker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory() { + executor?.dispose(); + return worker; + }, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("keeps one authoritative generation during factory submission reentrancy", async () => { + const outerWorker = new FakeWorker(); + const nestedWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let calls = 0; + let executor: + | ReturnType + | undefined; + let nestedSubmission: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 3, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory() { + calls += 1; + if (calls === 1) { + nestedSubmission = executor?.submit({ + grammar: "postgresql", + text: "nested", + }); + return outerWorker; + } + return nestedWorker; + }, + }); + + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + if (nestedSubmission === undefined) { + throw new Error("nested submission was not created"); + } + expect(calls).toBe(1); + expect(outerWorker.listenerCount()).toBe(3); + expect(outerWorker.terminateCalls()).toBe(0); + expect(nestedWorker.listenerCount()).toBe(0); + + ready(outerWorker); + expect(postedRequest(outerWorker).text).toBe("first"); + respond(outerWorker, { kind: "syntax-rejected" }); + expect(await outcome(first)).toStrictEqual({ + kind: "syntax-rejected", + }); + expect(postedRequest(outerWorker, 1).text).toBe("nested"); + respond(outerWorker, { kind: "syntax-rejected" }, 1); + expect(await outcome(nestedSubmission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + expect(outerWorker.terminateCalls()).toBe(1); + }); + + it.each(["error", "message", "messageerror"] as const)( + "contains %s listener installation failure", + async (eventType) => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ add: eventType }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }, + ); + + it("contains both listener installation and explicit cleanup failures", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ + add: "message", + remove: "message", + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("removes a listener registered after addEventListener reentrantly disposes", async () => { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker(); + factory.enqueue(worker); + let disposedDuringAdd = false; + let executor: + | ReturnType + | undefined; + worker.setBeforeAddHook(() => { + if (!disposedDuringAdd) { + disposedDuringAdd = true; + executor?.dispose(); + } + }); + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(disposedDuringAdd).toBe(true); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(worker.removed).toContain("error"); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("contains an add failure after addEventListener reentrantly disposes", async () => { + const factory = new FakeWorkerFactory(); + const scheduler = new ManualDeadlineScheduler(); + const worker = new FakeWorker({ add: "error" }); + factory.enqueue(worker); + let executor: + | ReturnType + | undefined; + worker.setBeforeAddHook(() => { + executor?.dispose(); + }); + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.listenerCount()).toBe(0); + expect(worker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("contains postMessage failure and never replays the active request", async () => { + const { factory, options } = harness(); + const firstWorker = new FakeWorker({ post: true }); + factory.enqueue(firstWorker); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + + ready(firstWorker); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + const secondWorker = createdWorker(factory, 1); + ready(secondWorker); + expect(postedRequest(secondWorker).text).toBe("queued"); + respond(secondWorker, { kind: "syntax-rejected" }); + expect(await outcome(queued)).toStrictEqual({ + kind: "syntax-rejected", + }); + }); + + it("does not call a postMessage accessor result after the accessor disposes", async () => { + const baseWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let executor: + | ReturnType + | undefined; + let callableInvocations = 0; + const hostileWorker: NodeSqlParserBrowserExecutorWorker = { + addEventListener(type, listener): void { + baseWorker.addEventListener(type, listener); + }, + get postMessage() { + executor?.dispose(); + return (_message: unknown): void => { + callableInvocations += 1; + }; + }, + removeEventListener(type, listener): void { + baseWorker.removeEventListener(type, listener); + }, + terminate(): void { + baseWorker.terminate(); + }, + }; + executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => hostileWorker, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + ready(baseWorker); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(callableInvocations).toBe(0); + expect(baseWorker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("rejects a correlated response emitted by a postMessage accessor", async () => { + const baseWorker = new FakeWorker(); + const scheduler = new ManualDeadlineScheduler(); + let callableInvocations = 0; + const hostileWorker: NodeSqlParserBrowserExecutorWorker = { + addEventListener(type, listener): void { + baseWorker.addEventListener(type, listener); + }, + get postMessage() { + baseWorker.emit( + encodeNodeSqlParserWireBackendOutcome(1, { + kind: "syntax-rejected", + }), + ); + return (_message: unknown): void => { + callableInvocations += 1; + }; + }, + removeEventListener(type, listener): void { + baseWorker.removeEventListener(type, listener); + }, + terminate(): void { + baseWorker.terminate(); + }, + }; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: scheduler, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: () => hostileWorker, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + ready(baseWorker); + expect(await outcome(submission)).toStrictEqual({ + code: "protocol-error", + kind: "failed", + }); + expect(callableInvocations).toBe(0); + expect(baseWorker.terminateCalls()).toBe(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it.each(["error", "messageerror"] as const)( + "stops listener installation after synchronous %s failure", + async (eventType) => { + const { factory, options } = harness(); + const worker = new FakeWorker(); + worker.setAddHook((type) => { + if (type === eventType) { + worker.dispatch("error", {}); + } + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("becomes terminal when retirement cannot terminate the worker", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ + remove: "message", + retainRemovedListeners: true, + terminate: true, + }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + let nested: + | NodeSqlParserBrowserExecutorSubmission + | undefined; + worker.setTerminateHook(() => { + nested = executor.submit({ + grammar: "postgresql", + text: "SELECT nested", + }); + nested.cancel(); + }); + const active = executor.submit({ + grammar: "postgresql", + text: "SELECT private", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "SELECT queued", + }); + + ready(worker); + worker.dispatch("error", new Error("private")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.removed).toContain("messageerror"); + expect(factory.created).toHaveLength(1); + if (nested === undefined) { + throw new Error("terminate hook did not submit"); + } + expect(await outcome(nested)).toStrictEqual({ + kind: "cancelled", + }); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(factory.created).toHaveLength(1); + worker.emit(encodeNodeSqlParserWireReady()); + worker.dispatch("error", new Error("late private")); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("lets reentrant disposal win for queued and future work", async () => { + const { factory, options, scheduler } = harness(); + const worker = new FakeWorker({ terminate: true }); + factory.enqueue(worker); + const executor = createNodeSqlParserBrowserExecutor(options); + worker.setTerminateHook(() => { + executor.dispose(); + }); + const active = executor.submit({ + grammar: "postgresql", + text: "SELECT active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "SELECT queued", + }); + + ready(worker); + worker.dispatch("error", new Error("private")); + expect(await outcome(active)).toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + expect(await outcome(queued)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(factory.created).toHaveLength(1); + expect(scheduler.pendingCount()).toBe(0); + + const later = executor.submit({ + grammar: "postgresql", + text: "SELECT later", + }); + expect(await outcome(later)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + }); + + it("contains a deadline scheduler set failure", async () => { + const factory = new FakeWorkerFactory(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(): unknown { + throw new Error("private scheduler set failure"); + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + expect(factory.created).toHaveLength(0); + }); + + it("contains a synchronously firing queue deadline", async () => { + const factory = new FakeWorkerFactory(); + let clearCalls = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void { + clearCalls += 1; + }, + setTimeout(callback): unknown { + callback(); + return 1; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "queue-timeout", + kind: "failed", + }); + expect(clearCalls).toBe(1); + expect(factory.created).toHaveLength(0); + }); + + it("contains a synchronously firing startup deadline", async () => { + const factory = new FakeWorkerFactory(); + let schedules = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(callback): unknown { + schedules += 1; + if (schedules === 2) { + callback(); + } + return schedules; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + + expect(await outcome(submission)).toStrictEqual({ + code: "startup-timeout", + kind: "failed", + }); + expect(createdWorker(factory).terminateCalls()).toBe(1); + }); + + it("contains a synchronously firing execution deadline", async () => { + const factory = new FakeWorkerFactory(); + let schedules = 0; + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout(callback): unknown { + schedules += 1; + if (schedules === 3) { + callback(); + } + return schedules; + }, + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + expect(await outcome(submission)).toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + expect(worker.posted).toHaveLength(0); + }); + + it("contains deadline scheduler clear failures", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void { + throw new Error("private scheduler clear failure"); + }, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + }); + + it("ignores cleared queue, startup, and execution callbacks that fire late", async () => { + const factory = new FakeWorkerFactory(); + const manual = new ManualDeadlineScheduler(); + const executor = createNodeSqlParserBrowserExecutor({ + deadlineScheduler: { + clearTimeout(): void {}, + setTimeout: manual.setTimeout.bind(manual), + }, + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 10, + startupDeadlineMs: 20, + workerFactory: factory.create, + }); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = createdWorker(factory); + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + + manual.advanceBy(30); + expect(worker.terminateCalls()).toBe(0); + executor.dispose(); + }); + + it("constructs the private default module worker lazily", async () => { + const workers: FakeWorker[] = []; + const constructorCalls: { + readonly options: unknown; + readonly url: unknown; + }[] = []; + class DefaultWorker extends FakeWorker { + constructor(url: unknown, options: unknown) { + super(); + workers.push(this); + constructorCalls.push({ options, url }); + } + } + vi.stubGlobal("Worker", DefaultWorker); + try { + const executor = createNodeSqlParserBrowserExecutor({ + executionDeadlineMs: 30, + maxQueuedRequests: 2, + maxQueuedTextUnits: 30, + queueDeadlineMs: 20, + startupDeadlineMs: 10, + }); + expect(workers).toHaveLength(0); + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + const worker = workers[0]; + if (worker === undefined) { + throw new Error("default worker was not constructed"); + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]?.options).toStrictEqual({ + name: "codemirror-sql-parser", + type: "module", + }); + expect(String(constructorCalls[0]?.url)).toContain( + "node-sql-parser-browser-worker.js", + ); + + ready(worker); + respond(worker, { kind: "syntax-rejected" }); + expect(await outcome(submission)).toStrictEqual({ + kind: "syntax-rejected", + }); + executor.dispose(); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("node-sql-parser browser executor disposal and validation", () => { + it.each(["remove", "terminate"] as const)( + "keeps disposed ownership when %s cleanup reentrantly cancels active work", + async (boundary) => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const worker = createdWorker(factory); + ready(worker); + if (boundary === "remove") { + worker.setRemoveHook((type) => { + if (type === "message") { + active.cancel(); + } + }); + } else { + worker.setTerminateHook(active.cancel); + } + + executor.dispose(); + expect(await outcome(active)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + }, + ); + + it("disposes starting, active, and queued submissions exactly once", async () => { + const { factory, options, scheduler } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + const first = executor.submit({ + grammar: "postgresql", + text: "first", + }); + const second = executor.submit({ + grammar: "postgresql", + text: "second", + }); + const worker = createdWorker(factory); + ready(worker); + + executor.dispose(); + executor.dispose(); + first.cancel(); + second.cancel(); + expect(await outcome(first)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(await outcome(second)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + expect(worker.terminateCalls()).toBe(1); + expect(worker.listenerCount()).toBe(0); + expect(scheduler.pendingCount()).toBe(0); + }); + + it("disposes while a cancelled active request is draining", async () => { + const { factory, options } = harness({ + queueDeadlineMs: 100, + }); + const executor = createNodeSqlParserBrowserExecutor(options); + const active = executor.submit({ + grammar: "postgresql", + text: "active", + }); + const queued = executor.submit({ + grammar: "postgresql", + text: "queued", + }); + const worker = createdWorker(factory); + ready(worker); + const lateResponse = encodeNodeSqlParserWireBackendOutcome( + postedRequest(worker).requestId, + { kind: "syntax-rejected" }, + ); + active.cancel(); + expect(await outcome(active)).toStrictEqual({ + kind: "cancelled", + }); + + executor.dispose(); + expect(await outcome(queued)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + worker.emit(lateResponse); + expect(worker.posted).toHaveLength(1); + expect(worker.terminateCalls()).toBe(1); + }); + + it("settles submissions made after disposal without creating a worker", async () => { + const { factory, options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + executor.dispose(); + + const submission = executor.submit({ + grammar: "postgresql", + text: "SELECT 1", + }); + expect(await outcome(submission)).toStrictEqual({ + code: "disposed", + kind: "failed", + }); + submission.cancel(); + expect(Object.isFrozen(submission)).toBe(true); + expect(factory.created).toHaveLength(0); + }); + + it.each([ + ["executionDeadlineMs", 0], + ["executionDeadlineMs", 2_147_483_648], + ["executionDeadlineMs", Number.POSITIVE_INFINITY], + ["maxQueuedRequests", -1], + ["maxQueuedRequests", 1.5], + ["maxQueuedTextUnits", -1], + ["maxQueuedTextUnits", Number.NaN], + ["queueDeadlineMs", 0], + ["startupDeadlineMs", -1], + ] as const)("rejects invalid %s=%s", (name, value) => { + const { options } = harness(); + + expect(() => + createNodeSqlParserBrowserExecutor({ + ...options, + [name]: value, + }), + ).toThrow(TypeError); + }); + + it("rejects invalid runtime inputs synchronously", () => { + const { options } = harness(); + const executor = createNodeSqlParserBrowserExecutor(options); + + expect(() => + Reflect.apply(executor.submit, executor, [{ + grammar: "sqlite", + text: "SELECT 1", + }]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(executor.submit, executor, [{ + grammar: "postgresql", + text: 1, + }]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(executor.submit, executor, [ + new Proxy( + {}, + { + get() { + throw new Error("private input getter failure"); + }, + }, + ), + ]), + ).toThrow(TypeError); + }); + + it("rejects hostile and structurally invalid options", () => { + const hostile = new Proxy( + {}, + { + get() { + throw new Error("private options getter failure"); + }, + }, + ); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + hostile, + ]), + ).toThrow(TypeError); + + const { options } = harness(); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + { + ...options, + deadlineScheduler: { + clearTimeout: 1, + setTimeout(): unknown { + return 1; + }, + }, + }, + ]), + ).toThrow(TypeError); + expect(() => + Reflect.apply(createNodeSqlParserBrowserExecutor, undefined, [ + { + ...options, + workerFactory: 1, + }, + ]), + ).toThrow(TypeError); + }); +}); diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js new file mode 100644 index 0000000..530f6a4 --- /dev/null +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-crash-worker.js @@ -0,0 +1,7 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 1, +}); +globalThis.addEventListener("message", () => { + throw new Error("Intentional parser executor crash fixture"); +}); diff --git a/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js new file mode 100644 index 0000000..b2c2edd --- /dev/null +++ b/src/vnext/browser_tests/fixtures/node-sql-parser-silent-worker.js @@ -0,0 +1,5 @@ +globalThis.postMessage({ + kind: "ready", + protocolVersion: 1, +}); +globalThis.addEventListener("message", () => {}); diff --git a/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts new file mode 100644 index 0000000..08a6b9b --- /dev/null +++ b/src/vnext/browser_tests/node-sql-parser-browser-executor.test.ts @@ -0,0 +1,278 @@ +import { expect, test } from "vitest"; +import { + createNodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutor, + type NodeSqlParserBrowserExecutorEventType, + type NodeSqlParserBrowserExecutorWorker, +} from "../node-sql-parser-browser-executor.js"; + +const REAL_WORKER_LIMITS = Object.freeze({ + executionDeadlineMs: 4_000, + maxQueuedRequests: 4, + maxQueuedTextUnits: 32_768, + queueDeadlineMs: 4_000, + startupDeadlineMs: 4_000, +}); +const FAILURE_WORKER_LIMITS = Object.freeze({ + ...REAL_WORKER_LIMITS, + executionDeadlineMs: 250, + queueDeadlineMs: 250, + startupDeadlineMs: 250, +}); + +function adaptWorker( + worker: Worker, +): NodeSqlParserBrowserExecutorWorker { + type Listener = (event: unknown) => void; + const errorListeners = new Map< + Listener, + (event: ErrorEvent) => void + >(); + const messageListeners = new Map< + Listener, + (event: MessageEvent) => void + >(); + const messageErrorListeners = new Map< + Listener, + (event: MessageEvent) => void + >(); + + return { + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: Listener, + ): void { + switch (type) { + case "error": { + const adapter = (event: ErrorEvent): void => { + listener(event); + }; + errorListeners.set(listener, adapter); + worker.addEventListener("error", adapter); + return; + } + case "message": { + const adapter = (event: MessageEvent): void => { + listener(event); + }; + messageListeners.set(listener, adapter); + worker.addEventListener("message", adapter); + return; + } + case "messageerror": { + const adapter = (event: MessageEvent): void => { + listener(event); + }; + messageErrorListeners.set(listener, adapter); + worker.addEventListener("messageerror", adapter); + } + } + }, + postMessage(message: unknown): void { + worker.postMessage(message); + }, + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: Listener, + ): void { + switch (type) { + case "error": { + const adapter = errorListeners.get(listener); + if (adapter !== undefined) { + errorListeners.delete(listener); + worker.removeEventListener("error", adapter); + } + return; + } + case "message": { + const adapter = messageListeners.get(listener); + if (adapter !== undefined) { + messageListeners.delete(listener); + worker.removeEventListener("message", adapter); + } + return; + } + case "messageerror": { + const adapter = messageErrorListeners.get(listener); + if (adapter !== undefined) { + messageErrorListeners.delete(listener); + worker.removeEventListener("messageerror", adapter); + } + } + } + }, + terminate(): void { + worker.terminate(); + errorListeners.clear(); + messageListeners.clear(); + messageErrorListeners.clear(); + }, + }; +} + +function createSilentWorker(): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "./fixtures/node-sql-parser-silent-worker.js", + import.meta.url, + ), + { + name: "codemirror-sql-parser-silent-test", + type: "module", + }, + ), + ); +} + +function createCrashWorker(): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "./fixtures/node-sql-parser-crash-worker.js", + import.meta.url, + ), + { + name: "codemirror-sql-parser-crash-test", + type: "module", + }, + ), + ); +} + +function createRecoveryWorker( + name: string, +): NodeSqlParserBrowserExecutorWorker { + return adaptWorker( + new Worker( + new URL( + "../node-sql-parser-browser-worker.ts", + import.meta.url, + ), + { name, type: "module" }, + ), + ); +} + +function submitQuery( + executor: NodeSqlParserBrowserExecutor, + grammar: "bigquery" | "postgresql", + text: string, +) { + return executor.submit({ grammar, text }).result; +} + +test( + "runs sequential cold, warm, and cross-grammar work through the production worker", + { timeout: 15_000 }, + async () => { + const executor = + createNodeSqlParserBrowserExecutor(REAL_WORKER_LIMITS); + try { + await expect( + submitQuery( + executor, + "postgresql", + "SELECT 1 AS cold_value", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "postgresql", + "SELECT 2 AS warm_value", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + await expect( + submitQuery( + executor, + "bigquery", + "SELECT `project.dataset.table`.id FROM `project.dataset.table`", + ), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + } finally { + executor.dispose(); + } + }, +); + +test( + "retires a silent active parse and serves later work on a fresh generation", + { timeout: 10_000 }, + async () => { + let generation = 0; + const executor = createNodeSqlParserBrowserExecutor({ + ...FAILURE_WORKER_LIMITS, + workerFactory: () => { + generation += 1; + return generation === 1 + ? createSilentWorker() + : createRecoveryWorker( + "codemirror-sql-parser-recovery-test", + ); + }, + }); + try { + await expect( + submitQuery(executor, "postgresql", "SELECT 1"), + ).resolves.toStrictEqual({ + code: "execution-timeout", + kind: "failed", + }); + await expect( + submitQuery(executor, "postgresql", "SELECT 2"), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(generation).toBe(2); + } finally { + executor.dispose(); + } + }, +); + +test( + "retires a crashed active parse and serves later work on a fresh generation", + { timeout: 10_000 }, + async () => { + let generation = 0; + const executor = createNodeSqlParserBrowserExecutor({ + ...FAILURE_WORKER_LIMITS, + workerFactory: () => { + generation += 1; + return generation === 1 + ? createCrashWorker() + : createRecoveryWorker( + "codemirror-sql-parser-crash-recovery-test", + ); + }, + }); + try { + await expect( + submitQuery(executor, "postgresql", "SELECT 1"), + ).resolves.toStrictEqual({ + code: "worker-failure", + kind: "failed", + }); + await expect( + submitQuery(executor, "bigquery", "SELECT 2"), + ).resolves.toStrictEqual({ + kind: "parsed", + statementKind: "query", + }); + expect(generation).toBe(2); + } finally { + executor.dispose(); + } + }, +); diff --git a/src/vnext/node-sql-parser-browser-executor.ts b/src/vnext/node-sql-parser-browser-executor.ts new file mode 100644 index 0000000..f250ec6 --- /dev/null +++ b/src/vnext/node-sql-parser-browser-executor.ts @@ -0,0 +1,1132 @@ +import { + MAX_NODE_SQL_PARSER_STATEMENT_LENGTH, +} from "./node-sql-parser-backend.js"; +import { + decodeNodeSqlParserWireMessage, + encodeNodeSqlParserWireRequest, + type NodeSqlParserWireFailureCode, + type NodeSqlParserWireGrammar, +} from "./node-sql-parser-wire.js"; +import type { SqlStatementKind } from "./syntax.js"; + +export interface NodeSqlParserBrowserExecutorLimits { + readonly executionDeadlineMs: number; + readonly maxQueuedRequests: number; + readonly maxQueuedTextUnits: number; + readonly queueDeadlineMs: number; + readonly startupDeadlineMs: number; +} + +export type NodeSqlParserBrowserExecutorEventType = + | "error" + | "message" + | "messageerror"; + +export interface NodeSqlParserBrowserExecutorWorker { + readonly addEventListener: ( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ) => void; + readonly postMessage: (message: unknown) => void; + readonly removeEventListener: ( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ) => void; + readonly terminate: () => void; +} + +export interface NodeSqlParserBrowserExecutorDeadlineScheduler { + readonly clearTimeout: (handle: unknown) => void; + readonly setTimeout: ( + callback: () => void, + delayMs: number, + ) => unknown; +} + +export interface NodeSqlParserBrowserExecutorOptions + extends NodeSqlParserBrowserExecutorLimits { + readonly deadlineScheduler?: NodeSqlParserBrowserExecutorDeadlineScheduler; + readonly requestIdStart?: number; + readonly workerFactory?: () => NodeSqlParserBrowserExecutorWorker; +} + +export interface NodeSqlParserBrowserExecutorInput { + readonly grammar: NodeSqlParserWireGrammar; + readonly text: string; +} + +export type NodeSqlParserBrowserExecutorFailureCode = + | NodeSqlParserWireFailureCode + | "disposed" + | "execution-timeout" + | "protocol-error" + | "queue-limit" + | "queue-timeout" + | "startup-timeout" + | "worker-failure"; + +export type NodeSqlParserBrowserExecutorOutcome = + | { + readonly kind: "parsed"; + readonly statementKind: SqlStatementKind; + } + | { + readonly kind: "syntax-rejected"; + } + | { + readonly kind: "unsupported"; + readonly reason: "multiple-statements" | "resource-limit"; + } + | { + readonly kind: "failed"; + readonly code: NodeSqlParserBrowserExecutorFailureCode; + } + | { + readonly kind: "cancelled"; + }; + +export interface NodeSqlParserBrowserExecutorSubmission { + readonly cancel: () => void; + readonly result: Promise; +} + +export interface NodeSqlParserBrowserExecutor { + readonly dispose: () => void; + readonly submit: ( + input: NodeSqlParserBrowserExecutorInput, + ) => NodeSqlParserBrowserExecutorSubmission; +} + +interface Deadline { + readonly handle: unknown; +} + +interface DetachedQueueEntry { + readonly deadline: Deadline | null; + readonly entry: QueueEntry; +} + +interface DetachedActiveRequest { + readonly deadline: Deadline | null; + readonly request: ActiveRequest; +} + +interface RevokedGeneration { + readonly startupDeadline: Deadline | null; + readonly target: WorkerGeneration; +} + +interface QueueEntry { + consumerSettled: boolean; + grammar: NodeSqlParserWireGrammar; + location: "active" | "done" | "queued"; + queueDeadline: Deadline | null; + resolve: + | ((outcome: NodeSqlParserBrowserExecutorOutcome) => void) + | null; + text: string; + textUnits: number; +} + +interface ActiveRequest { + draining: boolean; + readonly entry: QueueEntry; + executionDeadline: Deadline | null; + readonly generation: WorkerGeneration; + posted: boolean; + readonly requestId: number; +} + +interface WorkerGeneration { + readonly onFailure: (event: unknown) => void; + readonly onMessage: (event: unknown) => void; + startupDeadline: Deadline | null; + state: "ready" | "retired" | "starting"; + readonly worker: NodeSqlParserBrowserExecutorWorker; +} + +interface NormalizedOptions extends NodeSqlParserBrowserExecutorLimits { + readonly deadlineScheduler: NodeSqlParserBrowserExecutorDeadlineScheduler; + readonly requestIdStart: number; + readonly workerFactory: () => NodeSqlParserBrowserExecutorWorker; +} + +const DEFAULT_DEADLINE_SCHEDULER: NodeSqlParserBrowserExecutorDeadlineScheduler = + Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(globalThis.clearTimeout, globalThis, [handle]); + }, + setTimeout(callback: () => void, delayMs: number): unknown { + return globalThis.setTimeout(callback, delayMs); + }, + }); + +function createDefaultWorker(): NodeSqlParserBrowserExecutorWorker { + const worker = new Worker( + new URL("./node-sql-parser-browser-worker.js", import.meta.url), + { name: "codemirror-sql-parser", type: "module" }, + ); + return Object.freeze({ + addEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): void { + worker.addEventListener(type, listener); + }, + postMessage(message: unknown): void { + worker.postMessage(message); + }, + removeEventListener( + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): void { + worker.removeEventListener(type, listener); + }, + terminate(): void { + worker.terminate(); + }, + }); +} + +function requirePositiveSafeInteger( + value: number, + label: string, +): void { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 + ) { + throw new TypeError(`${label} must be a positive safe integer`); + } +} + +function requireDeadline(value: number, label: string): void { + requirePositiveSafeInteger(value, label); + if (value > 2_147_483_647) { + throw new TypeError(`${label} exceeds the platform timer limit`); + } +} + +function normalizeDeadlineScheduler( + scheduler: + | NodeSqlParserBrowserExecutorDeadlineScheduler + | undefined, +): NodeSqlParserBrowserExecutorDeadlineScheduler { + if (scheduler === undefined) { + return DEFAULT_DEADLINE_SCHEDULER; + } + let clearTimeoutMethod: (handle: unknown) => void; + let setTimeoutMethod: ( + callback: () => void, + delayMs: number, + ) => unknown; + try { + clearTimeoutMethod = scheduler.clearTimeout; + setTimeoutMethod = scheduler.setTimeout; + if ( + typeof clearTimeoutMethod !== "function" || + typeof setTimeoutMethod !== "function" + ) { + throw new TypeError(); + } + } catch { + throw new TypeError( + "deadlineScheduler must implement deadlines", + ); + } + return Object.freeze({ + clearTimeout(handle: unknown): void { + Reflect.apply(clearTimeoutMethod, scheduler, [handle]); + }, + setTimeout(callback: () => void, delayMs: number): unknown { + return Reflect.apply(setTimeoutMethod, scheduler, [ + callback, + delayMs, + ]); + }, + }); +} + +function normalizeOptions( + options: NodeSqlParserBrowserExecutorOptions, +): NormalizedOptions { + let deadlineScheduler: + | NodeSqlParserBrowserExecutorDeadlineScheduler + | undefined; + let executionDeadlineMs: number; + let maxQueuedRequests: number; + let maxQueuedTextUnits: number; + let queueDeadlineMs: number; + let requestIdStart: number | undefined; + let startupDeadlineMs: number; + let workerFactory: + | (() => NodeSqlParserBrowserExecutorWorker) + | undefined; + try { + deadlineScheduler = options.deadlineScheduler; + executionDeadlineMs = options.executionDeadlineMs; + maxQueuedRequests = options.maxQueuedRequests; + maxQueuedTextUnits = options.maxQueuedTextUnits; + queueDeadlineMs = options.queueDeadlineMs; + requestIdStart = options.requestIdStart; + startupDeadlineMs = options.startupDeadlineMs; + workerFactory = options.workerFactory; + } catch { + throw new TypeError("invalid parser executor options"); + } + requireDeadline( + executionDeadlineMs, + "executionDeadlineMs", + ); + requirePositiveSafeInteger( + maxQueuedRequests, + "maxQueuedRequests", + ); + requirePositiveSafeInteger( + maxQueuedTextUnits, + "maxQueuedTextUnits", + ); + requireDeadline( + queueDeadlineMs, + "queueDeadlineMs", + ); + requireDeadline( + startupDeadlineMs, + "startupDeadlineMs", + ); + if (requestIdStart !== undefined) { + requirePositiveSafeInteger(requestIdStart, "requestIdStart"); + } + if ( + workerFactory !== undefined && + typeof workerFactory !== "function" + ) { + throw new TypeError("workerFactory must be a function"); + } + return Object.freeze({ + deadlineScheduler: normalizeDeadlineScheduler( + deadlineScheduler, + ), + executionDeadlineMs, + maxQueuedRequests, + maxQueuedTextUnits, + queueDeadlineMs, + requestIdStart: requestIdStart ?? 1, + startupDeadlineMs, + workerFactory: workerFactory ?? createDefaultWorker, + }); +} + +function requireInput( + input: NodeSqlParserBrowserExecutorInput, +): NodeSqlParserBrowserExecutorInput { + try { + const grammar = input.grammar; + const text = input.text; + if ( + (grammar !== "bigquery" && grammar !== "postgresql") || + typeof text !== "string" + ) { + throw new TypeError("invalid parser executor input"); + } + return Object.freeze({ + grammar, + text, + }); + } catch { + throw new TypeError("invalid parser executor input"); + } +} + +function failedOutcome( + code: NodeSqlParserBrowserExecutorFailureCode, +): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ code, kind: "failed" }); +} + +function cancelledOutcome(): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ kind: "cancelled" }); +} + +function resourceLimitOutcome(): NodeSqlParserBrowserExecutorOutcome { + return Object.freeze({ + kind: "unsupported", + reason: "resource-limit", + }); +} + +function readMessageData(event: unknown): unknown { + if (typeof event !== "object" || event === null) { + return undefined; + } + try { + return Reflect.get(event, "data"); + } catch { + return undefined; + } +} + +function preventDefault(event: unknown): void { + if (typeof event !== "object" || event === null) { + return; + } + try { + const method = Reflect.get(event, "preventDefault"); + if (typeof method === "function") { + Reflect.apply(method, event, []); + } + } catch { + // Worker failures remain closed for hostile event objects. + } +} + +function isWorkerIdentity( + value: unknown, +): value is object { + return typeof value === "object" && value !== null; +} + +export function createNodeSqlParserBrowserExecutor( + options: NodeSqlParserBrowserExecutorOptions, +): NodeSqlParserBrowserExecutor { + const normalized = normalizeOptions(options); + + const limits = Object.freeze({ + executionDeadlineMs: normalized.executionDeadlineMs, + maxQueuedRequests: normalized.maxQueuedRequests, + maxQueuedTextUnits: normalized.maxQueuedTextUnits, + queueDeadlineMs: normalized.queueDeadlineMs, + startupDeadlineMs: normalized.startupDeadlineMs, + }); + const scheduler = normalized.deadlineScheduler; + const workerFactory = normalized.workerFactory; + + let active: ActiveRequest | null = null; + let creatingWorker = false; + let disposed = false; + let generation: WorkerGeneration | null = null; + let nextRequestId = normalized.requestIdStart; + let pumping = false; + let pumpRequested = false; + let queuedTextUnits = 0; + let retirementDepth = 0; + let terminalFailure: + | "protocol-error" + | "worker-failure" + | null = null; + const queue: QueueEntry[] = []; + const seenWorkers = new WeakSet(); + + function clearDeadline(deadline: Deadline | null): void { + if (deadline === null) { + return; + } + try { + scheduler.clearTimeout(deadline.handle); + } catch { + // Deadline cleanup cannot be allowed to expose host failures. + } + } + + function scheduleDeadline( + callback: () => void, + delayMs: number, + ): Deadline | null { + let fired = false; + let handle: unknown; + try { + handle = scheduler.setTimeout(() => { + fired = true; + callback(); + }, delayMs); + } catch { + callback(); + return null; + } + if (fired) { + clearDeadline({ handle }); + return null; + } + return { handle }; + } + + function settleConsumer( + entry: QueueEntry, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + if (entry.consumerSettled) { + return; + } + entry.consumerSettled = true; + const resolve = entry.resolve; + entry.resolve = null; + resolve?.(outcome); + } + + function removeQueuedEntry(entry: QueueEntry): boolean { + if (entry.location !== "queued") { + return false; + } + const index = queue.indexOf(entry); + if (index < 0) { + return false; + } + queue.splice(index, 1); + queuedTextUnits -= entry.textUnits; + entry.location = "done"; + const deadline = entry.queueDeadline; + entry.queueDeadline = null; + entry.text = ""; + entry.textUnits = 0; + clearDeadline(deadline); + return true; + } + + function detachAllQueued(): readonly DetachedQueueEntry[] { + const entries = queue.splice(0); + queuedTextUnits = 0; + return entries.map((entry) => { + const deadline = entry.queueDeadline; + entry.location = "done"; + entry.queueDeadline = null; + entry.text = ""; + entry.textUnits = 0; + return { deadline, entry }; + }); + } + + function cleanupDetachedQueue( + detached: readonly DetachedQueueEntry[], + ): void { + for (const { deadline } of detached) { + clearDeadline(deadline); + } + } + + function settleDetachedQueue( + detached: readonly DetachedQueueEntry[], + createOutcome: () => NodeSqlParserBrowserExecutorOutcome, + ): void { + cleanupDetachedQueue(detached); + for (const { entry } of detached) { + settleConsumer(entry, createOutcome()); + } + } + + function detachActiveRequest( + target?: WorkerGeneration, + ): DetachedActiveRequest | null { + const request = active; + if ( + request === null || + (target !== undefined && request.generation !== target) + ) { + return null; + } + active = null; + const deadline = request.executionDeadline; + request.executionDeadline = null; + request.entry.location = "done"; + request.entry.text = ""; + request.entry.textUnits = 0; + return { deadline, request }; + } + + function cleanupDetachedActive( + detached: DetachedActiveRequest | null, + ): void { + if (detached !== null) { + clearDeadline(detached.deadline); + } + } + + function settleDetachedActive( + detached: DetachedActiveRequest | null, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + cleanupDetachedActive(detached); + if (detached !== null && !detached.request.draining) { + settleConsumer(detached.request.entry, outcome); + } + } + + function revokeGeneration( + target: WorkerGeneration, + ): RevokedGeneration | null { + if (target.state === "retired" || generation !== target) { + return null; + } + target.state = "retired"; + generation = null; + const startupDeadline = target.startupDeadline; + target.startupDeadline = null; + return { startupDeadline, target }; + } + + function cleanupRevokedGeneration( + revoked: RevokedGeneration | null, + ): boolean { + if (revoked === null) { + return true; + } + clearDeadline(revoked.startupDeadline); + removeGenerationListeners(revoked.target); + try { + revoked.target.worker.terminate(); + return true; + } catch { + return false; + } + } + + function rejectAllQueued( + createOutcome: () => NodeSqlParserBrowserExecutorOutcome, + ): void { + const detached = detachAllQueued(); + settleDetachedQueue(detached, createOutcome); + } + + function enterTerminalFailure( + code: "protocol-error" | "worker-failure", + ): void { + if (terminalFailure !== null || disposed) { + return; + } + terminalFailure = code; + rejectAllQueued(() => failedOutcome(code)); + } + + function removeGenerationListeners( + target: WorkerGeneration, + ): void { + for (const type of [ + "message", + "error", + "messageerror", + ] as const) { + try { + target.worker.removeEventListener( + type, + type === "message" ? target.onMessage : target.onFailure, + ); + } catch { + // Termination below remains authoritative. + } + } + } + + function installGenerationListener( + target: WorkerGeneration, + type: NodeSqlParserBrowserExecutorEventType, + listener: (event: unknown) => void, + ): boolean { + try { + target.worker.addEventListener(type, listener); + } catch { + failGeneration(target, "worker-failure"); + try { + target.worker.removeEventListener(type, listener); + } catch { + // The retired generation is never reused. + } + return false; + } + if (generation === target && target.state !== "retired") { + return true; + } + try { + target.worker.removeEventListener(type, listener); + } catch { + // The retired generation is never reused. + } + return false; + } + + function startGeneration(): void { + if ( + disposed || + terminalFailure !== null || + creatingWorker || + generation !== null || + queue.length === 0 || + retirementDepth > 0 + ) { + return; + } + + let worker: NodeSqlParserBrowserExecutorWorker; + creatingWorker = true; + try { + worker = workerFactory(); + } catch { + creatingWorker = false; + rejectAllQueued(() => failedOutcome("worker-failure")); + return; + } + if ( + !isWorkerIdentity(worker) || + seenWorkers.has(worker) + ) { + creatingWorker = false; + rejectAllQueued(() => failedOutcome("worker-failure")); + return; + } + seenWorkers.add(worker); + creatingWorker = false; + if (disposed || generation !== null || queue.length === 0) { + try { + worker.terminate(); + } catch { + enterTerminalFailure("worker-failure"); + } + return; + } + + const target: WorkerGeneration = { + onFailure: (event) => { + if (generation !== target || target.state === "retired") { + return; + } + failGeneration(target, "worker-failure", () => { + preventDefault(event); + }); + }, + onMessage: (event) => { + if (generation !== target || target.state === "retired") { + return; + } + receiveMessage(target, readMessageData(event)); + }, + startupDeadline: null, + state: "starting", + worker, + }; + generation = target; + + const deadline = scheduleDeadline(() => { + if (generation === target && target.state === "starting") { + failGeneration(target, "startup-timeout"); + } + }, limits.startupDeadlineMs); + if (generation !== target || target.state !== "starting") { + clearDeadline(deadline); + return; + } + target.startupDeadline = deadline; + + if ( + !installGenerationListener( + target, + "error", + target.onFailure, + ) || + !installGenerationListener( + target, + "messageerror", + target.onFailure, + ) || + !installGenerationListener( + target, + "message", + target.onMessage, + ) + ) { + return; + } + } + + function failGeneration( + target: WorkerGeneration, + code: NodeSqlParserBrowserExecutorFailureCode, + afterRevocation?: () => void, + ): void { + if (generation !== target || target.state === "retired") { + return; + } + retirementDepth += 1; + try { + const failedDuringStartup = target.state === "starting"; + const detachedActive = detachActiveRequest(target); + const detachedQueue = failedDuringStartup + ? detachAllQueued() + : []; + const revoked = revokeGeneration(target); + + cleanupDetachedActive(detachedActive); + cleanupDetachedQueue(detachedQueue); + const retired = cleanupRevokedGeneration(revoked); + afterRevocation?.(); + if (!retired) { + enterTerminalFailure("worker-failure"); + } + + for (const { entry } of detachedQueue) { + settleConsumer(entry, failedOutcome(code)); + } + if ( + detachedActive !== null && + !detachedActive.request.draining + ) { + settleConsumer( + detachedActive.request.entry, + failedOutcome(code), + ); + } + } finally { + retirementDepth -= 1; + pump(); + } + } + + function finishActive( + request: ActiveRequest, + outcome: NodeSqlParserBrowserExecutorOutcome, + ): void { + const detached = detachActiveRequest(request.generation); + settleDetachedActive(detached, outcome); + pump(); + } + + function receiveMessage( + target: WorkerGeneration, + data: unknown, + ): void { + const message = decodeNodeSqlParserWireMessage(data); + if (message === null) { + failGeneration(target, "protocol-error"); + return; + } + + if (message.kind === "ready") { + if (target.state !== "starting" || active !== null) { + failGeneration(target, "protocol-error"); + return; + } + target.state = "ready"; + const startupDeadline = target.startupDeadline; + target.startupDeadline = null; + clearDeadline(startupDeadline); + if (generation === target && target.state === "ready") { + pump(); + } + return; + } + + if (message.kind === "protocol-error") { + failGeneration(target, "protocol-error"); + return; + } + + const request = active; + if ( + target.state !== "ready" || + request === null || + request.generation !== target || + !request.posted || + message.requestId !== request.requestId + ) { + failGeneration(target, "protocol-error"); + return; + } + + switch (message.kind) { + case "parsed": + finishActive( + request, + Object.freeze({ + kind: "parsed", + statementKind: message.statementKind, + }), + ); + return; + case "syntax-rejected": + finishActive( + request, + Object.freeze({ kind: "syntax-rejected" }), + ); + return; + case "unsupported": + finishActive( + request, + Object.freeze({ + kind: "unsupported", + reason: message.reason, + }), + ); + return; + case "failed": { + failGeneration(target, message.code); + return; + } + } + } + + function allocateRequestId(): number | null { + if (nextRequestId > Number.MAX_SAFE_INTEGER) { + return null; + } + const requestId = nextRequestId; + nextRequestId += 1; + return requestId; + } + + function pumpOnce(): void { + if ( + disposed || + terminalFailure !== null || + active !== null || + queue.length === 0 + ) { + return; + } + if (generation === null) { + startGeneration(); + return; + } + if (generation.state !== "ready") { + return; + } + + const requestId = allocateRequestId(); + if (requestId === null) { + retirementDepth += 1; + try { + terminalFailure = "protocol-error"; + const detachedQueue = detachAllQueued(); + const revoked = revokeGeneration(generation); + cleanupDetachedQueue(detachedQueue); + cleanupRevokedGeneration(revoked); + for (const { entry } of detachedQueue) { + settleConsumer( + entry, + failedOutcome("protocol-error"), + ); + } + } finally { + retirementDepth -= 1; + } + return; + } + + const entry = queue.shift(); + if (entry === undefined) { + return; + } + const target = generation; + const queueDeadline = entry.queueDeadline; + queuedTextUnits -= entry.textUnits; + entry.queueDeadline = null; + entry.location = "active"; + const request: ActiveRequest = { + draining: false, + entry, + executionDeadline: null, + generation: target, + posted: false, + requestId, + }; + active = request; + clearDeadline(queueDeadline); + if ( + disposed || + generation !== target || + target.state !== "ready" || + active !== request + ) { + return; + } + + const executionDeadline = scheduleDeadline(() => { + if ( + generation === target && + active === request && + target.state === "ready" + ) { + failGeneration(target, "execution-timeout"); + } + }, limits.executionDeadlineMs); + if (generation === target && active === request) { + request.executionDeadline = executionDeadline; + } else { + clearDeadline(executionDeadline); + return; + } + + let postMessage: (message: unknown) => void; + try { + postMessage = target.worker.postMessage; + if (typeof postMessage !== "function") { + throw new TypeError("worker postMessage must be callable"); + } + } catch { + failGeneration(target, "worker-failure"); + return; + } + if ( + disposed || + generation !== target || + target.state !== "ready" || + active !== request + ) { + return; + } + + try { + const wireRequest = encodeNodeSqlParserWireRequest( + entry.grammar, + requestId, + entry.text, + ); + request.posted = true; + Reflect.apply(postMessage, target.worker, [wireRequest]); + entry.text = ""; + entry.textUnits = 0; + } catch { + entry.text = ""; + entry.textUnits = 0; + failGeneration(target, "worker-failure"); + } + } + + function pump(): void { + pumpRequested = true; + if (pumping || retirementDepth > 0) { + return; + } + pumping = true; + try { + while (pumpRequested) { + pumpRequested = false; + pumpOnce(); + } + } finally { + pumping = false; + } + } + + function cancel(entry: QueueEntry): void { + if (entry.location === "queued") { + if (removeQueuedEntry(entry)) { + settleConsumer(entry, cancelledOutcome()); + } + return; + } + if ( + entry.location === "active" && + active !== null && + active.entry === entry + ) { + active.draining = true; + if (active.posted) { + entry.text = ""; + entry.textUnits = 0; + } + settleConsumer(entry, cancelledOutcome()); + } + } + + function immediateSubmission( + outcome: NodeSqlParserBrowserExecutorOutcome, + ): NodeSqlParserBrowserExecutorSubmission { + return Object.freeze({ + cancel(): void {}, + result: Promise.resolve(outcome), + }); + } + + function submit( + rawInput: NodeSqlParserBrowserExecutorInput, + ): NodeSqlParserBrowserExecutorSubmission { + const input = requireInput(rawInput); + if (disposed) { + return immediateSubmission(failedOutcome("disposed")); + } + if (terminalFailure !== null) { + return immediateSubmission(failedOutcome(terminalFailure)); + } + if (input.text.length > MAX_NODE_SQL_PARSER_STATEMENT_LENGTH) { + return immediateSubmission(resourceLimitOutcome()); + } + if ( + queue.length >= limits.maxQueuedRequests || + input.text.length > + limits.maxQueuedTextUnits - queuedTextUnits + ) { + return immediateSubmission(failedOutcome("queue-limit")); + } + + let resolve: + | ((outcome: NodeSqlParserBrowserExecutorOutcome) => void) + | undefined; + const result = new Promise( + (settle) => { + resolve = settle; + }, + ); + if (resolve === undefined) { + throw new Error("parser executor promise was not initialized"); + } + const entry: QueueEntry = { + consumerSettled: false, + grammar: input.grammar, + location: "queued", + queueDeadline: null, + resolve, + text: input.text, + textUnits: input.text.length, + }; + queue.push(entry); + queuedTextUnits += entry.textUnits; + + const queueDeadline = scheduleDeadline(() => { + if (removeQueuedEntry(entry)) { + settleConsumer(entry, failedOutcome("queue-timeout")); + } + }, limits.queueDeadlineMs); + if (entry.location === "queued") { + entry.queueDeadline = queueDeadline; + } else { + clearDeadline(queueDeadline); + } + pump(); + + return Object.freeze({ + cancel: () => { + cancel(entry); + }, + result, + }); + } + + function dispose(): void { + if (disposed) { + return; + } + disposed = true; + const target = generation; + const detachedActive = detachActiveRequest(); + const detachedQueue = detachAllQueued(); + const revoked = + target === null ? null : revokeGeneration(target); + + cleanupDetachedActive(detachedActive); + cleanupDetachedQueue(detachedQueue); + cleanupRevokedGeneration(revoked); + + for (const { entry } of detachedQueue) { + settleConsumer(entry, failedOutcome("disposed")); + } + if ( + detachedActive !== null && + !detachedActive.request.draining + ) { + settleConsumer( + detachedActive.request.entry, + failedOutcome("disposed"), + ); + } + } + + return Object.freeze({ dispose, submit }); +}