Files
orca/src/main/worker-thread-request-queue.ts
T
Jinwoo Hong 631b51f508 perf(usage): run the Claude/Codex/OpenCode usage scans on a worker thread (#21114)
* perf(codex-usage): resume rollout scans at the last parsed byte

Codex rollout files are append-only and grow all day, but any append
changed both mtime and size, so `canReuse` discarded the cached entry and
the scanner re-read the whole file from byte 0 on the Electron main
process. On one real corpus that was 6.59 GB re-read per cycle across
26.63 GB / 21,110 files.

Each parsed file now persists a resume point: the offset just past the
last newline-terminated line, the parse context at that offset (session
id, cwd, model, running totals), a sha256 of the 4 KiB before it, and the
file's dev:ino. A grown file resumes there and merges the appended
rollup into the cached one; anything unproven falls back to a full
reparse — truncation, an in-place rewrite, rotation, a counted tail with
no trailing newline, a legacy copied-session suffix offset, or a file
that must reclaim deferred fork claims. Resume never depends on mtime
equality, so a coarse-mtime filesystem cannot hide an append.

Fixture: a 75,737-byte rollout with a 758-byte append re-read 76,495
bytes before and 8,950 after (the append plus two bounded 4 KiB boundary
windows).

Also bounds the automation-attribution force predicate for both Codex and
Claude: it keyed on `lastScanError`, so a persistently failing scan forced
a fresh full rescan on every single lookup. It now keys on the most recent
scan attempt, which is one forced scan per run regardless of outcome.

* perf(usage): run the Claude/Codex/OpenCode usage scans on a worker thread

The three first-party usage scans walk whole rollout and transcript corpora
and read OpenCode's SQLite synchronously, all on the Electron main process.
They rarely produce a long stall — the JSONL reader streams, so it yields to
the loop between chunks — but they pin the main-process event loop at ~95%
utilization for the scan's whole duration, which is what every IPC message,
timer and window event then queues behind.

Move that work to one lazily-spawned, unref'd worker thread shared by all
three providers, following the OpenCode SQLite scanner precedent (#8864).
Measured on a synthetic 4,000-rollout corpus (25.8 MB cache): a cold scan
drops from 2,147 ms of main-thread time to 31 ms, and a steady-state
incremental scan from 165 ms to 64 ms.

The worker is stateless and the cache crosses the boundary both ways. That
costs ~64 ms of structured clone at this corpus size, against 2,147 ms saved
on the cold path, and it keeps the persisted cache the single source of
truth — a worker-owned copy would need an invalidation protocol and a second
resident copy of the same multi-MB array.

Failure is closed, never a silent empty result: a worker that cannot spawn,
times out, or crash-loops rejects, and the store records the scan error and
keeps the previous projection.

Two clients already carried the same FIFO/timeout/crash-cap machinery, so
extract it once as WorkerThreadRequestQueue (with the packaged entry-path
resolver as worker-thread-entry-path) and move all three onto it, rather
than adding a third copy. Their existing tests pass unchanged.

The oracle is event-loop utilization on the calling thread, not a stopwatch:
usage-scan-worker-event-loop.test.ts runs the same scan both ways and asserts
the worker leg leaves the caller idle while the main-thread leg does not, so
CI load moves both legs together (#18788).

* test(usage): compare the two scan arms instead of two fixed thresholds

The event-loop oracle claimed to be self-calibrating — its header said "the
ratio is self-calibrating, so CI load moves both legs together (#18788)
instead of tipping a fixed millisecond threshold." It computed no ratio. Two
separate `it()` blocks each asserted an absolute threshold against its own
arm, run separately, so load moved them independently. The comment described
a test nobody wrote, and the flake it promised was impossible is the one that
landed: `activeRatio > 0.8` on the calling-thread arm measured 0.764 on an
ubuntu runner.

Fixing the comment is not enough, because the fraction is the wrong quantity.
CPU contention drags the calling-thread arm's active/wall fraction *down*
toward the worker's, since the loop parks waiting on a contended libuv pool.
A 4-vCPU Linux container measured that arm at 0.175-0.756 across twenty runs,
idle and loaded — never once above 0.8. Active *milliseconds* move the other
way: contention stretches the caller's JS time far more than it stretches the
worker arm's fixed post-and-deserialize cost, so the gap widens under load.

Merge the two arms into one case over one corpus and assert the worker arm
costs the caller under a fifth of the inline arm's active milliseconds. Same
twenty Linux runs: 10.9x-83.6x, passing throughout. Keep the presence
preconditions on both arms — an arm that silently scanned nothing satisfies
the comparison trivially — and extend them to the calling-thread arm, which
previously checked only file and session counts.

* fix(ports): name the dropped command when the probe queue is full

The shared-queue extraction turned `Port scan command queue is full; dropped
${command}.` into a constant string, because `describeFull` was given no way
to see the request. Pile-up is per-probe, so the name is the only thing in
that log that identifies which of lsof/ps/netstat was shed.

Pass the rejected request to `describeFull` and restore the name. The request
is built before the cap check so it exists to be named; the id it burns is a
correlation token, so a gap costs nothing.

The existing overflow test asserted only the error class, which is why the
regression escaped a 29-test suite. It now dispatches the overflow under a
different command than the accepted ones and asserts the message text, so a
message that names the wrong request fails too.

Also add a direct WorkerThreadRequestQueue test. Three subsystems share the
queue and each client test only sees the parts its own protocol exercises,
with `queueCap` reachable from port-scan alone. Covers one-at-a-time FIFO
dispatch, the deadline starting at dispatch rather than enqueue, the
consecutive-death cap, and both points where that count clears.

And record the child-process hazard at the usage worker entry. `terminate()`
reaps nothing the thread spawned, and OpenCode discovery reaches a fork
today: `wslGated*` forks the WSL transcript sidecar for a `\\wsl$\...` path,
which a Windows `OPENCODE_DB` or `XDG_DATA_HOME` can be. One scan through
that entry with a UNC `OPENCODE_DB` forked a sidecar that outlived
`terminate()`.

* test(ai-vault): assert the OpenCode worker messages exactly, not by fragment

Checked every message string in the two clients the shared-queue extraction
rewrote against origin/main. Only the port-scan queue-full one regressed
(fixed in the previous commit); the OpenCode SQLite client's four messages
render identically, the remaining source diffs being renames — `error.message`
to `lastError`, `call.timeoutMs` and `CALL_DEADLINE_MS` to `timeoutMs`.
`session-scanner-worker-client.ts` was not touched by the extraction.

But its suite could not have caught it either. `/timed out/`, `/exited with
code/` and a bare `rejects.toThrow()` all still match a message that has lost
its interpolated value, which is the same blind spot that let the port-scan
regression through. Assert the rendered text instead: the timeout names its
deadline, the exit names its code, and the crash-loop drain still carries the
text of the fault that killed the run.

* fix(usage): correct the worker entry's child-process note

The previous note said `worker.terminate()` leaves a forked sidecar orphaned.
It does not, and the reproduction that appeared to show it used a stub sidecar
missing the `process.on('disconnect', () => process.exit(0))` the real entry
has. With a faithful one: the sidecar lives exactly as long as the thread and
is gone within 2s of `terminate()`, because tearing the thread down closes the
IPC channel it owned. Two worker lifecycles forked two sidecars and leaked
neither, and the pre-worker main-thread path reaps its sidecar the same way,
on host exit.

What is true and worth recording: a fork is reachable from this bundle at all,
which is easy to miss; it survives only as long as the channel does; and the
sidecar is now re-forked per worker lifecycle instead of pooled for the app's
life. State those, and warn that a future child which does not exit on channel
close would not get the same free cleanup.

* fix(usage): kill a wedged scan worker on no progress, not on wall clock

`USAGE_SCAN_TIMEOUT_MS` was a 10-minute deadline on the whole scan. A cold
scan of a real history is legitimately minutes — 637 s measured on a 30 GB
corpus with 300 worktrees before the per-cwd memo, ~51 s after — so a
larger corpus or a slower disk crosses it. Crossing it killed the worker,
recorded a scan error and left the cache unadvanced, so the next refresh
started cold and died at the same point, forever.

The deadline is now a no-progress window. The worker posts a file counter
as it walks the corpus (`UsageScanWorkerProgress`, rate-limited to one
message a second), and `WorkerThreadRequestQueue` re-arms the active
call's timer on each one via the new optional `isProgress`. Clients that
do not pass it keep the plain wall-clock deadline. `MAX_CONSECUTIVE_DEATHS`
and idle teardown are unchanged.

* refactor(usage): report scan progress as a file count, not one call per file

Claude's scanner walks batches, so a per-file callback made it loop just
to bump a counter.
2026-09-16 23:03:37 -04:00

220 lines
7.3 KiB
TypeScript

import { LazyWorkerThreadHost, type WorkerThreadFactory } from './lazy-worker-thread-host'
/**
* FIFO one-at-a-time request half shared by every main-process worker-thread
* client: per-call timeout armed at dispatch, respawn-on-fault capped so a
* payload that reliably kills the worker cannot spin a crash loop, idle
* teardown, and — the rule that matters — failing queued calls closed instead
* of moving their work back onto the main thread. `LazyWorkerThreadHost` owns
* the thread's lifetime; this owns which call a message belongs to.
*/
export type WorkerThreadRequestQueueOptions<TRequest> = {
factory: WorkerThreadFactory
idleTeardownMs: number
/** Consecutive deaths after which the remaining queue is failed rather than respawned. */
maxConsecutiveDeaths: number
/**
* Omit for an unbounded queue; set it where pile-up is itself the bug.
* `describeFull` gets the rejected request so the message can name the work
* that was dropped, which is the only detail a log has to identify it.
*/
queueCap?: { maxQueuedCalls: number; describeFull: (request: TRequest) => string }
/**
* Marks a message as liveness for the active call rather than its result.
* Omit it and `describeTimeout`'s deadline is a wall clock on the whole call;
* supply it and the deadline becomes a no-progress window, re-armed by every
* progress message the active call sends. Work that is slow but still moving
* must not be killed for being slow.
*/
isProgress?: (message: { id: number }) => boolean
/** The client's own error subclass, so callers can tell "no worker" from a fault. */
createUnavailableError: (message: string) => Error
describeTimeout: (timeoutMs: number) => string
describeExit: (code: number) => string
describeCrashLoop: (lastError: string) => string
/** First spawn failure only; a repeating one must not repeat the log. */
onUnavailable: (error: unknown) => void
}
type PendingCall<TRequest, TResponse> = {
request: TRequest
timeoutMs: number
resolve: (value: TResponse) => void
reject: (error: Error) => void
timer: NodeJS.Timeout | null
}
export class WorkerThreadRequestQueue<
TRequest extends { id: number },
TResponse extends { id: number }
> {
private active: PendingCall<TRequest, TResponse> | null = null
private queue: PendingCall<TRequest, TResponse>[] = []
private consecutiveDeaths = 0
private nextId = 1
private readonly host: LazyWorkerThreadHost<TResponse>
constructor(private readonly options: WorkerThreadRequestQueueOptions<TRequest>) {
this.host = new LazyWorkerThreadHost<TResponse>({
factory: options.factory,
idleTeardownMs: options.idleTeardownMs,
onMessage: (response) => this.onMessage(response),
onError: (error) => this.onWorkerFault(error),
onExit: (code) => this.onWorkerExit(code),
isIdle: () => !this.active && this.queue.length === 0,
onUnavailable: options.onUnavailable
})
}
/**
* Queue one request and resolve with the worker's matching response.
* @param buildRequest - Builds the request body around the correlation id this queue stamps.
* @param timeoutMs - Deadline measured from dispatch, not from enqueue.
* @returns The worker's response; rejects on timeout, crash, or an unspawnable worker.
*/
dispatch(buildRequest: (id: number) => TRequest, timeoutMs: number): Promise<TResponse> {
return new Promise((resolve, reject) => {
// Built before the cap check so a rejection can name the dropped work;
// the id it burns is only a correlation token, so a gap costs nothing.
const request = buildRequest(this.nextId++)
const cap = this.options.queueCap
if (cap && this.queue.length >= cap.maxQueuedCalls) {
reject(new Error(cap.describeFull(request)))
return
}
// A fresh burst from full idle starts new work: clear any death count
// carried from a prior burst so the respawn cap can't drain it early.
if (!this.active && this.queue.length === 0) {
this.consecutiveDeaths = 0
}
this.queue.push({
request,
timeoutMs,
resolve,
reject,
timer: null
})
this.pump()
})
}
private pump(): void {
if (this.active || this.queue.length === 0) {
return
}
const worker = this.host.ensure()
if (!worker) {
this.failQueuedAsUnavailable()
return
}
const call = this.queue.shift()
if (!call) {
return
}
this.active = call
this.host.clearIdleTimer()
this.armDeadline(call)
worker.postMessage(call.request)
}
/**
* Clock starts at dispatch, not enqueue: a queue-inclusive deadline would fire
* falsely on the calls waiting behind a long one. Re-armed on every progress.
*/
private armDeadline(call: PendingCall<TRequest, TResponse>): void {
if (call.timer) {
clearTimeout(call.timer)
}
call.timer = setTimeout(() => this.onTimeout(call), call.timeoutMs)
call.timer.unref?.()
}
private onMessage(response: TResponse): void {
const call = this.active
if (!call || call.request.id !== response.id) {
return
}
// Liveness, not a result: keep waiting, but restart the no-progress window.
if (this.options.isProgress?.(response)) {
this.armDeadline(call)
return
}
this.consecutiveDeaths = 0
this.settle(call, () => call.resolve(response))
this.afterSettle()
}
private onTimeout(call: PendingCall<TRequest, TResponse>): void {
if (this.active !== call) {
return
}
this.onWorkerFault(new Error(this.options.describeTimeout(call.timeoutMs)))
}
private onWorkerExit(code: number): void {
// A clean self-exit is not a death, but the stale handle must be dropped or
// the next dispatch would post into the dead worker and stall to timeout.
if (code === 0 && !this.active && this.queue.length === 0) {
this.host.destroy()
return
}
this.onWorkerFault(new Error(this.options.describeExit(code)))
}
private onWorkerFault(error: Error): void {
const failed = this.active
this.host.destroy()
this.consecutiveDeaths++
if (failed) {
this.settle(failed, () => failed.reject(error))
}
if (this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) {
this.drainQueueAfterCrashLoop(error)
return
}
if (this.queue.length > 0) {
this.pump()
}
}
private drainQueueAfterCrashLoop(error: Error): void {
const pending = this.queue
this.queue = []
this.consecutiveDeaths = 0
const drainError = new Error(this.options.describeCrashLoop(error.message))
for (const call of pending) {
this.settle(call, () => call.reject(drainError))
}
}
private failQueuedAsUnavailable(): void {
const pending = this.queue
this.queue = []
for (const call of pending) {
this.settle(call, () =>
call.reject(this.options.createUnavailableError('worker spawn failed'))
)
}
}
private settle(call: PendingCall<TRequest, TResponse>, run: () => void): void {
if (call.timer) {
clearTimeout(call.timer)
call.timer = null
}
if (this.active === call) {
this.active = null
}
run()
}
private afterSettle(): void {
if (this.queue.length > 0) {
this.pump()
} else {
this.host.scheduleIdleTeardown()
}
}
}