Files
orca/config/scripts/foreign-sqlite-reader-worker-smoke.mjs
T
Jinwoo Hong b06f40f3ba fix(opencode): read the binder's session store off the main thread; ship the reader worker in orcad (#24638)
* fix(opencode): read the binder's session store on the foreign SQLite reader worker (STA-9122)

Before: the OpenCode session binder listed new sessions from opencode.db with
node:sqlite on the main thread every 60 s (and on SessionStart kicks), so a
large or contended store could stall the app the same way Cursor's did.

After: the read is a pure openCodeBinderSessions reader in
foreign-sqlite-readers/readers/, run only on the worker. The binder's
correlation, pane snapshot and process sweep stay where they were.

- The binder round awaits listSessions and re-checks its generation right
  after, so a stop() during the read discards the round before it touches the
  unbound map or the watermark.
- The client's in-flight dedupe key now includes the cursor, so a stale round
  from before a restart cannot hand its rows to the restarted round.
- Idle teardown is per reader. The binder lane keeps its thread for 120 s,
  longer than its 60 s poll, so the thread is not respawned every round.
- A timeout, crash, malformed reply or unstartable worker resolves to [] (no
  sessions), the value the old read already returned on failure.
- An absent store still reads as [] without a log line, and a permission or
  corrupt-file failure still logs (kept from #24577, now in the reader: it
  stats the path and throws anything but ENOENT/ENOTDIR to the client's log).
- The binder lane inherits #24572's limits from the shared lane: no respawn
  until a timed-out worker has exited, 2 consecutive deaths, a queue cap of
  8. Its timeout stays 60 s, matching its poll.
- dispatch switches on the destructured kind, so a new kind without a case
  still fails to compile.

orcad: the hook server runs there too, so orcad now ships
foreign-sqlite-reader-entry.js beside orcad.js (ORCAD_ARTIFACTS, built as an
orcad child). build-orcad runs a smoke check that starts the built worker
under the build's Node and under the pinned runtime, and does a real binder
read on a fixture DB, a Cursor read of a missing file and an OpenCode history
list. The OpenCode history scanner uses the same entry and was bundled into
orcad without it, so on orcad it always failed closed; it can now run.

Tests: reader (cursor, same-ms ids, OpenCode 2 rows, missing then created,
corrupt, inaccessible directory), retirement gate for the binder lane, dispatch
routing, client lane (rows, failure -> [], dedupe per cursor, own thread, idle
teardown default and override), binder loop with an async listSessions
(failure -> [], stop during the read), orcad path resolution through orcad's
host adapters, artifact list, and the smoke check against good, missing and
non-reading entries.

* test(opencode): cover the binder read deadline with fake timers and name the failure test accurately (STA-9122)
2026-10-02 19:58:29 -04:00

81 lines
3.3 KiB
JavaScript

import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describeProcessFailure, runProcessSync } from './script-child-process.mjs'
import { ORCAD_FOREIGN_SQLITE_READER_ENTRY } from '../../src/shared/orcad-artifacts.ts'
// Why a child process: the read must run under the runtime orcad ships, which may not be
// the Node running the build. The verdict is the exit code, never matched output.
const PROBE = `
const { Worker } = require('node:worker_threads')
const { DatabaseSync } = process.getBuiltinModule('node:sqlite')
const [entry, dbPath, missingPath] = process.argv.slice(2)
const db = new DatabaseSync(dbPath)
db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)')
db.prepare('INSERT INTO session VALUES (?, ?, ?, ?)').run('ses_smoke', '/smoke', 100, null)
db.close()
const steps = [
{
request: { id: 1, kind: 'openCodeBinderSessions', dbPath, cursor: { ms: 0, id: '' } },
expected: [{ id: 'ses_smoke', directory: '/smoke', createdAtMs: 100, parentId: null }]
},
{ request: { id: 2, kind: 'cursorProfile', dbPath: missingPath }, expected: { status: 'missing' } },
// The OpenCode history scanner's kinds share this entry.
{ request: { id: 3, kind: 'list', dbPaths: [], limit: null }, expected: { candidates: [], issues: [] } }
]
const worker = new Worker(entry, { execArgv: [] })
const fail = (code, message) => {
console.error(message)
process.exit(code)
}
setTimeout(() => fail(3, 'foreign SQLite reader worker did not answer'), 20000).unref()
worker.on('error', (error) => fail(4, String(error && error.stack || error)))
worker.on('exit', (code) => fail(5, 'foreign SQLite reader worker exited with ' + code))
let step = 0
worker.on('message', (response) => {
const { request, expected } = steps[step]
if (!response || response.id !== request.id || response.ok !== true ||
JSON.stringify(response.value) !== JSON.stringify(expected)) {
fail(6, request.kind + ' answered ' + JSON.stringify(response))
}
step += 1
if (step === steps.length) {
process.exit(0)
}
worker.postMessage(steps[step].request)
})
worker.postMessage(steps[0].request)
`
/**
* Load the built foreign SQLite reader entry and run real reads through it.
* @param outDir - orcad output directory holding the entry.
* @param options.runtimePath - Node to run under; the build's own Node when omitted.
*/
export function smokeForeignSqliteReaderWorker(outDir, { runtimePath, timeoutMs = 30_000 } = {}) {
const directory = mkdtempSync(join(tmpdir(), 'orca-foreign-sqlite-smoke-'))
try {
const probe = join(directory, 'probe.cjs')
writeFileSync(probe, PROBE)
const result = runProcessSync({
program: runtimePath ?? process.execPath,
args: [
probe,
resolve(outDir, ORCAD_FOREIGN_SQLITE_READER_ENTRY),
join(directory, 'opencode.db'),
join(directory, 'missing.vscdb')
],
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
timeoutMs,
maxOutputBytes: 64 * 1024
})
if (result.code !== 0 || result.timedOut) {
throw new Error(
`Foreign SQLite reader worker smoke failed: ${describeProcessFailure(result)}`
)
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
}