mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
a658c3f6df
* 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>
268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Runs the full-repo `tsc --noEmit` and classifies the OUTCOME, rather than
|
|
* leaving that to whoever reads the log.
|
|
*
|
|
* Why this exists
|
|
* ---------------
|
|
* `tsc` on this repo needs a raised V8 old-space (the default cap is far below
|
|
* what the program graph costs). When the cap is too low, V8 aborts:
|
|
*
|
|
* FATAL ERROR: Ineffective mark-compacts near heap limit
|
|
* Allocation failed - JavaScript heap out of memory
|
|
*
|
|
* The abort happens PART WAY THROUGH checking, so the process emits **zero**
|
|
* `error TS` lines and dies. Through `pnpm run`, that surfaces as
|
|
* `ELIFECYCLE Command failed with exit code 1` — a non-zero exit with a log
|
|
* that contains no type errors at all.
|
|
*
|
|
* That is byte-for-byte indistinguishable from a clean run to anything that
|
|
* judges the typecheck by its OUTPUT ("no `error TS` lines => clean"), which is
|
|
* what humans and scripted checks usually do. Measured on this repo: with a
|
|
* deliberate `const x: number = 'nope'` in `src/`, a run capped at 4096 MB
|
|
* reported 0 errors and hid it completely, while the same tree at 8192 MB
|
|
* reported it. Raising the cap only moves that cliff — the codebase grows into
|
|
* it again — so the number alone is not the fix. This wrapper makes the crash
|
|
* LOUD and unmistakably distinct from both "clean" and "you have type errors".
|
|
*
|
|
* Classification
|
|
* --------------
|
|
* exit 0, no `error TS` -> clean.
|
|
* exit != 0, has `error TS` -> ordinary type errors; exit code passed through.
|
|
* exit != 0, no `error TS` -> CRASH. Never report this as a typecheck result.
|
|
* Names heap exhaustion / kernel OOM-kill /
|
|
* unknown-crash separately, since the fixes differ.
|
|
* exit 0, has `error TS` -> defensive: tsc contradicting itself, treated as
|
|
* a crash rather than trusted.
|
|
*
|
|
* Heap size
|
|
* ---------
|
|
* Measured cold (no .tsbuildinfo) on a clean checkout, with a deliberate type
|
|
* error in `src/` as a visibility control:
|
|
*
|
|
* node 24.18.1 4096 MB -> OOM, 0 diagnostics 4608 MB -> OOM, 0 diagnostics
|
|
* 5120 MB -> pass, error found 8192 MB -> pass, error found
|
|
* node 22.22.2 6144 MB -> pass, error found 8192 MB -> pass, error found
|
|
*
|
|
* So the cliff sits between 4608 and 5120 MB, and the 8192 default carries ~1.6x
|
|
* headroom over it. It is deliberately NOT raised further: the CI runner has
|
|
* 16 GB, and a cap approaching that trades a self-describing V8 abort for a
|
|
* kernel OOM-kill, which says less. Override per-run with TYPECHECK_HEAP_MB.
|
|
* When the codebase does grow into 8192, the crash report below is what says so
|
|
* — out loud — instead of a silently empty log.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { typecheckQueueDecision } from './typecheck-queue.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
// A full typecheck costs one core and up to an 8 GB heap, and several agents starting one at once
|
|
// is what pegs the box. With CIVITAI_TEST_QUEUE set this hands the run to the dev-server queue's
|
|
// typecheck lane, which spawns `pnpm run typecheck` back in this worktree with the flag off — so
|
|
// the child comes through here again and falls to the direct path below.
|
|
//
|
|
// Top-level await on purpose: a queued run never returns (the client exits the process with the
|
|
// run's verdict), so everything below is reached only when the queue could not take the run.
|
|
if (typecheckQueueDecision(process.argv.slice(2), process.env).queue) {
|
|
const client = resolve(dirname(fileURLToPath(import.meta.url)), 'test-unit-run.mjs');
|
|
if (existsSync(client)) {
|
|
const { runQueued, queueAccepted } = await import(pathToFileURL(client).href);
|
|
try {
|
|
await runQueued([], { kind: 'typecheck', fallback: () => undefined });
|
|
} catch (err) {
|
|
if (queueAccepted()) {
|
|
console.error(`Lost contact with the test queue (${err?.message ?? err}). The run may still be queued.`);
|
|
process.exit(2);
|
|
}
|
|
console.error(`Typecheck queue failed (${err?.message ?? err}); running directly.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const DEFAULT_HEAP_MB = 8192;
|
|
const heapMb = Number(process.env.TYPECHECK_HEAP_MB || DEFAULT_HEAP_MB);
|
|
if (!Number.isFinite(heapMb) || heapMb <= 0) {
|
|
console.error(
|
|
`typecheck: TYPECHECK_HEAP_MB must be a positive number, got "${process.env.TYPECHECK_HEAP_MB}"`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
// TYPECHECK_TSC_PATH is a test seam: it lets the suite drive this classifier
|
|
// with a stub that exits the way a crashed / clean / erroring tsc does, without
|
|
// paying a multi-minute real typecheck per case. Not meant for normal use.
|
|
let tscPath = process.env.TYPECHECK_TSC_PATH;
|
|
if (!tscPath) {
|
|
try {
|
|
tscPath = require.resolve('typescript/lib/tsc.js');
|
|
} catch {
|
|
console.error('typecheck: cannot resolve typescript/lib/tsc.js — run `pnpm install` first.');
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
const tscArgs = process.argv.slice(2);
|
|
const startedAt = Date.now();
|
|
const child = spawn(
|
|
process.execPath,
|
|
[`--max-old-space-size=${heapMb}`, tscPath, '--noEmit', ...tscArgs],
|
|
{
|
|
// NODE_OPTIONS is dropped on purpose: the heap cap is passed as an argv flag
|
|
// so an inherited NODE_OPTIONS cannot silently override it, and so the value
|
|
// reported below is the value actually in force.
|
|
env: { ...process.env, NODE_OPTIONS: '' },
|
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
}
|
|
);
|
|
|
|
// Counted while streaming rather than buffered: a badly broken tree can emit
|
|
// tens of thousands of diagnostics, and this wrapper must not become the thing
|
|
// that runs out of memory.
|
|
let errorTsLines = 0;
|
|
let sawHeapOom = false;
|
|
const tail = [];
|
|
const TAIL_LINES = 40;
|
|
|
|
const OOM_SIGNATURES = [
|
|
'JavaScript heap out of memory',
|
|
'Ineffective mark-compacts near heap limit',
|
|
'FATAL ERROR',
|
|
];
|
|
|
|
function onLine(line) {
|
|
// `tsc` diagnostics are `path(line,col): error TS####: message`. `--pretty`
|
|
// is not used by this script, so the plain form is what arrives.
|
|
if (line.includes('error TS')) errorTsLines++;
|
|
if (OOM_SIGNATURES.some((sig) => line.includes(sig))) sawHeapOom = true;
|
|
tail.push(line);
|
|
if (tail.length > TAIL_LINES) tail.shift();
|
|
}
|
|
|
|
function wire(stream, sink) {
|
|
let residual = '';
|
|
stream.on('data', (chunk) => {
|
|
sink.write(chunk);
|
|
// Split on the residual + chunk so a signature straddling a chunk boundary
|
|
// is still seen whole.
|
|
const text = residual + chunk.toString('utf8');
|
|
const lines = text.split('\n');
|
|
residual = lines.pop() ?? '';
|
|
for (const line of lines) onLine(line);
|
|
});
|
|
stream.on('end', () => {
|
|
if (residual) onLine(residual);
|
|
});
|
|
}
|
|
|
|
wire(child.stdout, process.stdout);
|
|
wire(child.stderr, process.stderr);
|
|
|
|
function annotate(message) {
|
|
// Surfaces in the checks UI when running under Actions; harmless elsewhere.
|
|
if (process.env.GITHUB_ACTIONS === 'true') console.log(`::error::${message}`);
|
|
}
|
|
|
|
function reportCrash({ code, signal, reason, remedy }) {
|
|
const how = signal ? `killed by signal ${signal}` : `exit code ${code}`;
|
|
// The detail goes to stderr, but a one-line verdict also goes to STDOUT.
|
|
// The failure this whole wrapper exists to catch was found by a caller that
|
|
// redirected stdout and dropped stderr: V8's abort is written to stderr, so
|
|
// that caller saw an empty log and a bare non-zero exit. A crash has to be
|
|
// visible in whichever stream the reader happens to be capturing.
|
|
console.log(`TYPECHECK CRASHED (${how}): ${reason}. NOT a clean typecheck — see stderr.`);
|
|
console.error('');
|
|
console.error('================================================================');
|
|
console.error(' TYPECHECK CRASHED — THIS IS NOT A TYPECHECK RESULT');
|
|
console.error('================================================================');
|
|
// Nothing this wrapper prints may contain the literal diagnostic marker that
|
|
// callers grep for — otherwise a crash report reads as a type error to them.
|
|
console.error(` tsc ${how} after emitting 0 diagnostics.`);
|
|
console.error(` Cause: ${reason}`);
|
|
console.error('');
|
|
console.error(' The check did not finish, so it says NOTHING about whether the');
|
|
console.error(' code typechecks. Do not read the absence of errors as a pass.');
|
|
console.error('');
|
|
console.error(` Fix: ${remedy}`);
|
|
console.error(` Heap cap in force: --max-old-space-size=${heapMb} (MB).`);
|
|
console.error(' Override with TYPECHECK_HEAP_MB=<mb> pnpm run typecheck');
|
|
if (tail.length) {
|
|
console.error('');
|
|
console.error(` Last ${tail.length} line(s) of tsc output:`);
|
|
for (const line of tail) console.error(` ${line}`);
|
|
}
|
|
console.error('================================================================');
|
|
annotate(`Typecheck crashed (${how}, 0 diagnostics emitted): ${reason}`);
|
|
}
|
|
|
|
child.on('error', (err) => {
|
|
console.error(`typecheck: failed to start tsc: ${err.message}`);
|
|
process.exit(2);
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
const crashed = signal !== null || code !== 0;
|
|
|
|
if (!crashed) {
|
|
if (errorTsLines > 0) {
|
|
// tsc printed diagnostics and still claimed success. Whatever that is, it
|
|
// is not a verdict worth trusting.
|
|
reportCrash({
|
|
code,
|
|
signal,
|
|
reason: `tsc exited 0 but printed ${errorTsLines} diagnostic line(s) — contradictory result`,
|
|
remedy: 'investigate the tsc invocation; do not treat this run as a pass.',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
// A clean `tsc --noEmit` prints NOTHING, so an empty log is exactly what a
|
|
// crash-with-stderr-dropped looks like too. Stating success explicitly means
|
|
// "the log has no errors in it" is no longer a thing anyone has to infer.
|
|
console.log(
|
|
`typecheck: OK — 0 type errors in ${((Date.now() - startedAt) / 1000).toFixed(0)}s ` +
|
|
`(heap cap ${heapMb} MB).`
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (errorTsLines > 0) {
|
|
// The ordinary, useful failure: real type errors. Pass the verdict through
|
|
// untouched so the existing developer experience is unchanged.
|
|
console.error(`\ntypecheck: ${errorTsLines} type error(s).`);
|
|
process.exit(code === 0 || code === null ? 1 : code);
|
|
}
|
|
|
|
if (sawHeapOom) {
|
|
reportCrash({
|
|
code,
|
|
signal,
|
|
reason: `V8 ran out of old-space heap at ${heapMb} MB`,
|
|
remedy:
|
|
'raise the cap — TYPECHECK_HEAP_MB=<larger> to confirm, then raise ' +
|
|
'DEFAULT_HEAP_MB in this script (and check the CI runner has the RAM for it).',
|
|
});
|
|
} else if (signal === 'SIGKILL' || code === 137) {
|
|
reportCrash({
|
|
code,
|
|
signal,
|
|
reason:
|
|
'the process was killed from outside (out of system memory, or a job/container limit)',
|
|
remedy:
|
|
'give the machine or runner more RAM, or LOWER the heap cap so V8 stays ' +
|
|
'inside it — a cap above available RAM turns into an unhelpful kill.',
|
|
});
|
|
} else {
|
|
reportCrash({
|
|
code,
|
|
signal,
|
|
reason: 'tsc terminated abnormally without producing any diagnostics',
|
|
remedy: 'read the tsc output above; this is a tsc/tooling failure, not a code failure.',
|
|
});
|
|
}
|
|
process.exit(code === 0 || code === null ? 1 : code);
|
|
});
|