Files
civitai__civitai/scripts/__tests__/dev-server-test-queue-lanes.test.ts
Justin Maier a658c3f6df perf(dev-server): let the test queue cap each run's vitest pool (#4965)
* perf(dev-server): let the test queue cap each run's vitest pool

The queue serialises full-suite runs at concurrency 1, which makes a run wait
behind every other agent's. Measured over 23.4h of the daemon's own history
(50 runs, 12 worktrees): median run 549s, median wait 186s, mean wait 405s,
worst 2247s.

Raising concurrency is the only lever that helps a change whose closure reaches
the hot services, but it cannot be raised alone: vitest sizes its pool at
`cpus - 1`, so two uncapped runs ask for 62 workers on a 32-core box.

VITEST_MAX_WORKERS cannot carry the cap here — the daemon spawns the child with
the daemon's own environment, so the caller's copy never arrives and the
daemon's is fixed at start. The CLI flag is the only channel that reaches a
queued run, and it is forwarded through `pnpm run` into vitest.

Verified by pool id rather than by argv alone: 8 files at --max-workers=2 ran
on workers [1 2]; the same 8 uncapped ran on [1 2 3 4 5 6 7 8].

Adds a runtime setter beside it so the width can be tuned without a second
daemon restart, and each key of `test config` is applied only when sent — a
concurrency change must not silently drop the cap.

Also replaces the "~75s" figure in the full-suite hook, which was off by 7x
against the measured median and was what every agent budgeted against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(dev-server): queue full typechecks in their own lane

A full `pnpm run typecheck` is one core and up to an 8 GB heap, and several
agents starting one at once pegs the box the same way concurrent suites did.
With CIVITAI_TEST_QUEUE set it now goes through the dev-server queue.

The queue gains run kinds with separate limits rather than one pool, because
the loads differ: a suite saturates every core, tsc is effectively
single-threaded. One shared limit would either hold a typecheck behind every
queued suite or let two suites run at once. Each lane takes only its own head
of the queue, and a run's position is reported within its lane.

The scalar concurrency every existing caller passes still sets the unit lane
only; reading it as "every lane" would raise the typecheck limit on any machine
that had only ever tuned the suite.

A typecheck stays direct in CI, with any argument (the scripts gate's
`-p tsconfig.scripts.json`), with the tsc test seam in use (otherwise the
typecheck tests would queue behind real runs and assert on the daemon's REAL
tsc), and with a heap override (a queued run gets the daemon's environment, so
the override would be silently dropped).

typecheck.mjs reuses test-unit-run.mjs's queue client rather than a copy.

Also fixes the worker cap missing a caller's camelCase `--maxWorkers`, which
vitest treats as the same flag — the queue would have appended a second,
conflicting width after it.

Nine revert controls, each red on its own named test, restores verified by
hash — including the one nothing else catches: a typecheck posted without its
kind is accepted as a unit run and spawns a full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(hooks): send full-program tsc runs to the queued typecheck script

A direct `npx tsc --noEmit` skips the typecheck lane, so agents doing it at
once are N single-core 8 GB heaps pegging the box. It is also the wrong check:
tsc at node's default heap can abort part-way with zero diagnostics and a log
that reads clean. scripts/typecheck.mjs raises the heap and names that crash.

The hook now denies a full-program tsc (no -p, or -p at the root tsconfig)
and points at `pnpm run typecheck`. Narrow runs pass untouched: a sub-project
(`-p tsconfig.scripts.json`, which the scripts gate itself recommends), named
files, --build, and informational flags. TYPECHECK_DIRECT=1 opts out for
diagnosing tsc itself.

Selftest: 68 rows green. Controls: disabling the guard fails all 10 block rows;
matching `tsc\b` instead of `tsc(?=\s|$)` fails only "tsc-alias is not tsc".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:11:39 -06:00

156 lines
5.6 KiB
TypeScript

import { EventEmitter } from 'events';
import { beforeEach, describe, expect, it } from 'vitest';
import * as QueueModule from '../../.claude/skills/dev-server/scripts/test-queue.mjs';
// The module is plain .mjs, so TS infers `request`'s payload from nothing and lands on types no
// call here can satisfy. Named once instead of cast at every call site.
type Kind = 'unit' | 'typecheck';
type View = {
id: string;
kind: Kind;
status: string;
position: number;
queueLength: number;
running: number;
concurrency: number;
};
type Queue = {
request: (run: { worktree: string; args?: string[]; kind?: string }) => View;
get: (id: string) => View;
list: () => View[];
setConcurrency: (value: number, kind?: Kind) => number;
concurrencyFor: (kind: Kind) => number;
};
type Runner = EventEmitter & { finish: (code: number) => void; kind: Kind; worktree: string };
type RunnerArgs = { worktree: string; kind: Kind };
const { TestQueue } = QueueModule as unknown as {
TestQueue: new (options: Record<string, unknown>) => Queue;
};
let started: Runner[];
const build = (concurrency: unknown) =>
new TestQueue({
concurrency,
now: () => 1_000,
startRun: ({ worktree, kind }: RunnerArgs) => {
const handle = new EventEmitter() as Runner;
handle.finish = (code) => handle.emit('exit', code);
handle.kind = kind;
handle.worktree = worktree;
started.push(handle);
return handle;
},
});
beforeEach(() => {
started = [];
});
/**
* 🔴 The property the lanes exist for. If you are here because this fails after collapsing the
* lanes back into one pool: that is the regression, not a stale test. A single pool puts a
* one-core typecheck behind every queued 31-worker suite, which is the "everything crawls"
* condition this was built to end.
*/
describe('a typecheck does not wait behind the unit lane', () => {
it('starts immediately while the unit lane is full and has runs queued ahead of it', () => {
const queue = build({ unit: 1, typecheck: 1 });
queue.request({ worktree: '/wt/suite-a' });
queue.request({ worktree: '/wt/suite-b' });
queue.request({ worktree: '/wt/suite-c' });
const check = queue.request({ worktree: '/wt/tc', kind: 'typecheck' });
expect(check.status).toBe('running');
expect(started.map((r) => `${r.kind}:${r.worktree}`)).toEqual([
'unit:/wt/suite-a',
'typecheck:/wt/tc',
]);
});
// Position is within the run's own lane. Counting the suites in `order` would tell a caller they
// are fourth in line for a slot they will never wait on.
it("reports a queued typecheck's position within its own lane only", () => {
const queue = build({ unit: 1, typecheck: 1 });
queue.request({ worktree: '/wt/suite-a' });
queue.request({ worktree: '/wt/suite-b' });
queue.request({ worktree: '/wt/suite-c' });
queue.request({ worktree: '/wt/tc-running', kind: 'typecheck' });
const waiting = queue.request({ worktree: '/wt/tc-waiting', kind: 'typecheck' });
expect(waiting.status).toBe('queued');
expect(waiting.position).toBe(1);
expect(waiting.queueLength).toBe(1);
});
});
describe('each lane enforces its own limit', () => {
it('holds a second typecheck while the first runs, even with the unit lane idle', () => {
const queue = build({ unit: 4, typecheck: 1 });
queue.request({ worktree: '/wt/tc-1', kind: 'typecheck' });
const second = queue.request({ worktree: '/wt/tc-2', kind: 'typecheck' });
expect(second.status).toBe('queued');
expect(started).toHaveLength(1);
});
it('holds a second suite while the first runs, even with the typecheck lane idle', () => {
const queue = build({ unit: 1, typecheck: 4 });
queue.request({ worktree: '/wt/suite-a' });
const second = queue.request({ worktree: '/wt/suite-b' });
expect(second.status).toBe('queued');
expect(started).toHaveLength(1);
});
it('frees a lane slot only for that lane', () => {
const queue = build({ unit: 1, typecheck: 1 });
queue.request({ worktree: '/wt/suite-a' });
const waitingSuite = queue.request({ worktree: '/wt/suite-b' });
queue.request({ worktree: '/wt/tc-1', kind: 'typecheck' });
const waitingCheck = queue.request({ worktree: '/wt/tc-2', kind: 'typecheck' });
started.find((r) => r.kind === 'typecheck')!.finish(0);
expect(queue.get(waitingCheck.id).status).toBe('running');
expect(queue.get(waitingSuite.id).status).toBe('queued');
});
it('starts waiting typechecks when their lane is raised at runtime', () => {
const queue = build({ unit: 1, typecheck: 1 });
queue.request({ worktree: '/wt/tc-1', kind: 'typecheck' });
const second = queue.request({ worktree: '/wt/tc-2', kind: 'typecheck' });
queue.setConcurrency(2, 'typecheck');
expect(queue.get(second.id).status).toBe('running');
});
});
describe('configuring the lanes', () => {
/**
* The scalar form is what every existing caller passes, from TEST_CONCURRENCY. Reading it as
* "every lane" would quietly raise the typecheck limit on any machine that had only ever tuned
* the suite — nobody who set that variable asked for more concurrent tsc heaps.
*/
it('reads a bare number as the unit lane only', () => {
const queue = build(3);
expect(queue.concurrencyFor('unit')).toBe(3);
expect(queue.concurrencyFor('typecheck')).toBe(1);
});
it('refuses an unknown kind without recording a run no lane would ever start', () => {
const queue = build({ unit: 1, typecheck: 1 });
expect(() => queue.request({ worktree: '/wt/x', kind: 'lint' })).toThrow(/unknown run kind/);
expect(queue.list()).toEqual([]);
});
});