Files
civitai__civitai/scripts/__tests__/dev-server-test-queue-capture.test.ts
Zachary Lowden 314663b1b1 fix(dev-server): give a queued test run a FILE, not a pipe — a child that exits without flushing was losing most of its log (#4190)
* fix(dev-server): give a queued test run a FILE, not a pipe — a child that exits without flushing was losing most of its log

Closes two ClickUp tickets that share one capture path but are different
defects, and the head-to-head measurement is what picked the fix.

868ktwgyc — output lost before the daemon ever received it. Node makes a child's
stdout SYNCHRONOUS when it refers to a regular file and ASYNCHRONOUS when it
refers to a pipe, so a child calling `process.exit()` discards whatever is still
queued on a pipe. Measured head-to-head, same child, same process, old
mechanism vs new:

    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=slow  PIPES received= 172 missing=4828  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=1473 missing=3527  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=5000 missing=   0  |  FILE received=5000 missing=0
    consumer=fast  PIPES received=5000 missing=   0  |  FILE received=5000 missing=0

Two separate runs would not have been a comparison; this is the same child
through both arms. Numbering the lines is what makes it readable — a bare count
cannot tell truncation from sampling, and every missing line here is a
contiguous tail, which is what identifies the mechanism.

That measurement also ruled out the two fixes the ticket proposed. The daemon
CANNOT detect the loss by reading: it sees a clean EOF with no signal to compare
against, because the bytes never reached the pipe. And "flush before exit" is
not ours to call — the child is vitest. A file needs no cooperation from the
thing losing the data.

868kubfd9 — the reader tore lines at chunk boundaries. `d.toString().split('\n')`
per chunk with no carry buffer split any line straddling a boundary in two and
recorded BOTH halves as lines: 5,000 in, 4,998 recognised, 6 fragments invented,
353,893 of 353,893 bytes. Complete delivery, corrupted log, and `logsDropped`
correctly reports 0 for it — which is what made it invisible behind a counter
readers had been trained to trust. The new reader carries the partial line.

Third thing, not in either ticket and the one that protects a reader most: the
final drain now runs BEFORE onExit. A waiter wakes on the terminal status and
then reads the log, so reporting the exit while lines are still unread produces
a complete-LOOKING log that is still filling — the same false green, reached a
different way. The old pipe path could do that, since 'exit' does not wait for
pending 'data'.

Trade-off, stated rather than buried: one file for both streams, so the
interleaving is the real one, at the cost of no longer distinguishing stdout
from stderr. Lines are recorded as `output` rather than claiming to be one or
the other — nothing renders the level for a test run (both consumers print
`entry.message`), and a label we cannot support is worse than an honest one.

Seven mutants, each alone, tree cmp-verified between:
  carry buffer removed        -> the straddle case (+2 more)
  final flush removed         -> the trailing-line case
  back to pipes               -> both seam cases
  onExit before the drain     -> the ordering case
  drain reads a single chunk  -> the 5,000-line case + straddle
  stderr to another fd        -> the shared-descriptor case
  capture file not deleted    -> the cleanup case
All killed. The ordering mutant is killed ONLY by the ordering test, which is
why it exists.

344 passed (15 files). typecheck 0 errors. New test file prettier-clean.

* fix(dev-server): the capture had no owner — a forced or shut-down run leaked two fds, an unbounded /tmp file and a live interval

Audit round on #4190. Both blockers share one root cause: `finish` was bound only
to the child's own 'exit', and there are two live paths where that event never
comes.

🔴 The sweep's force-release — the wedge this queue exists to prevent — set
`run.handle = null` and dropped the queue's last reference while the interval,
both descriptors and the capture file stayed alive inside the closure. Measured
on a forced run: 2 fds still open and the log still growing 2s after the run was
reported terminal (logIndex 75 -> 471), file at 102,465 bytes, never unlinked.
A reader fetching logs after the verdict would get lines emitted AFTER it, with
`logsDropped` reading 0. `shutdown()` had the same gap.

🔴 And it was already leaking on every CI run, from a test this PR did not
update: `dev-server-test-queue-spawn.test.ts` calls `defaultStartRun` twice and
never emits 'exit'. Not hypothetical — 15 stale `civitai-test-run-*.log` files
were sitting in this box's /tmp from my own runs, 420 KB. Verified fixed by
counting: 0 before, 0 after, where it was 2 per run.

Both closed by `dispose()` on the handle, called from the force-release and from
`shutdown()`. Idempotent, and deliberately does NOT call onExit — the caller has
already settled the run.

Also from the audit:

- A carry buffer fixes a torn LINE and does nothing for a torn CHARACTER.
  Decoding each read independently turned a 3-byte `⎯` starting at byte 65535
  into THREE U+FFFD with the original gone — and vitest builds its failure output
  from `⎯`/`✓`/`×`/`❯`, one boundary per 64 KiB. `StringDecoder` now spans reads.
  The existing straddle test is ASCII and structurally could not see this.
- The final drain no longer stops at the first short read. Truncating there would
  report `logsDropped: 0` over a clipped log — the exact thing
  `warnIfLogsDropped` exists to make impossible.
- The straddle test's positive control is pinned from both sides; `> WINDOW`
  alone is also satisfied by padding that already exceeded it, which would put
  the line wholly inside the second read and never exercise the hazard.
- The `output` level is now pinned, the read buffer is hoisted out of a 10x/s
  allocation, and the spawn-failure paths return a handle with the same shape.

Seven mutants, each alone, tree cmp-verified between:
  force-release stops disposing  -> the queue seam case
  shutdown stops disposing       -> the queue seam case
  per-read decode                -> the multi-byte case
  lines relabelled 'stdout'      -> the level case
  dispose also calls onExit      -> the dispose-contract case
  dispose not idempotent         -> the dispose-contract case
  final drain stops on short read-> SURVIVED, and it is honest that it does

That survivor is stated rather than papered over: on a regular file a short read
only happens at EOF, so no test on this filesystem can distinguish the two. It
is contract hardening, not covered behaviour.

🔴 The seam lesson again: the first version of the dispose test called
`handle.dispose()` directly, which proves the method EXISTS and not that the
queue ever calls it — a `dispose()` nobody invokes is exactly the shape this
missed the first time. The killing test now drives TestQueue and asserts on
both release paths.

MEASURED, not fixed here: a fully PASSING unit run emits 5,390 lines against
`MAX_LOG_LINES = 2000`, so now that the capture actually delivers everything the
"INCOMPLETE" warning will fire on every full-suite run — and a warning that
always fires is one people stop reading. Raising the cap has memory arithmetic
attached (`KEEP_FINISHED = 50` retained runs x the window), so it is a tuning
decision that does not belong inside a correctness fix.

349 passed. typecheck 0 errors. 0 leaked captures.

* fix(dev-server): shutdown disposed the handle and settled nothing — the fix for the leak wedged the queue

Delta re-audit round on #4190. The previous round's `dispose()` closed both
leaks and introduced one genuine regression, which is fixed here.

🔴-in-effect: `dispose()` and `finish()` share the `finished` flag, so disposing
DISABLES the child's exit callback. The sweep's force-release settles the run
itself right after disposing; `shutdown()` did not. The SIGKILLed child's 'exit'
then arrived and returned early, `onExit` never fired, the run stayed `running`
and `running.size` never dropped — so `pump()` could never start another run.
That is the wedge this queue exists to prevent, reintroduced by the fix for a
leak. Measured in both arms: 300 ms after shutdown(), `running` / size 1 at
HEAD, against `cancelled` / 0 with the dispose removed. `shutdown()` now
settles what it disposes.

Survivable today only because every caller exits the process straight after —
but each first awaits rgbProxy.stop(), authHub.stop() and stopSpokeApps(), and
if any of those hangs the daemon stays up serving a queue that can never run
anything again.

🔴 And the comment I wrote to justify that dispose was FALSE. It claimed the
capture file would otherwise "survive the daemon that created it, every time".
Measured in both arms: the file was unlinked either way, because the daemon does
linger long enough for the child's exit. The dispose earns its place by
releasing the interval and the descriptors deterministically — not by that. The
comment now says so, including that it was wrong.

Also from the re-audit, all on lines the previous round added:

- The multi-byte test was VACUOUS without a straddle — it passed with the
  StringDecoder removed entirely, because "no U+FFFD" is equally satisfied by a
  character that never crossed a boundary. Now pinned from both sides, the same
  control its sibling got last round.
- `decoder.end()` had no test. A child killed mid-sequence leaves an incomplete
  character; without the flush those bytes vanish silently instead of surfacing
  as U+FFFD.
- `dispose()` draining before releasing had no test, on the one path where the
  child is KNOWN to still be writing — `drain(true)` after `close()` emits
  nothing at all.
- A read that THROWS on the final drain was silent. Same argument as the
  short-read break beside it: it would report a clipped log with
  `logsDropped: 0`. It now emits `[capture truncated: <code>]`.
- The `!final` comment overstated its scope: on the `finish()` path the writer
  is already dead, so it changes nothing there. It has effect only on `dispose()`.

Battery, each mutant alone, tree cmp-verified:
  shutdown disposes but never settles -> the new queue-level case
  dispose closes before draining      -> the drain-before-release case
  decoder.end() removed               -> the incomplete-character case
  final-drain read error silent       -> the truncation-marker case
  dispose leaves the interval running -> SURVIVED

That survivor is stated, not papered over: once the descriptors are closed the
stray interval's read just throws and breaks, so it has no observable effect on
the log. The ordering is still correct and still load-bearing — fd numbers are
reused, so a surviving reader could splice another file's bytes into this run —
but nothing pins it without exposing internals.

🔴 The harness lied to me first. Four mutants reported SURVIVED with an EMPTY
test count, because the runner list was an unquoted `$T` and zsh does not
word-split — vitest matched zero files and printed project globs. That is the
exact trap I wrote into the auditors' own briefing. The battery now refuses to
report a verdict when no `Tests` line is present, and the baseline control
proves one appears.

353 passed. typecheck 0 errors. 0 leaked captures.

* fix(dev-server): freeing the SLOT must not sit downstream of IO that can throw — and I was wrong that the stray interval was harmless

Round-3 delta re-audit. It refuted two of my own claims with measurements; both
are corrected here rather than argued with.

🔴-in-effect: `dispose()` does real IO and calls back into `onLog`, and it sat
ABOVE the detach/release/settle that free the slot — in both `shutdown()` and
the sweep's force-release. A throw there skips all three and the queue is wedged:
the identical failure the settle was added to fix, one round earlier. Measured
with a throwing dispose: status `running`, running.size 1, against `cancelled`
/ 0 when it returns normally. It also aborts the loop, leaving every remaining
run unkilled, and because both daemon handlers are async the rejection means
`process.exit(0)` is never reached — SIGINT would appear to do nothing.

Worse, this round ADDED a throw source inside that block: `err.code` raises on a
null/undefined throw. The repo already documents this hazard one file over
(`scripts/test-unit-run.mjs` uses `err?.message ?? String(err)` with a comment
saying why). Now `err?.code ?? err?.message ?? String(err)`, and both dispose
call sites are guarded — releasing the slot matters more than releasing the fds.

🔴 RETRACTION. I reported the `clearInterval` mutant as surviving with "no
observable effect once the descriptors are closed". That is measurably false,
and my own test comment said so while I argued the opposite. Descriptor NUMBERS
are reused: once the next `openSync` claims the freed fd, a surviving interval
reads whatever file now owns it. Measured: 4,246 lines of an unrelated file
spliced into a terminal run's log, against 0 with the clearInterval in place.
It is now pinned rather than argued away.

That test needed a second try to be able to fail at all. `openSync` returns the
LOWEST free descriptor, and the tail reads from `readFd` — the HIGHER of the
pair the capture just closed. Claiming one descriptor takes `writeFd`'s number
and the mutant goes unobserved, which is exactly how the first version passed.
It now claims both.

🔴 SECOND RETRACTION. Last round's message said the multi-byte test was
"vacuous without a straddle" and passed with the StringDecoder removed. The
re-audit re-ran that mutant against the round-2 file: KILLED. The padding was
already `READ_WINDOW - 1`, so the straddle always existed — the "survived" came
from my own broken harness, the one that reported four false SURVIVED verdicts
with an empty test count. The added assertions were still worth having, but the
justification I gave for them was wrong.

The residual behind it was real and is fixed: the test held a private copy of
`64 * 1024`. Widen the module's buffer and both boundary controls go vacuous
while staying green — proven: at 128 KiB the whole suite passed with the
StringDecoder deleted outright. The window is now exported and imported, so the
control cannot drift from what it controls.

Also: the test named 'drains before releasing, and stops the tail before closing
the descriptors' asserted only the first half. Renamed to what it checks, and
the second half is now its own test.

Battery, each mutant alone, tree cmp-verified, every verdict gated on a `Tests`
line being present:
  dispose leaves the tail running        -> the fd-reuse case
  shutdown's dispose unguarded           -> frees-the-slot-on-shutdown
  sweep force-release dispose unguarded  -> frees-the-slot-on-force-release
  read window widened + decoder removed  -> the multi-byte case

356 passed. typecheck 0 errors. 0 leaked captures, 0 leaked probes.

* test(dev-server): assert the exact terminal status per release path, not either-of

`toContain(['cancelled','timeout'])` passes with the WRONG status for the path
under test — a shutdown reported as a timeout, or the reverse. That is the shape
of assertion that lets a real mix-up through, and this file now has two paths
that settle differently.

Controlled: making shutdown settle 'timeout' now fails two tests; before this it
failed neither.

* fix(dev-server): a throwing log consumer leaked both descriptors and the file, and the healthy path's tail guard was never pinned

Round-4 delta re-audit. It reported no 🔴 and confirmed the previous round's
fixes hold — each killed by a mutant dying for its own reason, which is the
first round that has been true. These are its two 🟡 follow-ups, folded in
rather than deferred.

Draining calls back into `onLog`. With the close outside a `finally`, a consumer
that throws left BOTH descriptors open and the capture file on disk — measured:
fds 20 before and 20 after, file still present, 1 of 3 lines lost. Per run, in a
daemon that runs for days. And the slot-freeing guard added last round would
then swallow it, so it was silent as well as leaky. Both `finish()` and
`dispose()` now close in a `finally`.

That guard is no longer silent either. Swallowing the error hid a clipped log
behind `logsDropped: 0`, which is the one outcome the module's own comment says
the log contract rules out — and which the `[capture truncated]` marker two
lines below exists to prevent. Both sites now `addLog(run, 'error', …)`.

🔴 The tail guard covered the RARER path. `finish()` and `dispose()` have the
identical clearInterval -> drain -> close shape, and last round pinned only
`dispose()`. `finish()` runs on every normal child exit, so the stray-interval
hazard the round proved is not harmless was pinned on the edge case and left
open on the common one. Removing `finish()`'s clearInterval survived the whole
suite; it now fails.

Also: the fd-reuse tests had no self-check that the reclamation they depend on
actually happened. If `openSync` ever stops handing back the released numbers,
`readSync` gets EBADF, `drain()` breaks silently, and the test goes on passing
while protecting nothing. Both now assert the descriptor was reclaimed.

And a sentence on why `kill(true)` beside the guarded dispose is NOT guarded —
`defaultStartRun`'s kill body is wholly inside its own try/catch and the daemon
always uses that runner — so the asymmetry does not have to be re-derived.

Battery, each alone, every verdict gated on a `Tests` line:
  finish() loses clearInterval   -> the healthy-path tail case (was surviving)
  dispose closes outside finally -> the throwing-consumer case
  finish closes outside finally  -> the throwing-consumer case

CORRECTION to the previous message: I described `err?.code ?? err?.message ??
String(err)` as a fix. It is an EQUIVALENT MUTANT — `readSync` only ever throws
a Node SystemError, so reverting it is undetectable on any reachable input. It
costs nothing and I have kept it, but it is defensive hardening, not covered
behaviour, and I should not have implied otherwise.

358 passed. typecheck 0 errors. 0 leaked captures, 0 leaked probes.

* fix(dev-server): the test asserted deletion of files it did not own, and the round's headline change was untested

Round-5 delta re-audit. No 🔴 and no regression introduced — the first time that
has been true twice running. These are its three follow-ups.

🔴-in-practice: the throwing-consumer test snapshotted /tmp by PREFIX and
asserted every match was deleted. That matches captures owned by the operator's
own daemon, which creates one whenever it runs a queued test — so the suite goes
red with no defect present. Not theoretical: during the audit a single foreign
file made the UNMUTATED test fail, and made three mutants report a false KILLED.
The filename carries the owning pid for exactly this reason; both snapshots are
now scoped to `civitai-test-run-${process.pid}-`. Controlled: with a foreign
file planted, 359 pass and the file is left untouched.

The headline change of the previous round was untested. `finish()` and
`dispose()` have the identical drain-then-close shape and the throwing-consumer
test drove only `dispose()` — reverting `finish()`'s try/finally survived the
whole suite. `finish()` is the path every healthy run takes. It now has its own
case driving `child.emit('exit', 0)`, and the revert fails.

Nothing asserted the `capture release failed` line either, so both catch sites
could go back to a silent swallow with the suite green — which would restore
exactly the hidden-clipped-log outcome the line was added to prevent. Asserted
now; both silent-swallow mutants fail.

🔴 CORRECTION. The previous message claimed I had added a comment explaining why
`kill(true)` is unguarded while its neighbour is. That comment was NOT in the
tree — my edit's anchor did not match and I did not verify that one landed. The
claim itself was true and the auditor re-derived it independently, but I stated
as done something I had not checked. It is there now. This is the second false
claim I have put in a commit message on this branch; the pattern in both cases
was asserting an edit without grepping for it afterwards.

Battery — the three that survived the last round:
  finish() reverts try/finally  -> the normal-exit release case
  sweep catch goes silent       -> the sweep force-release case
  shutdown catch goes silent    -> the shutdown case
All killed. Every verdict gated on a `Tests` line.

359 passed. typecheck 0 errors. Clean /tmp before and after the shipped suite:
0 captures, 0 probes. (The one file that appeared mid-battery was the M3 MUTANT
leaking by design — content `boom one/boom two/x`, the `x` proving the
descriptor stayed open, which is what the kill detects.)

* test(dev-server): a positive control on the pid-scoped /tmp filter, so an empty match cannot pass as a no-op

Scoping the snapshot to this process fixed the cross-process false failure, and
introduced the opposite hazard: `for (const f of mine) expect(...)` over an
EMPTY list is a loop that runs zero assertions and passes. If the capture
filename format ever changes, both deletion tests would go on passing while
protecting nothing — the same vacuous-green shape this branch has now hit in
three different places.

Measured at exactly 1 match, and pinned there. Controlled: breaking the scope so
it matches nothing fails both tests, where before it would have been silent.

* test(dev-server): own captures by DELTA, so a sibling's defect stops failing this test on bookkeeping

Round-6 delta re-audit: no 🔴, no production defect, no regression — the range's
only non-test change was three comment lines. This is its one 🟡 plus two
accuracy fixes.

The capture snapshot claimed every live capture for this pid, and these cases
share a fork. So a defect in the SIBLING test's subject left its file behind and
this test failed on the file count rather than on its own assertion. The mutant
still died — at the wrong line, which silently over-credits whatever this test
was meant to cover. That is the same wrong-reason-kill problem this branch has
been chasing since round one, arriving in the fixture instead of the guard.

Now a before/after delta, so ownership is exact. Controlled: with dispose()'s
try/finally removed, ONE test now fails — the dispose one, at
`expected [Function] to throw an error`, its own assertion. Previously BOTH
failed and the finish() case reported
`expected [ …(2) ] to have a length of 1 but got 2`.

Two comments corrected:

- "it is the only `new TestQueue`" was false repo-wide — three more exist in the
  test file, two passing a startRun. The load-bearing claim (production never
  injects a runner) is true; the sentence was not, and the next reader greps it.
- The throwing-consumer leak is NOT reachable through the daemon today: its
  `onLog` is `addLog`, which cannot throw. The round-5 comment described it as
  happening "per run, in a long-lived daemon". It is cheap insurance against a
  future consumer, and now says so.

359 passed. typecheck 0 errors. 0 leaked captures.
2026-08-21 01:56:20 -05:00

574 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type * as ChildProcess from 'child_process';
import { EventEmitter } from 'events';
import { closeSync, existsSync, openSync, readdirSync, rmSync, writeFileSync, writeSync } from 'fs';
import { dirname, resolve } from 'path';
import { tmpdir } from 'os';
import { fileURLToPath } from 'url';
import { afterEach, describe, expect, it, vi } from 'vitest';
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock('child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcess>()),
spawn: (...args: unknown[]) => spawnMock(...args),
}));
const { createOutputCapture, defaultStartRun, READ_WINDOW_BYTES } = await import(
'../../.claude/skills/dev-server/scripts/test-queue.mjs'
);
// The mock above replaces `spawn` for EVERY importer, this file included — so a plain
// `import { spawn }` here would hand back the mock and the "real child" cases would spawn
// nothing. importActual is how a test that mocks a module still reaches the genuine one.
const { spawn: realSpawn } = await vi.importActual<typeof ChildProcess>('child_process');
/** Every capture this process currently owns. */
const listCaptures = () =>
readdirSync(tmpdir()).filter((f) => f.startsWith(`civitai-test-run-${process.pid}-`));
/**
* The captures created since `before`, i.e. the ones THIS case owns.
*
* A snapshot taken after the fact claims every live capture for this pid, and these cases share a
* fork — so a defect in a SIBLING test leaves its file behind and this one fails on the
* bookkeeping rather than on its own assertion. The mutant still dies, at the wrong line, which
* silently over-credits whatever this test was meant to cover. A delta is exact.
*/
const capturesCreatedBy = (before: string[]): string[] =>
listCaptures()
.filter((f) => !before.includes(f))
.map((f) => resolve(tmpdir(), f));
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '../..');
/**
* A child that writes N numbered lines and then calls `process.exit()` — the exact shape that
* loses output down a pipe. Numbered, because a COUNT alone cannot tell truncation (a prefix
* survives, the tail is gone) from sampling (gaps throughout), and the two point at different
* causes.
*/
const WRITER = `
const N = Number(process.argv[1]);
for (let i = 1; i <= N; i++) process.stdout.write('LINE ' + i + ' ' + 'x'.repeat(60) + '\\n');
process.exit(0);
`;
afterEach(() => vi.clearAllMocks());
describe('a run that exits without flushing still has all of its output', () => {
/** Runs the writer against a capture and returns what the capture recorded. */
function capture(n: number) {
return new Promise<{ lines: string[]; path: string }>((done) => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
const child = realSpawn(process.execPath, ['--input-type=module', '-e', WRITER, String(n)], {
stdio: ['ignore', cap.writeFd, cap.writeFd],
});
const tail = setInterval(() => cap.drain(), 20);
child.on('exit', () => {
clearInterval(tail);
cap.drain(true);
const { path } = cap;
cap.close();
done({ lines, path });
});
});
}
/**
* The regression. Measured head-to-head against the mechanism this replaces, same child, same
* process: through PIPES the parent received 172 of 5,000 lines with a slow consumer (three
* identical runs) and 1,473 on one of three fast runs; through the FILE, 5,000 of 5,000 on all
* six. Node makes a child's stdout synchronous for a regular file and asynchronous for a pipe,
* so `process.exit()` can discard a pipe's queue and cannot discard a file's.
*/
it('records every line of a 5,000-line run', async () => {
const { lines } = await capture(5000);
const numbers = new Set<number>();
let malformed = 0;
for (const line of lines) {
const m = /^LINE (\d+) x{60}$/.exec(line);
if (m) numbers.add(Number(m[1]));
else malformed += 1;
}
// Three distinct claims. The count catches loss; the distinct count catches a duplicate
// standing in for a missing line; malformed catches a line torn at a read boundary and
// recorded as two.
expect(lines).toHaveLength(5000);
expect(numbers.size).toBe(5000);
expect(malformed).toBe(0);
}, 60_000);
it('cleans up its capture file', async () => {
const { path } = await capture(10);
expect(existsSync(path)).toBe(false);
}, 30_000);
/**
* A carry buffer fixes a torn LINE and does nothing for a torn CHARACTER. Decoding each read
* independently turned a 3-byte `⎯` starting at byte 65535 into THREE U+FFFD, with the original
* gone — and vitest builds its failure output from `⎯`/`✓`/`×`/`❯`, with one boundary every
* 64 KiB. The straddle case below is ASCII and structurally cannot see this.
*/
it('does not corrupt a multi-byte character split across the read window', () => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
try {
// The module's own window, not a copy of the number: with a hand-copied 64 KiB here, the
// whole suite passed at a 128 KiB window with the StringDecoder deleted outright.
const READ_WINDOW = READ_WINDOW_BYTES;
// Pad so the 3-byte U+23AF begins one byte before the window ends, splitting it 1 + 2.
const pad = Buffer.alloc(READ_WINDOW - 1, 0x61); // 'a'
// The control this test needs and its sibling already has: without a straddle the
// assertion below is satisfied by a character that never crossed a boundary, and passes
// with the StringDecoder removed entirely. Pin that the character STARTS inside the first
// read window and ENDS outside it.
expect(pad.length).toBeLessThan(READ_WINDOW);
expect(pad.length + Buffer.byteLength('⎯', 'utf8')).toBeGreaterThan(READ_WINDOW);
writeSync(cap.writeFd, pad);
writeSync(cap.writeFd, Buffer.from('⎯MARKER\n', 'utf8'));
cap.drain(true);
const marked = lines.filter((l) => l.includes('MARKER'));
expect(marked).toHaveLength(1);
expect(marked[0].endsWith('⎯MARKER')).toBe(true);
// The control: no replacement character anywhere. Without the decoder this line carries
// three of them in place of the `⎯`.
expect(marked[0]).not.toContain('<27>');
} finally {
cap.close();
}
});
});
/**
* The second defect, and it is independent of the first: with every byte delivered, the old
* per-chunk `split('\n')` still tore any line straddling a read boundary into two, recording
* BOTH halves as lines. Measured at 5,000 lines: 4,998 recognised, 6 fragments invented, and
* 353,893 of 353,893 bytes — complete delivery, corrupted log. `logsDropped` reports 0 for this,
* correctly, which is what makes it invisible.
*/
describe('a line that straddles a read boundary survives whole', () => {
it('does not tear a line across the 64 KiB read window', () => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
try {
// Land a line's midpoint exactly past the read window: pad to just under, then write a
// marked line long enough to cross it. Taken from the module so widening it there cannot
// silently make this control vacuous.
const READ_WINDOW = READ_WINDOW_BYTES;
const filler = `${'f'.repeat(99)}\n`;
const padding = filler.repeat(Math.floor((READ_WINDOW - 50) / filler.length));
const straddler = `STRADDLE-${'s'.repeat(400)}-END`;
writeSync(cap.writeFd, `${padding}${straddler}\n`);
cap.drain(true);
const found = lines.filter((l) => l.includes('STRADDLE'));
expect(found).toHaveLength(1);
expect(found[0]).toBe(straddler);
// The positive control, pinned from both sides. `> READ_WINDOW` alone is also satisfied if
// the padding ALREADY exceeded the window, which would put the line wholly inside the
// second read and never exercise the hazard.
expect(padding.length).toBeLessThan(READ_WINDOW);
expect(padding.length + straddler.length).toBeGreaterThan(READ_WINDOW);
} finally {
cap.close();
}
});
// A child killed mid-sequence leaves an incomplete character at EOF. Without `decoder.end()`
// those bytes are dropped silently; with it they surface as U+FFFD, which is a visible
// artefact rather than an absent one. That distinction is the whole point of the log contract.
it('surfaces an incomplete trailing character rather than dropping it', () => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
try {
// The first two bytes of a 3-byte U+23AF, and nothing more — a killed writer's tail.
writeSync(cap.writeFd, Buffer.from([0xe2, 0x8e]));
cap.drain(true);
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('\ufffd');
} finally {
cap.close();
}
});
/**
* A final drain that cannot read must SAY so. Stopping silently would hand back a clipped log
* with `logsDropped: 0` — a complete-looking record of an incomplete run, which is the one
* thing the queue's log contract promises cannot happen.
*/
it('says so when the final drain cannot read, instead of returning a short log', () => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
writeSync(cap.writeFd, 'delivered\n');
cap.drain();
// Releasing the descriptors out from under the capture is the reachable way to make the
// final read fail; the queue's own dispose path closes them for real.
cap.close();
cap.drain(true);
expect(lines[0]).toBe('delivered');
expect(lines.some((l) => l.startsWith('[capture truncated:'))).toBe(true);
});
it('emits a final line that has no trailing newline, but only on the final drain', () => {
const lines: string[] = [];
const cap = createOutputCapture((line: string) => lines.push(line));
try {
writeSync(cap.writeFd, 'complete line\nno trailing newline');
cap.drain();
// Mid-run it is not a line yet — the writer may still be mid-write.
expect(lines).toEqual(['complete line']);
cap.drain(true);
expect(lines).toEqual(['complete line', 'no trailing newline']);
} finally {
cap.close();
}
});
});
/**
* The seam. The capture above can be perfect and the queue can still hand its child a pipe — the
* two are different files and nothing else here loads both. This asserts the mechanism is wired
* in, by reading the stdio the queue actually passes to spawn.
*/
describe('the queue hands its child the capture file, not a pipe', () => {
it('passes one file descriptor for both stdout and stderr', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
defaultStartRun({ worktree: repoRoot, args: [], onLog: () => {}, onExit: () => {} });
expect(spawnMock).toHaveBeenCalledTimes(1);
const stdio = (spawnMock.mock.calls[0][2] as { stdio: unknown[] }).stdio;
expect(stdio[0]).toBe('ignore');
// Numbers, not 'pipe' — that difference IS the fix.
expect(typeof stdio[1]).toBe('number');
expect(stdio[1]).not.toBe('pipe');
// The SAME descriptor for both, so the two streams share one offset and interleave in the
// order they were written. Two descriptors would reorder them.
expect(stdio[2]).toBe(stdio[1]);
child.emit('exit', 0);
});
it('records lines at the level the trade-off claims, not a stream it cannot know', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const levels: string[] = [];
const handle = defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (level: string) => levels.push(level),
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
writeSync(stdio[1], 'a line\n');
child.emit('exit', 0);
handle.dispose();
// One file cannot tell stdout from stderr, so claiming either would be a false label in
// stored data. `info` is the queue's own preamble line; captured output is `output`.
expect(levels.filter((l) => l !== 'info')).toEqual(['output']);
});
/**
* The capture has to be releasable WITHOUT an exit, because two live paths never produce one:
* the sweep's force-release past the kill grace — the wedge this queue exists to prevent — and
* daemon shutdown. Both dropped the queue's last reference while the interval, both descriptors
* and an unbounded /tmp file stayed alive in the closure.
*/
it('releases the capture when the child never exits', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
let exits = 0;
const handle = defaultStartRun({
worktree: repoRoot,
args: [],
onLog: () => {},
onExit: () => (exits += 1),
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
handle.dispose();
// The descriptor is closed, so a write to it now throws — that is the observable proof the
// fds were released, rather than a claim that dispose() was called.
expect(() => writeSync(stdio[1], 'x')).toThrow();
// The caller has already settled the run; disposing must not settle it a second time.
expect(exits).toBe(0);
// Idempotent — shutdown may dispose a handle the sweep already did.
expect(() => handle.dispose()).not.toThrow();
// And a late exit after disposal cannot re-enter finish().
child.emit('exit', 0);
expect(exits).toBe(0);
});
/**
* dispose() must READ before it releases, and release in the right order.
*
* Both halves were unpinned. `capture.drain(true)` after `capture.close()` emits nothing at all
* — everything the child wrote is silently lost — and this is the one path where the child is
* KNOWN to still be producing output, because force-release fires exactly when a kill did not
* take. The ordering is load-bearing rather than cosmetic: fd numbers are reused, so a tail
* interval surviving a close would read whatever file next claimed that descriptor and splice
* its bytes into this run's log.
*/
it('drains before releasing the descriptors', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const messages: string[] = [];
const handle = defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => messages.push(message),
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
writeSync(stdio[1], 'written but never drained\n');
handle.dispose();
// Drained, not lost — this fails outright if dispose closes before draining.
expect(messages).toContain('written but never drained');
// The descriptor is closed.
expect(() => writeSync(stdio[1], 'x')).toThrow();
});
/**
* The tail must actually STOP, and this is not cosmetic.
*
* I previously argued this mutant had no observable effect because a surviving interval would
* just throw on the closed descriptor. That is wrong, and measurably so: descriptor NUMBERS are
* reused, so once the next `openSync` claims the freed fd the surviving interval reads whatever
* file now owns it and splices those bytes into a terminal run's log. Measured against the
* mechanism: 4,246 lines of an unrelated file, versus 0 with the clearInterval in place.
*
* Driven with fake timers so the 100 ms tick is reached without waiting for it.
*/
it('stops the tail, so a reused descriptor cannot splice another file into the log', () => {
vi.useFakeTimers();
const foreign = resolve(tmpdir(), `civitai-fd-probe-${process.pid}-${Date.now()}.log`);
const foreignFds: number[] = [];
try {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const messages: string[] = [];
const handle = defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => messages.push(message),
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
handle.dispose();
const before = messages.length;
// Claim the descriptors the capture just released, and fill the new file with something
// unmistakable. A surviving tail would read THIS.
//
// BOTH of them, and that detail is the test: the capture closed two fds, `openSync` hands
// back the LOWEST free number, and the tail reads from `readFd` — the higher of the pair.
// Claiming only one takes writeFd's number and the mutant goes unobserved, which is exactly
// how the first version of this test passed against a surviving interval.
writeFileSync(foreign, 'FOREIGN-SECRET\n'.repeat(500));
foreignFds.push(openSync(foreign, 'r'), openSync(foreign, 'r'));
// The self-check. This case only has teeth if the reclamation actually happened — if it
// ever stops, `readSync` gets EBADF, `drain()` breaks silently, and the test goes on
// passing while protecting nothing.
expect(foreignFds).toContain(stdio[1]);
vi.advanceTimersByTime(1000);
expect(messages.slice(before)).toEqual([]);
expect(messages.some((m) => m.includes('FOREIGN-SECRET'))).toBe(false);
} finally {
for (const fd of foreignFds) closeSync(fd);
rmSync(foreign, { force: true });
vi.useRealTimers();
}
});
/**
* A log consumer that throws must not cost the descriptors and the file.
*
* Draining calls back into `onLog`. With the close outside a `finally`, a throwing consumer
* left BOTH descriptors open and the capture file on disk — measured — per run, in a daemon
* that runs for days. The slot-freeing guard upstream then swallows it, so it would be silent
* as well as leaky.
*/
it('releases the descriptors and the file even when the log consumer throws', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const before = listCaptures();
let seen = 0;
const handle = defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => {
// The queue's own preamble line arrives first; blow up on captured output.
if (message.startsWith('boom')) {
seen += 1;
throw new Error('log consumer blew up');
}
},
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
// Scoped to THIS process. The prefix alone matches every capture in /tmp, including one the
// operator's own daemon creates whenever it runs a queued test — so the unscoped form asserts
// that a file it does not own has been deleted, and goes red with no defect present. Measured:
// a single foreign file made the unmutated test fail, and made three mutants report a false
// KILLED. The filename carries the owning pid precisely so this can be scoped.
const capturePath = capturesCreatedBy(before);
// The positive control for the delta above. An empty list makes the deletion assertions below
// a no-op loop that passes trivially — so if the filename format ever changes, this fails
// loudly instead of the suite quietly protecting nothing. Measured at exactly 1.
expect(capturePath).toHaveLength(1);
writeSync(stdio[1], 'boom one\nboom two\n');
expect(() => handle.dispose()).toThrow(/log consumer blew up/);
expect(seen).toBeGreaterThan(0);
// Released despite the throw: the descriptor is closed and the file is gone.
expect(() => writeSync(stdio[1], 'x')).toThrow();
for (const p of capturePath) expect(existsSync(p)).toBe(false);
});
/**
* The same release guarantee on the path every healthy run takes.
*
* `finish()` and `dispose()` have the identical drain-then-close shape, and the first version of
* the throwing-consumer test drove only `dispose()`. Reverting `finish()`'s try/finally survived
* the entire suite — the headline change of its own round, untested, on the common path.
*/
it('releases the descriptors on a normal exit even when the log consumer throws', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const before = listCaptures();
let seen = 0;
defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => {
if (message.startsWith('boom')) {
seen += 1;
throw new Error('log consumer blew up');
}
},
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
const mine = capturesCreatedBy(before);
// Same positive control as above: an empty list would make the loop below vacuous.
expect(mine).toHaveLength(1);
writeSync(stdio[1], 'boom one\nboom two\n');
// The real exit path, not dispose().
expect(() => child.emit('exit', 0)).toThrow(/log consumer blew up/);
expect(seen).toBeGreaterThan(0);
expect(() => writeSync(stdio[1], 'x')).toThrow();
for (const f of mine) expect(existsSync(f)).toBe(false);
});
/**
* The same hazard on the path every healthy run takes.
*
* `finish()` and `dispose()` have the identical clearInterval -> drain -> close shape, and the
* previous round pinned only `dispose()` — the RARER of the two. `finish()` runs on every
* normal child exit, so a stray interval there is the common case, not the edge case.
*/
it('stops the tail on a normal exit too, not just on dispose', () => {
vi.useFakeTimers();
const foreign = resolve(tmpdir(), `civitai-fd-probe-${process.pid}-${Date.now()}-exit.log`);
const foreignFds: number[] = [];
try {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const messages: string[] = [];
defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => messages.push(message),
onExit: () => {},
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
child.emit('exit', 0);
const before = messages.length;
writeFileSync(foreign, 'FOREIGN-SECRET\n'.repeat(500));
foreignFds.push(openSync(foreign, 'r'), openSync(foreign, 'r'));
expect(foreignFds).toContain(stdio[1]);
vi.advanceTimersByTime(1000);
expect(messages.slice(before)).toEqual([]);
expect(messages.some((m) => m.includes('FOREIGN-SECRET'))).toBe(false);
} finally {
for (const fd of foreignFds) closeSync(fd);
rmSync(foreign, { force: true });
vi.useRealTimers();
}
});
/**
* The ordering invariant, and it is the half that actually protects a reader.
*
* A waiter wakes on the run's TERMINAL status and then reads the log. If the queue reports the
* exit before it has read what the child wrote, the waiter sees a complete-looking log that is
* still filling — the same false-green as losing the lines outright, arrived at differently.
* The old pipe path could do exactly that, because 'exit' does not wait for pending 'data'.
*
* Driven through the real `defaultStartRun` with a fake child, writing to the very descriptor
* the queue handed to spawn.
*/
it('reads everything the child wrote BEFORE reporting the exit', () => {
const child = Object.assign(new EventEmitter(), { pid: 4242, kill: vi.fn() });
spawnMock.mockReturnValue(child);
const events: string[] = [];
defaultStartRun({
worktree: repoRoot,
args: [],
onLog: (_level: string, message: string) => events.push(`log:${message}`),
onExit: (code: number) => events.push(`exit:${code}`),
});
const stdio = (spawnMock.mock.calls[0][2] as { stdio: number[] }).stdio;
// Written and never drained by the interval — the child "exits" immediately after, so the
// only thing that can have read these is the final drain inside the exit handler.
writeSync(stdio[1], 'first\nsecond\nthird\n');
child.emit('exit', 0);
const exitAt = events.indexOf('exit:0');
expect(exitAt).toBeGreaterThan(-1);
expect(events.slice(0, exitAt)).toEqual(
expect.arrayContaining(['log:first', 'log:second', 'log:third'])
);
// Stated as an index comparison too, so a reordering cannot hide behind arrayContaining.
expect(events.indexOf('log:third')).toBeLessThan(exitAt);
});
});