mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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
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.
624 lines
24 KiB
TypeScript
624 lines
24 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Ships with the dev-server skill (plain .mjs, loaded by the daemon under node, never bundled),
|
|
// so it is imported by path rather than moved into src/ — same arrangement as the port probe.
|
|
import { TestQueue, exitCodeFor } from '../../.claude/skills/dev-server/scripts/test-queue.mjs';
|
|
|
|
type FakeRun = EventEmitter & {
|
|
kill: ReturnType<typeof vi.fn>;
|
|
finish: (code: number) => void;
|
|
worktree: string;
|
|
};
|
|
|
|
// Every fake terminates on demand and nothing here drives a loop: the queue owns no timers, so
|
|
// each deadline is reached by moving the injected clock. A regression fails on an assertion
|
|
// naming the wrong position or status, never by hanging the runner.
|
|
type RunnerArgs = { worktree: string; onExit?: (code: number, error?: string) => void };
|
|
|
|
function makeRunner() {
|
|
const started: FakeRun[] = [];
|
|
const startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn(() => handle.emit('exit', 1));
|
|
handle.finish = (code: number) => handle.emit('exit', code);
|
|
handle.worktree = worktree;
|
|
started.push(handle);
|
|
return handle;
|
|
};
|
|
return { started, startRun };
|
|
}
|
|
|
|
describe('dev-server test queue', () => {
|
|
let now: number;
|
|
let runner: ReturnType<typeof makeRunner>;
|
|
|
|
const build = (overrides = {}) =>
|
|
new TestQueue({
|
|
// Indirect so a test can swap the runner after construction.
|
|
startRun: (opts: RunnerArgs) => runner.startRun(opts),
|
|
now: () => now,
|
|
abandonAfterMs: 10_000,
|
|
runTimeoutMs: 60_000,
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
now = 1_000;
|
|
runner = makeRunner();
|
|
});
|
|
|
|
it('runs the first request immediately and queues the rest with exact positions', () => {
|
|
const queue = build();
|
|
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const second = queue.request({ worktree: '/wt/b' });
|
|
const third = queue.request({ worktree: '/wt/c' });
|
|
|
|
expect(first.status).toBe('running');
|
|
expect(first.position).toBe(0);
|
|
expect(second.status).toBe('queued');
|
|
expect(second.position).toBe(1);
|
|
expect(third.position).toBe(2);
|
|
expect(runner.started).toHaveLength(1);
|
|
});
|
|
|
|
it('starts the next run when the running one exits, and reports the outcome', () => {
|
|
const queue = build();
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const second = queue.request({ worktree: '/wt/b' });
|
|
|
|
runner.started[0].finish(0);
|
|
|
|
expect(queue.get(first.id).status).toBe('completed');
|
|
expect(queue.get(first.id).exitCode).toBe(0);
|
|
expect(queue.get(second.id).status).toBe('running');
|
|
expect(queue.get(second.id).position).toBe(0);
|
|
expect(runner.started).toHaveLength(2);
|
|
});
|
|
|
|
it('reports a nonzero exit as failed and still frees the slot', () => {
|
|
const queue = build();
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const second = queue.request({ worktree: '/wt/b' });
|
|
|
|
runner.started[0].finish(1);
|
|
|
|
expect(queue.get(first.id).status).toBe('failed');
|
|
expect(queue.get(first.id).exitCode).toBe(1);
|
|
expect(queue.get(second.id).status).toBe('running');
|
|
});
|
|
|
|
it('honours a configured concurrency above one', () => {
|
|
const queue = build({ concurrency: 2 });
|
|
|
|
queue.request({ worktree: '/wt/a' });
|
|
queue.request({ worktree: '/wt/b' });
|
|
const third = queue.request({ worktree: '/wt/c' });
|
|
|
|
expect(runner.started).toHaveLength(2);
|
|
expect(third.status).toBe('queued');
|
|
expect(third.position).toBe(1);
|
|
});
|
|
|
|
it('treats concurrency 0 as paused, and says so rather than leaving the caller guessing', () => {
|
|
const queue = build({ concurrency: 0 });
|
|
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
|
|
expect(run.status).toBe('queued');
|
|
expect(run.paused).toBe(true);
|
|
expect(run.position).toBe(1);
|
|
expect(runner.started).toHaveLength(0);
|
|
|
|
queue.setConcurrency(1);
|
|
|
|
expect(queue.get(run.id).status).toBe('running');
|
|
expect(queue.get(run.id).paused).toBe(false);
|
|
});
|
|
|
|
it('rejects a negative concurrency instead of quietly clamping it', () => {
|
|
expect(() => build({ concurrency: -1 })).toThrow(/concurrency must be an integer/);
|
|
});
|
|
|
|
it('drops a queued run whose caller stopped polling, and starts the one behind it', () => {
|
|
const queue = build();
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const abandoned = queue.request({ worktree: '/wt/b' });
|
|
const behind = queue.request({ worktree: '/wt/c' });
|
|
|
|
// The caller of `behind` is alive and polling; the caller of `abandoned` died.
|
|
now += 9_000;
|
|
queue.get(behind.id);
|
|
now += 2_000;
|
|
|
|
const swept = queue.sweep();
|
|
|
|
expect(swept.abandoned).toEqual([abandoned.id]);
|
|
expect(queue.get(abandoned.id).status).toBe('abandoned');
|
|
expect(queue.get(behind.id).status).toBe('queued');
|
|
expect(queue.get(behind.id).position).toBe(1);
|
|
expect(queue.get(first.id).status).toBe('running');
|
|
});
|
|
|
|
it('never abandons a run that is already executing — the daemon owns it, not the caller', () => {
|
|
const queue = build({ concurrency: 2 });
|
|
const executing = queue.request({ worktree: '/wt/a' });
|
|
queue.request({ worktree: '/wt/b' });
|
|
const waiting = queue.request({ worktree: '/wt/c' });
|
|
|
|
now += 30_000; // three times the abandon window, half the run ceiling
|
|
const swept = queue.sweep();
|
|
|
|
// The queued sibling proves the sweep ran and was capable of abandoning something; without it
|
|
// an empty `abandoned` list would be true no matter what the sweep did.
|
|
expect(swept.abandoned).toEqual([waiting.id]);
|
|
expect(queue.get(executing.id).status).toBe('running');
|
|
});
|
|
|
|
it('kills a run that overruns the ceiling and hands the slot to the next caller', () => {
|
|
const queue = build();
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
const next = queue.request({ worktree: '/wt/b' });
|
|
|
|
// `next` has a live waiter polling it, which is what keeps it out of the abandon sweep during
|
|
// a long run ahead of it.
|
|
for (let elapsed = 5_000; elapsed <= 55_000; elapsed += 5_000) {
|
|
now += 5_000;
|
|
queue.get(next.id);
|
|
expect(queue.sweep().timedOut).toEqual([]);
|
|
}
|
|
now += 5_001;
|
|
|
|
const swept = queue.sweep();
|
|
|
|
expect(swept.timedOut).toEqual([stuck.id]);
|
|
expect(runner.started[0].kill).toHaveBeenCalledTimes(1);
|
|
expect(queue.get(stuck.id).status).toBe('timeout');
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
});
|
|
|
|
it('frees the slot when a killed process never exits, rather than holding it forever', () => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
// A process that swallows the kill — the wedge this whole queue exists to prevent.
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
const next = queue.request({ worktree: '/wt/b' });
|
|
|
|
now += 60_001;
|
|
queue.get(next.id);
|
|
expect(queue.sweep().timedOut).toEqual([stuck.id]);
|
|
expect(queue.get(stuck.id).status).toBe('running'); // kill issued, exit not seen yet
|
|
expect(queue.get(next.id).status).toBe('queued');
|
|
|
|
now += 5_000;
|
|
const swept = queue.sweep();
|
|
|
|
expect(swept.forced).toEqual([stuck.id]);
|
|
expect(queue.get(stuck.id).status).toBe('timeout');
|
|
expect(queue.get(stuck.id).error).toMatch(/did not exit after kill/);
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
});
|
|
|
|
/**
|
|
* The two paths where a run's own 'exit' never arrives are exactly the two that must still
|
|
* release what the run owns. A real handle owns a capture file, two descriptors and a tail
|
|
* interval, and `finish` is bound to that 'exit' — so dropping `run.handle` here without a
|
|
* dispose left all three alive forever. Measured before the fix, on a forced run: 2 fds still
|
|
* open and the log still growing 2s after the run was reported terminal (logIndex 75 -> 471),
|
|
* the file at 102,465 bytes and never unlinked.
|
|
*
|
|
* Asserted on the QUEUE rather than on the handle: a `dispose()` that exists and is never
|
|
* called is exactly the shape this missed the first time.
|
|
*/
|
|
it('disposes a handle whose process never exits, on both release paths', () => {
|
|
const disposals: string[] = [];
|
|
const stubborn = (tag: string) => (): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun & { dispose: () => void };
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = '/wt';
|
|
handle.dispose = vi.fn(() => disposals.push(tag));
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
|
|
// Path 1 — the sweep's force-release past the kill grace.
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
runner.startRun = stubborn('forced');
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
now += 60_001;
|
|
queue.sweep();
|
|
now += 5_000;
|
|
expect(queue.sweep().forced).toEqual([stuck.id]);
|
|
expect(disposals).toEqual(['forced']);
|
|
|
|
// Path 2 — daemon shutdown, which exits the process immediately afterwards.
|
|
const queue2 = build();
|
|
runner.startRun = stubborn('shutdown');
|
|
queue2.request({ worktree: '/wt/b' });
|
|
queue2.shutdown();
|
|
expect(disposals).toEqual(['forced', 'shutdown']);
|
|
});
|
|
|
|
/**
|
|
* The regression the dispose itself introduced, and the reason a spy-only fake could not see it.
|
|
*
|
|
* `dispose()` and `finish()` share one `finished` flag, so disposing DISABLES the child's exit
|
|
* callback. `shutdown()` disposed and settled nothing, so the run stayed `running` forever and
|
|
* `running.size` never dropped — `pump()` could never start another run. That is the wedge this
|
|
* queue exists to prevent, reintroduced by the fix for a leak.
|
|
*
|
|
* The fake below carries the real interaction — a shared flag whose `dispose` suppresses the
|
|
* later exit — because a `vi.fn()` that only records the call cannot express it.
|
|
*/
|
|
it('settles a run it shuts down, even though disposing suppresses the exit callback', () => {
|
|
const queue = build();
|
|
runner.startRun = ({ worktree, onExit }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun & { dispose: () => void };
|
|
let done = false;
|
|
handle.kill = vi.fn();
|
|
// The shape of the real handle: dispose and exit share one latch.
|
|
handle.dispose = () => {
|
|
done = true;
|
|
};
|
|
handle.finish = (code: number) => {
|
|
if (done) return;
|
|
done = true;
|
|
onExit?.(code);
|
|
};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
queue.shutdown();
|
|
// The SIGKILLed child's exit arrives after the dispose and is swallowed — as it is in reality.
|
|
runner.started[0].finish(-1);
|
|
|
|
expect(queue.get(run.id).status).toBe('cancelled');
|
|
expect(queue.get(run.id).error).toMatch(/daemon-shutdown/);
|
|
// The slot, which is the thing that actually wedges: it must be free.
|
|
expect(queue.running.size).toBe(0);
|
|
});
|
|
|
|
/**
|
|
* Releasing the SLOT matters more than releasing the file descriptors.
|
|
*
|
|
* `dispose()` does real IO and calls back into `onLog`, and it sits above the detach/release/
|
|
* settle that free the slot. Unguarded, a throw there skips all three and the queue is wedged —
|
|
* the identical failure the settle was added to fix. It also aborts the loop, leaving every
|
|
* remaining run unkilled.
|
|
*/
|
|
it.each([
|
|
['shutdown', (q: ReturnType<typeof build>) => q.shutdown(), 'cancelled'],
|
|
[
|
|
'the sweep force-release',
|
|
(q: ReturnType<typeof build>) => {
|
|
now += 60_001;
|
|
q.sweep();
|
|
now += 5_000;
|
|
q.sweep();
|
|
},
|
|
'timeout',
|
|
],
|
|
])('frees the slot on %s even when dispose throws', (_name, act, expected) => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun & { dispose: () => void };
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.dispose = () => {
|
|
throw new Error('capture release blew up');
|
|
};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
expect(() => act(queue)).not.toThrow();
|
|
|
|
expect(queue.running.size).toBe(0);
|
|
// The exact status for THIS path, not either-of. `toContain` over both would pass with the
|
|
// wrong terminal status — a shutdown reported as a timeout, or the reverse — which is the
|
|
// shape of assertion that lets a real mix-up through.
|
|
expect(queue.get(run.id).status).toBe(expected);
|
|
// And it must SAY so. Swallowing this silently hides a clipped log behind `logsDropped: 0`,
|
|
// which is the one outcome the queue's log contract rules out — and nothing asserted the line
|
|
// existed, so both call sites could revert to a silent catch with the suite still green.
|
|
expect(queue.logs(run.id).map((l: { message: string }) => l.message)).toContainEqual(
|
|
expect.stringContaining('capture release failed: capture release blew up')
|
|
);
|
|
});
|
|
|
|
// A runner that predates `dispose` must not crash the sweep — the queue calls it optionally.
|
|
it('tolerates a handle with no dispose', () => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
now += 60_001;
|
|
queue.sweep();
|
|
now += 5_000;
|
|
expect(() => queue.sweep()).not.toThrow();
|
|
expect(queue.get(stuck.id).status).toBe('timeout');
|
|
});
|
|
|
|
it('does not settle a forced run twice when its exit finally arrives', () => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
const next = queue.request({ worktree: '/wt/b' });
|
|
|
|
now += 60_001;
|
|
queue.get(next.id);
|
|
queue.sweep(); // kill requested; the fake's kill emits exit, settling it here
|
|
expect(queue.get(stuck.id).status).toBe('timeout');
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
|
|
now += 10_000;
|
|
const swept = queue.sweep();
|
|
|
|
expect(swept.forced).toEqual([]);
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
expect(runner.started).toHaveLength(2);
|
|
});
|
|
|
|
it('ignores an exit from a handle the run no longer owns', () => {
|
|
const queue = build();
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const second = queue.request({ worktree: '/wt/b' });
|
|
|
|
runner.started[0].finish(0);
|
|
expect(queue.get(second.id).status).toBe('running');
|
|
|
|
// A late exit from the first run's dead handle must not settle anything or free a second slot.
|
|
runner.started[0].finish(1);
|
|
|
|
expect(queue.get(first.id).status).toBe('completed');
|
|
expect(queue.get(first.id).exitCode).toBe(0);
|
|
expect(queue.get(second.id).status).toBe('running');
|
|
expect(runner.started).toHaveLength(2);
|
|
});
|
|
|
|
it('cancels a queued run without disturbing the running one', () => {
|
|
const queue = build();
|
|
const running = queue.request({ worktree: '/wt/a' });
|
|
const doomed = queue.request({ worktree: '/wt/b' });
|
|
const behind = queue.request({ worktree: '/wt/c' });
|
|
|
|
queue.cancel(doomed.id);
|
|
|
|
expect(queue.get(doomed.id).status).toBe('cancelled');
|
|
expect(queue.get(behind.id).position).toBe(1);
|
|
expect(queue.get(running.id).status).toBe('running');
|
|
expect(runner.started).toHaveLength(1);
|
|
});
|
|
|
|
it('returns null for a run it has never heard of, so a waiter can fail instead of poll forever', () => {
|
|
const queue = build();
|
|
|
|
expect(queue.get('nope')).toBeNull();
|
|
expect(queue.logs('nope')).toBeNull();
|
|
expect(queue.cancel('nope')).toBeNull();
|
|
});
|
|
|
|
// Every sweep between the kill and the grace expiring must leave the deadline alone. Sweeping
|
|
// resets it, the force-release never fires, and the queue wedges permanently -- and the daemon
|
|
// sweeps every 5s plus on every request, against a 30s default grace.
|
|
it('does not restart the kill grace on each sweep', () => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
const next = queue.request({ worktree: '/wt/b' });
|
|
|
|
now += 60_001;
|
|
queue.get(next.id);
|
|
expect(queue.sweep().timedOut).toEqual([stuck.id]);
|
|
|
|
// Four sweeps inside the grace, the way the daemon's own 5s timer would.
|
|
for (let i = 0; i < 4; i += 1) {
|
|
now += 1_000;
|
|
queue.get(next.id);
|
|
expect(queue.sweep().forced).toEqual([]);
|
|
}
|
|
now += 1_001;
|
|
|
|
expect(queue.sweep().forced).toEqual([stuck.id]);
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
});
|
|
|
|
it('refuses to call a killed run a pass, even when its process exited 0', () => {
|
|
const queue = build();
|
|
// A kill that does not itself end the process — on Windows it is a separately spawned
|
|
// taskkill, so the child can still exit on its own terms first.
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = (code: number) => handle.emit('exit', code);
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
|
|
queue.cancel(run.id);
|
|
// The child exited cleanly in the window between the kill being issued and it landing.
|
|
runner.started[0].finish(0);
|
|
|
|
const view = queue.get(run.id);
|
|
expect(view.status).toBe('cancelled');
|
|
expect(view.exitCode).toBe(0);
|
|
// What a waiter would exit with. 0 here would report a green suite that never finished.
|
|
expect(exitCodeFor(view)).toBe(1);
|
|
});
|
|
|
|
it('exits nonzero for every terminal state that is not a clean pass', () => {
|
|
expect(exitCodeFor({ status: 'completed', exitCode: 0 })).toBe(0);
|
|
expect(exitCodeFor({ status: 'timeout', exitCode: 0 })).toBe(1);
|
|
expect(exitCodeFor({ status: 'cancelled', exitCode: 0 })).toBe(1);
|
|
expect(exitCodeFor({ status: 'abandoned', exitCode: null })).toBe(1);
|
|
expect(exitCodeFor({ status: 'error', exitCode: null })).toBe(1);
|
|
// A distinctive code, so the table distinguishes passing the real code through from
|
|
// returning a bare 1.
|
|
expect(exitCodeFor({ status: 'failed', exitCode: 130 })).toBe(130);
|
|
// A child killed by a signal reports no code; -1 must not reach a shell as 255.
|
|
expect(exitCodeFor({ status: 'error', exitCode: -1 })).toBe(1);
|
|
});
|
|
|
|
it('calls a signal-killed run an error, not a test failure', () => {
|
|
const queue = build();
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
|
|
runner.started[0].finish(-1); // node reports no code when a child dies by signal
|
|
|
|
expect(queue.get(run.id).status).toBe('error');
|
|
expect(exitCodeFor(queue.get(run.id))).toBe(1);
|
|
});
|
|
|
|
it('keeps the verdict of a runner that reported an exit and then threw', () => {
|
|
const queue = build();
|
|
runner.startRun = ({ worktree, onExit }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
onExit!(0);
|
|
throw new Error('boom after reporting');
|
|
};
|
|
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
|
|
expect(queue.get(run.id).status).toBe('completed');
|
|
expect(queue.get(run.id).exitCode).toBe(0);
|
|
});
|
|
|
|
it('ignores a late exit arriving after the slot was force-released', () => {
|
|
const queue = build({ killGraceMs: 5_000 });
|
|
runner.startRun = ({ worktree }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = (code: number) => handle.emit('exit', code);
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
return handle;
|
|
};
|
|
const stuck = queue.request({ worktree: '/wt/a' });
|
|
const next = queue.request({ worktree: '/wt/b' });
|
|
|
|
now += 60_001;
|
|
queue.get(next.id);
|
|
queue.sweep();
|
|
now += 5_001;
|
|
expect(queue.sweep().forced).toEqual([stuck.id]);
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
|
|
// The abandoned process finally exits, cleanly, long after its slot was given away.
|
|
runner.started[0].finish(0);
|
|
|
|
expect(queue.get(stuck.id).status).toBe('timeout');
|
|
expect(queue.get(stuck.id).exitCode).toBeNull();
|
|
expect(queue.get(next.id).status).toBe('running');
|
|
expect(runner.started).toHaveLength(2);
|
|
});
|
|
|
|
it('settles a runner that reports its exit before it returns a handle', () => {
|
|
const queue = build();
|
|
runner.startRun = ({ worktree, onExit }: RunnerArgs): FakeRun => {
|
|
const handle = new EventEmitter() as FakeRun;
|
|
handle.kill = vi.fn();
|
|
handle.finish = () => {};
|
|
handle.worktree = worktree;
|
|
runner.started.push(handle);
|
|
onExit!(0); // synchronous, before this function has returned anything
|
|
return handle;
|
|
};
|
|
|
|
const first = queue.request({ worktree: '/wt/a' });
|
|
const second = queue.request({ worktree: '/wt/b' });
|
|
|
|
// Without this, the finished run holds the only slot until the run ceiling expires and
|
|
// everything behind it is abandoned instead of run.
|
|
expect(queue.get(first.id).status).toBe('completed');
|
|
expect(queue.get(second.id).status).toBe('completed');
|
|
expect(runner.started).toHaveLength(2);
|
|
});
|
|
|
|
it('hands back a wait command naming the run', () => {
|
|
const queue = build({ waitCommand: 'node cli.mjs test wait' });
|
|
const run = queue.request({ worktree: '/wt/a' });
|
|
|
|
expect(run.waitCommand).toBe(`node cli.mjs test wait ${run.id}`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A run's log window is bounded, and it has to be. What is not acceptable is dropping lines
|
|
* without saying so: a clipped log is byte-for-byte indistinguishable from a whole one, which is
|
|
* how a fragment gets read — and quoted — as a complete run.
|
|
*
|
|
* Measured through the real queue before this counter existed: a child that wrote 5,000 lines
|
|
* produced 1,998 in the window, and neither the run view nor the log response said a word.
|
|
*/
|
|
describe('a clipped log announces itself', () => {
|
|
const drive = (lines: number) => {
|
|
const { startRun } = makeRunner();
|
|
const queue = new TestQueue({ startRun });
|
|
const view = queue.request({ worktree: '/repo' });
|
|
const run = queue.runs.get(view.id);
|
|
for (let i = 0; i < lines; i++) queue.addLog(run, 'stdout', `line ${i}`);
|
|
return queue.get(view.id);
|
|
};
|
|
|
|
it('reports nothing dropped while the window still holds everything', () => {
|
|
const state = drive(10);
|
|
expect(state.logIndex).toBe(10);
|
|
expect(state.logsDropped).toBe(0);
|
|
});
|
|
|
|
// The count is the difference between what was emitted and what survives, so a reader can tell
|
|
// exactly how much of the run is missing rather than only that some of it is.
|
|
it('counts every line the window threw away', () => {
|
|
const state = drive(5000);
|
|
expect(state.logIndex).toBe(5000);
|
|
expect(state.logsDropped).toBe(3000);
|
|
expect(state.logIndex - state.logsDropped).toBe(2000);
|
|
});
|
|
|
|
// The waiters warn, but `test logs` fetches the window directly and would otherwise get a
|
|
// fragment with nothing attached to it. The daemon's log route reads this.
|
|
it('offers the same count to a caller reading logs directly', () => {
|
|
const { startRun } = makeRunner();
|
|
const queue = new TestQueue({ startRun });
|
|
const { id } = queue.request({ worktree: '/repo' });
|
|
const run = queue.runs.get(id);
|
|
for (let i = 0; i < 2500; i++) queue.addLog(run, 'stdout', `line ${i}`);
|
|
expect(queue.droppedFor(id)).toBe(500);
|
|
expect(queue.logs(id, -1)).toHaveLength(2000);
|
|
// An unknown run is 0 dropped, not a throw: the route answers 404 on its own terms.
|
|
expect(queue.droppedFor('nope')).toBe(0);
|
|
});
|
|
});
|