Files
orca/src/main/worker-thread-request-queue.test.ts
T
Jinwoo Hong f8a31edd6f refactor(cursor): move the desktop-login read onto the shared foreign SQLite reader worker (#24603)
* refactor(sqlite): rename the OpenCode SQLite worker entry to foreign-sqlite-reader (STA-9122)

The worker thread that reads OpenCode's database off the main thread is about
to read other apps' databases too, so its entry is renamed to what it is:
src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts, built as
out/main/foreign-sqlite-reader-entry.js.

Why now: #24572 fixed the Cursor focus freeze with a second, dedicated
worker. Rather than grow one worker per foreign app, the next commit moves
Cursor onto this entry and deletes that worker. This commit is the rename
only; #24572's cursor-desktop-profile-worker-entry lines stay until then.

It moves out of ai-vault/ into a new foreign-sqlite-readers/ module because
it will no longer be session-scanner code; the module will own the readers,
their dispatch, protocol and main-process client.

The entry still routes only OpenCode kinds in this commit. The OpenCode
dispatch, protocol and process entry stay in ai-vault/ and stay OpenCode-only,
because the SSH/WSL relay reader bundles them (build-relay.mjs).

Every reference is updated: electron.vite.config.ts input key, knip entry,
the plain-node entry guard and its test, the asarUnpack list (the scanner
service still spawns this entry under ELECTRON_RUN_AS_NODE), and the
electron-builder test that reads the filename. The filename and the
beside-or-one-up (Rollup chunks) lookup now live in
foreign-sqlite-reader-entry-path.ts, which the OpenCode spawn reuses, plus an
Electron-main resolver that uses the packaged app.asar path.

* fix(cursor): move the desktop-login read from its dedicated worker onto the foreign SQLite reader (STA-9122)

#24572 fixed the Cursor focus freeze (#24360) with a dedicated worker
(rate-limits/cursor-desktop-profile-worker*.ts). Orca already runs OpenCode's
database reads on a worker, and more foreign-app SQLite reads are coming, so
keeping one worker per app means one entry, build input, asarUnpack line, knip
entry and guard line each. This keeps one pattern instead: Cursor's
state.vscdb read runs on the shared foreign SQLite reader entry, and the
dedicated worker, its entry and its config lines are deleted.

What moves:
- The read itself is a pure cursorProfile reader in
  foreign-sqlite-readers/readers/ (was rate-limits/cursor-desktop-state-db.ts),
  run only on the worker. A separate dispatch owns the new kinds and refuses
  an unknown kind. The entry routes OpenCode kinds to the untouched OpenCode
  dispatch, so the relay's OpenCode reader stays byte-identical.
- ForeignSqliteReaderClient gives each reader its own WorkerThreadRequestQueue
  lane (own lazily started, idle-torn-down thread; one shared factory) with
  in-flight dedupe per database path. Any failure resolves to the reader's
  existing failure value and never falls back to the main thread.

Kept from #24572, so every reader gets them:
- Await worker retirement before respawning. Worker.terminate() cannot
  interrupt a native SQLite call (e.g. a WAL-index rebuild), so the old
  thread lives on until that call returns; respawning at once stacked a new
  thread on the same work for every timed-out read (#24572 measured three
  live workers). This belongs in the shared host, which fire-and-forgot
  terminate(): LazyWorkerThreadHost now takes awaitRetirement and refuses to
  spawn until the terminated worker settles, and the queue fails calls closed
  meanwhile. Opt-in, because pure-JS clients (session scanner abort, port
  scan) respawn right after an abort. A rejected terminate() also ends
  retirement, so it cannot latch the reader off (raised in #24572's review).
- 10 s Cursor timeout, 2 consecutive deaths, a queue cap of 8.
- #24572's worker tests, rewritten against the shared client: responsive
  caller plus coalesced probes, unavailable worker without path leaks,
  stalled-worker recovery, no respawn before retirement, dispose settles.

Tests: reader, dispatch, client (timeout, 10 s default, crash, malformed,
unavailable without a main-thread read, dedupe, queue cap, own thread per
reader), queue retirement (stalled and rejected terminate), import boundary,
and an event-loop test reading a ~50 MB WAL with no -shm on a real worker.

* test(sqlite): walk the reader import boundary with the shared source-tree scan (STA-9122)

* fix(sqlite): key reader dedupe on a caller-supplied key, not the path alone (STA-9122)
2026-10-02 19:00:41 -04:00

388 lines
14 KiB
TypeScript

import type { Worker } from 'node:worker_threads'
import { getEventListeners } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WorkerThreadRequestQueue } from './worker-thread-request-queue'
// Why a direct test (#20940): three subsystems now share this queue, and each
// client test can only observe the parts its own protocol happens to exercise.
// The contract constrained here is the queue's own — dispatch order, where the
// deadline clock starts, and when the respawn cap counts and clears — so a
// change to it fails here rather than in whichever client noticed first.
type Request = { id: number; label: string }
type Response = { id: number; label: string }
class FakeWorker {
posted: Request[] = []
terminated = false
private listeners = new Map<string, Set<(arg?: unknown) => void>>()
on(event: string, listener: (arg?: unknown) => void): this {
const set = this.listeners.get(event) ?? new Set()
set.add(listener)
this.listeners.set(event, set)
return this
}
off(event: string, listener: (arg?: unknown) => void): this {
this.listeners.get(event)?.delete(listener)
return this
}
removeAllListeners(): void {
this.listeners.clear()
}
unref(): void {}
/** Set to hold termination open, as a native call in the worker does. */
exit: Promise<number> = Promise.resolve(1)
async terminate(): Promise<number> {
this.terminated = true
return this.exit
}
postMessage(request: Request): void {
this.posted.push(request)
}
emit(event: string, arg?: unknown): void {
// Copy first: the host removes its listeners synchronously during a fault.
for (const listener of Array.from(this.listeners.get(event) ?? [])) {
listener(arg)
}
}
/** Answer the request currently in flight. */
respond(): void {
const last = this.posted.at(-1)
if (!last) {
throw new Error('no request posted to fake worker')
}
this.emit('message', { id: last.id, label: last.label })
}
}
const TIMEOUT_MS = 1_000
const IDLE_TEARDOWN_MS = 60_000
const MAX_CONSECUTIVE_DEATHS = 3
function makeQueue(
workers: FakeWorker[],
options: { awaitRetirement?: boolean; makeWorker?: () => FakeWorker } = {}
): WorkerThreadRequestQueue<Request, Response> {
return new WorkerThreadRequestQueue<Request, Response>({
awaitRetirement: options.awaitRetirement,
factory: () => {
const worker = options.makeWorker?.() ?? new FakeWorker()
workers.push(worker)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeWorker implements every Worker member LazyWorkerThreadHost touches (on/off/removeAllListeners/unref/terminate/postMessage); the rest of the Worker surface is never reached.
return worker as unknown as Worker
},
idleTeardownMs: IDLE_TEARDOWN_MS,
maxConsecutiveDeaths: MAX_CONSECUTIVE_DEATHS,
createUnavailableError: (message) => new Error(`unavailable: ${message}`),
describeTimeout: (timeoutMs) => `timed out after ${timeoutMs}ms`,
describeExit: (code) => `exited with code ${code}`,
describeCrashLoop: (lastError) => `crashed repeatedly (${lastError})`,
onUnavailable: () => {}
})
}
function send(
queue: WorkerThreadRequestQueue<Request, Response>,
label: string
): Promise<Response> {
return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS)
}
/** Resolve to the response or to the rejection, so a test can assert on either. */
function settle(promise: Promise<Response>): Promise<unknown> {
return promise.catch((error: unknown) => error)
}
function labels(worker: FakeWorker): string[] {
return worker.posted.map((request) => request.label)
}
describe('WorkerThreadRequestQueue', () => {
afterEach(() => {
vi.useRealTimers()
})
it('cancels queued requests without retiring active work and retires active cancellation', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const active = new AbortController()
const queued = new AbortController()
const first = settle(
queue.dispatch((id) => ({ id, label: 'active' }), TIMEOUT_MS, active.signal)
)
const second = settle(
queue.dispatch((id) => ({ id, label: 'queued' }), TIMEOUT_MS, queued.signal)
)
const third = send(queue, 'survivor')
queued.abort(new Error('queued cancelled'))
await expect(second).resolves.toMatchObject({ message: 'queued cancelled' })
expect(workers[0].terminated).toBe(false)
active.abort(new Error('active cancelled'))
await expect(first).resolves.toMatchObject({ message: 'active cancelled' })
expect(workers[0].terminated).toBe(true)
expect(workers).toHaveLength(2)
expect(labels(workers[1])).toEqual(['survivor'])
workers[0].respond()
workers[1].respond()
await expect(third).resolves.toMatchObject({ label: 'survivor' })
queue.dispose()
})
it('never starts an already aborted request and rejects all work on disposal', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
await expect(
queue.dispatch(
(id) => ({ id, label: 'aborted' }),
TIMEOUT_MS,
AbortSignal.abort(new Error('cancelled'))
)
).rejects.toThrow('cancelled')
expect(workers).toHaveLength(0)
const active = settle(send(queue, 'active'))
const queued = settle(send(queue, 'queued'))
queue.dispose()
await expect(active).resolves.toMatchObject({ message: 'Worker request queue disposed' })
await expect(queued).resolves.toMatchObject({ message: 'Worker request queue disposed' })
await expect(send(queue, 'later')).rejects.toThrow('disposed')
expect(workers[0].terminated).toBe(true)
})
it.each([false, true])(
'does not respawn for queued calls sharing the cancelled active signal (survivor: %s)',
async (hasSurvivor) => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const controller = new AbortController()
const reason = new Error('scan cancelled')
const pending = Array.from({ length: 8 }, (_, index) =>
settle(
queue.dispatch(
(id) => ({ id, label: `cancelled-${index}` }),
TIMEOUT_MS,
controller.signal
)
)
)
const survivor = hasSurvivor ? send(queue, 'survivor') : undefined
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(8)
controller.abort(reason)
expect(await Promise.all(pending)).toEqual(Array.from({ length: 8 }, () => reason))
expect(workers[0].terminated).toBe(true)
expect(workers).toHaveLength(hasSurvivor ? 2 : 1)
expect(workers.flatMap(labels)).toEqual(
hasSurvivor ? ['cancelled-0', 'survivor'] : ['cancelled-0']
)
expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0)
if (survivor) {
workers[1].respond()
await expect(survivor).resolves.toMatchObject({ label: 'survivor' })
}
queue.dispose()
}
)
it('posts one request at a time and in the order it was dispatched', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const first = send(queue, 'a')
const second = send(queue, 'b')
const third = send(queue, 'c')
await Promise.resolve()
expect(workers).toHaveLength(1)
expect(labels(workers[0])).toEqual(['a'])
workers[0].respond()
await expect(first).resolves.toMatchObject({ label: 'a' })
expect(labels(workers[0])).toEqual(['a', 'b'])
workers[0].respond()
await expect(second).resolves.toMatchObject({ label: 'b' })
expect(labels(workers[0])).toEqual(['a', 'b', 'c'])
workers[0].respond()
await expect(third).resolves.toMatchObject({ label: 'c' })
})
it('starts each deadline when the call is posted, not when it was queued', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const first = send(queue, 'slow')
const queued = settle(send(queue, 'behind'))
await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1)
// 'behind' has now waited nearly its whole deadline without being posted.
workers[0].respond()
await expect(first).resolves.toMatchObject({ label: 'slow' })
expect(labels(workers[0])).toEqual(['slow', 'behind'])
// A queue-inclusive clock would already have fired here.
await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1)
workers[0].respond()
await expect(queued).resolves.toMatchObject({ label: 'behind' })
})
it('fires a posted call at its own deadline', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const pending = settle(send(queue, 'silent'))
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
expect(await pending).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
expect(workers[0].terminated).toBe(true)
})
it('stops respawning and fails the rest of the queue once deaths hit the cap', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
const dispatched = ['a', 'b', 'c', 'd'].map((label) => settle(send(queue, label)))
await Promise.resolve()
for (let death = 0; death < MAX_CONSECUTIVE_DEATHS; death++) {
workers.at(-1)?.emit('error', new Error(`boom ${death}`))
}
// Three deaths consumed three calls; the fourth never got a worker.
expect(workers).toHaveLength(MAX_CONSECUTIVE_DEATHS)
expect(labels(workers[0])).toEqual(['a'])
expect(labels(workers[2])).toEqual(['c'])
const settled = await Promise.all(dispatched)
expect(settled.slice(0, 3)).toMatchObject([
{ message: 'boom 0' },
{ message: 'boom 1' },
{ message: 'boom 2' }
])
expect(settled[3]).toMatchObject({ message: 'crashed repeatedly (boom 2)' })
})
it('clears the death count on a successful response mid-queue', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
// One burst, never idle between the faults: the queue stays non-empty
// throughout, so only the success can clear the count.
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map((label) => settle(send(queue, label)))
await Promise.resolve()
workers[0].emit('error', new Error('boom 0'))
workers[1].emit('error', new Error('boom 1'))
expect(await a).toMatchObject({ message: 'boom 0' })
expect(await b).toMatchObject({ message: 'boom 1' })
expect(labels(workers[2])).toEqual(['c'])
workers[2].respond()
expect(await c).toMatchObject({ label: 'c' })
expect(labels(workers[2])).toEqual(['c', 'd'])
workers[2].emit('error', new Error('boom 2'))
expect(await d).toMatchObject({ message: 'boom 2' })
// Without the reset that fault is the third consecutive death and 'e' is
// drained with the crash-loop message instead of posted to a new worker.
expect(labels(workers[3])).toEqual(['e'])
workers[3].respond()
expect(await e).toMatchObject({ label: 'e' })
})
it('clears the death count when a fresh burst starts from full idle', async () => {
const workers: FakeWorker[] = []
const queue = makeQueue(workers)
for (const label of ['a', 'b']) {
const lone = settle(send(queue, label))
await Promise.resolve()
workers.at(-1)?.emit('error', new Error(`boom ${label}`))
expect(await lone).toMatchObject({ message: `boom ${label}` })
}
expect(workers).toHaveLength(2)
// Both deaths drained to an empty queue, so this burst is new work.
const active = settle(send(queue, 'c'))
const behind = settle(send(queue, 'd'))
await Promise.resolve()
workers[2].emit('error', new Error('boom c'))
expect(await active).toMatchObject({ message: 'boom c' })
// Without the reset this would be the third consecutive death and 'd' would
// have been drained with the crash-loop message instead of posted.
expect(labels(workers[3])).toEqual(['d'])
workers[3].respond()
expect(await behind).toMatchObject({ label: 'd' })
})
describe('awaitRetirement', () => {
function stalledExit(): { worker: FakeWorker; finish: (code: number) => void } {
const worker = new FakeWorker()
let finish: (code: number) => void = () => {}
worker.exit = new Promise((resolve) => {
finish = resolve
})
return { worker, finish }
}
it('fails calls closed until the timed-out worker exits, then spawns again', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const stalled = stalledExit()
const queue = makeQueue(workers, {
awaitRetirement: true,
makeWorker: () => (workers.length === 0 ? stalled.worker : new FakeWorker())
})
const first = settle(send(queue, 'stuck'))
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
expect(await first).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
// No second thread beside one still inside a native call (#24572).
for (const label of ['a', 'b', 'c']) {
expect(await settle(send(queue, label))).toMatchObject({
message: 'unavailable: previous worker still exiting'
})
}
expect(workers).toHaveLength(1)
stalled.finish(1)
await vi.advanceTimersByTimeAsync(0)
const recovered = send(queue, 'recovered')
expect(workers).toHaveLength(2)
workers[1].respond()
await expect(recovered).resolves.toMatchObject({ label: 'recovered' })
queue.dispose()
})
it('does not latch spawning off when termination rejects', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const queue = makeQueue(workers, { awaitRetirement: true })
const first = settle(send(queue, 'stuck'))
workers[0].terminate = () => Promise.reject(new Error('terminate failed'))
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
expect(await first).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
const next = send(queue, 'next')
expect(workers).toHaveLength(2)
workers[1].respond()
await expect(next).resolves.toMatchObject({ label: 'next' })
queue.dispose()
})
})
})