mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
1891ce76aa
* fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon The variable was read where the client decides what to CONNECT to and ignored where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status` therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could not reach it — the daemon has always accepted `--port`, the spawn simply passed no arguments and the daemon read no environment. Four files each decided the port for themselves and two of them were wrong: cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded 9444, and daemon.mjs saw only argv. That is not a bug any one of them contains — it is a bug in the set, so the number now lives in exactly one module and all four resolve it there. A daemon a client spawns inherits that client's environment, so both ends read the same variable through the same function; an explicit `--port` still wins for a daemon started by hand. resolveDaemonPort also refuses a value that is not a port rather than handing back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and fail a long way from its cause. Verified by reproducing the reported path, not by reading the code. On pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`, emitted no ready line, and nothing ever listened on 19461. After the change it reports and binds 19461, and the pid it reports is the child that was spawned — a port that answers proves a listener, not THIS listener. Six isolated mutants, each killed by its own named assertion: daemon ignores the environment -> both behavioural cases daemon ignores --port -> the --port precedence case resolver stops validating -> the rejects-a-non-port case console.mjs re-hardcodes 9444 -> both halves of the ledger test-unit-run.mjs re-hardcodes -> both halves of the ledger the default drifts to 9445 -> the default case console.mjs is a TUI with no end-to-end case here, which is why the ledger exists: it fails when the set of files deciding the port grows or shrinks. Closes ClickUp 868kuaa4e. * fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow 🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules `unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that saved and restored the developer's `daemon.pid` restored it ~100 ms before the daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every `pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the file is only written at daemon START, so it did not come back. That breaks the recipe SKILL.md itself gives for checking the daemon's interpreter, and makes `cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to stop answering before returning. The ledger scanned a FIXED list of four readers, so it could not detect the set GROWING — which the PR description claimed it did. A fifth hardcoded 9444 already existed while the test was green: `.claude/hooks/check-writable.mjs` baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched that nudge off for the daemon, the one long-lived server it most exists for. The ledger now walks the tree, so a file that does not exist yet is covered, and the hook reads the port from the module. Verified with a negative control: with DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent. The ledger's assertions were also spelled rather than structural. `includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own prose names the module in several — and `includes('resolveDaemonPort(')` is satisfied by any call whatever is done with the result, so `resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no arithmetic on a port; the assertions pin an import line and that call. Three more the audit found: - `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled rejection and no tests ran. Measured against base: base printed "running directly", HEAD died. Restored, with the outer `.catch()` as the general net. - `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is invisible (detached, stdio ignored) and reads only as "Failed to start daemon". Both inputs now go through `parsePort`, which names the input it is complaining about. - Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING daemon.mjs, which would have broken collection of the port-reservation suite. Resolution moved into `parseArgs`. Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that number had gone stale under a later edit. Out-of-range is now a different message from unparseable ('0' is a number, just not a port). The 9444 scan uses a word boundary so 19444 is not a false positive. Seven mutants, each applied alone, tree restored and cmp-verified between: shutdown() stops waiting -> the pid-file case a NEW file hardcodes 9444 -> the ledger (the growth case) the hook re-hardcodes 9444 -> the ledger console.mjs does resolvePort() + 1 -> the client-address case the targeted fallback is removed -> the degrade-to-direct case --port back to parseInt -> the argv validation case port resolved at module load -> the import-safety case 🔴 One of them SURVIVED the first run and the fix is the point: the fallback case asserted the generic phrase "running directly", which BOTH guards on that path print, so deleting the targeted guard left the suite green — the mutant died to the other guard. The assertion now names the specific message. scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green. * fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite Both regressions were introduced by the previous commit, not pre-existing, and a delta re-audit of 7dd65a9d62..85cb922567 found them. 🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to it, so setting it un-guarded the shared daemon. Reproduced through the hook's real stdin contract: with the override set, `curl http://localhost:9444/...` went from ask to allowed, and the hook's own selftest went from `all green` to `1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the shared one — both are live, so both need guarding. `daemonPortsGuarded()` now returns the union, and the selftest is green both plain and under the override. The other one is subtler and worse in effect. The outer `.catch()` I added wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and started a second, unqueued full suite beside it. That defeats the serialisation this script exists for, and the file had already decided the opposite for the status-code form of the same condition (`Lost contact with the test queue` -> exit 2). It now tracks whether the run was accepted: before acceptance it degrades to a direct run, after acceptance it exits 2. `err?.message`, because a non-Error throw printed `(undefined)` and a null throw raised inside the handler — back to the unhandled rejection the catch exists to prevent. Also from the re-audit: - The port scrape in the hook is a dependency on one line's formatting in another file and nothing tested it. A vitest case now drives the real `unboundedDevRequest`, with a negative control on a port the skill does not use. Breaking the scrape regex now fails two tests; it used to fail none. - `console.mjs` had no behavioural coverage, and every structural assertion was walkable by resolving the URL correctly and then drifting it. It now runs for real against a stub daemon on an ephemeral port — via `--tail`, since the dashboard refuses to start without a TTY and exits before contacting anything. - `--base-dev-port` was still `parseInt`, which made "argv gets the same validation the environment gets" false about half of argv. - The port range's upper bound was untested: fixtures were 0 and 70000, so 65535 could drift to 65536 unnoticed. Now pinned ON the boundary. - SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation and moved console.mjs's line to 89 in the same change. Now 89. - The module header claimed the scan covers "the whole skill ... anywhere". It covers three roots and four extensions; prose and test files are deliberately out of scope. Said so instead. 🔴 The ledger caught ME during this round: a comment I wrote explaining the un-guarding bug spelled the port, and `spells the port in exactly one source file` went red. That is the guard working on its author — reworded, not exempted. Round-3 battery, each mutant alone, tree cmp-verified between: outer .catch() deleted -> the accepted-run case accepted-check removed -> the accepted-run case hook override replaces default -> the union case hook scrape regex broken -> both hook cases upper bound 65535 -> 65536 -> the boundary case console.mjs drifts the URL -> the behavioural case + the ledger --base-dev-port back to parseInt -> the base-dev-port case All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots, which is the documented scope and is now stated in the header rather than overclaimed. 330 passed. typecheck 0 errors. Hook selftest green plain and under override. * style: prettier the added test file — the CI gate checks ADDED files and I never ran it `ESLint + Prettier (changed files)` went red on the round-3 push for one reason: `scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted. Four lines, all wrapping. Reproduced the gate's exact scope locally rather than guessing at it — it runs `prettier --list-different` over ADDED files only, which is why the four `.mjs` files this branch MODIFIES were never checked. Those four are unformatted, and the control says that is not mine: at the merge base `bc74ba06ea`, all four are already unformatted. Reformatting them would rewrite files this change does not own, which is the breadth rule in CLAUDE.md, so they are left alone. Local prettier is 2.8.8, the same version CI installs, so the local format is the one the gate will read. 21 tests still pass. * fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find. The console test was not wrapped in `withPidFilePreserved`, and the exposure is specifically on the FAILING path: on green the stub answers `/`, so the console never starts a daemon. Under the mutant the test exists to catch, it cannot reach the stub, falls through to its own `startDaemon`, and overwrites `daemon.pid` with a dead pid — the test that proves the bug also damaged the thing the rest of the file is careful about. Controlled both ways: with the drift mutant applied the test now goes red AND the pid file's md5 is unchanged. The port scrape in the hook is anchored on `export const` again. Dropping the anchor was justified as surviving a reformat, and that reasoning was wrong — a reformat does not rewrite `export const NAME =`. What it actually bought was letting the FIRST match anywhere in the file win, comments included: a `// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the hook guard 9999 and stop guarding the real port. Controlled: that same line now yields 9444. Three comments that no longer described their code. `test-unit-run.mjs` had the previous round's two lines left verbatim above the block that replaced them, so the same sentence appeared twice. The hook still said the port "is overridable via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which is neither lazy nor cached; the laziness is in `devPorts`. Numeric line references (`:123`, `:128`) replaced with named ones. Both had already rotted by three lines. This is the same class as the SKILL.md citation fixed last round, and a line number in a comment will rot again — a quoted message will not. Named the residual hole rather than implying it away: the daemon enqueues INSIDE the response write, so the slot is taken before the client can observe it. Lose the response between those points and `accepted` is still false while the run is queued. That window needs an idempotency key on the enqueue, not a flag here. Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the merge base as well as here, so it is pre-existing and reformatting it would rewrite files this change does not own. And `scripts/__tests__/*.ts` is typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's "typecheck 0 errors" says nothing about the new test file's annotations. Both are stated rather than quietly folded in. 330 passed. Hook selftest green plain and under the override. Test file prettier-clean.
222 lines
9.8 KiB
JavaScript
222 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* `pnpm run test:unit:run` — runs the unit suite, optionally through the dev-server queue.
|
|
*
|
|
* Off by default: with no flag set this spawns exactly the vitest command the script used to be,
|
|
* so CI and anyone who does not run the daemon see no change at all.
|
|
*
|
|
* With CIVITAI_TEST_QUEUE set, a full-suite run is routed through the daemon's queue instead, which
|
|
* serialises it against every other agent on the machine. Routing rather than refusing is the whole
|
|
* point: there is no second command to learn, nothing to wrap around, and an agent that never read
|
|
* the guidance still gets queued.
|
|
*/
|
|
|
|
import { spawn } from 'child_process';
|
|
import { existsSync } from 'fs';
|
|
import { dirname, resolve } from 'path';
|
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const CLI = resolve(repoRoot, '.claude/skills/dev-server/cli.mjs');
|
|
// The queue module owns the one rule that decides pass from fail. It is imported dynamically
|
|
// rather than at the top of the file because it lives under `.claude/`, which the direct path
|
|
// must keep working without: a static import would fail the whole script when the skill is absent.
|
|
const QUEUE = resolve(repoRoot, '.claude/skills/dev-server/scripts/test-queue.mjs');
|
|
// Same override the CLI and the daemon honour, and from the same module, so no two of the three
|
|
// can disagree about where the daemon is. Without it this file could only ever talk to the shared
|
|
// daemon, which is why the verdict below had no test: there was no way to stand a fake one up
|
|
// beside it. Imported on the queue path only, for the reason given above QUEUE.
|
|
const PORT_MODULE = resolve(repoRoot, '.claude/skills/dev-server/scripts/daemon-port.mjs');
|
|
let DAEMON = null;
|
|
// Whether the queue has taken ownership of this run. Once it has, a later failure must NOT be
|
|
// answered by starting a second, unqueued suite — see the note where this is set.
|
|
let accepted = false;
|
|
const POLL_MS = 2000;
|
|
|
|
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
|
|
export function queueDecision(args, env) {
|
|
if (env.CI) return { queue: false, why: 'CI runs the suite directly' };
|
|
if (!env.CIVITAI_TEST_QUEUE || /^(0|false|off|no)$/i.test(env.CIVITAI_TEST_QUEUE)) {
|
|
return { queue: false, why: 'CIVITAI_TEST_QUEUE is not set' };
|
|
}
|
|
// A narrow run is cheap and is the fast iteration loop. Queueing it behind a full suite would
|
|
// turn a two-second check into a nine-minute wait, and push callers toward batching more work
|
|
// into each run — the opposite of what this is for.
|
|
if (args.some((a) => TEST_FILE.test(a))) return { queue: false, why: 'run names specific files' };
|
|
return { queue: true };
|
|
}
|
|
|
|
function runDirect(args) {
|
|
// Resolved from node_modules rather than PATH, so this behaves the same when run directly as it
|
|
// does under `pnpm run`, which is the only context that puts .bin on PATH.
|
|
const local = resolve(
|
|
repoRoot,
|
|
'node_modules/.bin',
|
|
process.platform === 'win32' ? 'vitest.cmd' : 'vitest'
|
|
);
|
|
const bin = existsSync(local) ? local : 'vitest';
|
|
const child = spawn(bin, ['run', '--project', 'unit*', ...args], {
|
|
cwd: repoRoot,
|
|
stdio: 'inherit',
|
|
shell: process.platform === 'win32',
|
|
});
|
|
child.on('exit', (code, signal) => process.exit(signal ? 1 : code ?? 1));
|
|
child.on('error', (err) => {
|
|
console.error(`Failed to start vitest: ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
async function post(path, body) {
|
|
const res = await fetch(`${DAEMON}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`daemon returned ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function ensureDaemon() {
|
|
return new Promise((done) => {
|
|
const child = spawn(process.execPath, [CLI, 'status'], { cwd: repoRoot, stdio: 'ignore' });
|
|
child.on('exit', () => done());
|
|
child.on('error', () => done());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The queue keeps a bounded window of a run's output. Dropping the oldest lines is fine; dropping
|
|
* them SILENTLY is not, because a truncated log is indistinguishable from a complete one — that is
|
|
* how a clipped log gets quoted as a full-suite pass. Say the number out loud instead.
|
|
*/
|
|
function warnIfLogsDropped(state) {
|
|
if (!state.logsDropped) return;
|
|
console.error(
|
|
`WARNING: this log is INCOMPLETE — the queue dropped the oldest ${state.logsDropped} of ` +
|
|
`${state.logIndex} output lines. Do not read the text above as the whole run.`
|
|
);
|
|
}
|
|
|
|
async function runQueued(args) {
|
|
// Resolved once, up front: this is the module that decides pass from fail, and a waiter that
|
|
// discovers it cannot load that rule at the moment it must apply it has no verdict to give.
|
|
const { exitCodeFor } = await import(pathToFileURL(QUEUE).href);
|
|
|
|
// Resolving the daemon's address can THROW — a malformed DEV_DAEMON_PORT is rejected rather
|
|
// than silently becoming NaN. That must not cost the caller their test run: the
|
|
// "Test queue unreachable" guarantee below is that an unusable queue degrades to a direct run,
|
|
// and an unusable ADDRESS is the queue being unusable. Before this catch existed the throw
|
|
// escaped an un-awaited `runQueued` as an unhandled rejection and no tests ran at all.
|
|
try {
|
|
const { resolveDaemonUrl } = await import(pathToFileURL(PORT_MODULE).href);
|
|
DAEMON = resolveDaemonUrl();
|
|
} catch (err) {
|
|
console.error(`Test queue address unusable (${err.message}); running directly.`);
|
|
return runDirect(args);
|
|
}
|
|
|
|
let run;
|
|
try {
|
|
run = await post('/test-runs', { worktree: repoRoot, args });
|
|
} catch {
|
|
await ensureDaemon();
|
|
try {
|
|
run = await post('/test-runs', { worktree: repoRoot, args });
|
|
} catch (err) {
|
|
// Never leave a caller unable to run tests because the queue is unavailable.
|
|
console.error(`Test queue unreachable (${err.message}); running directly.`);
|
|
return runDirect(args);
|
|
}
|
|
}
|
|
|
|
// From here the daemon has ACCEPTED the run, and that changes what a failure may do. Falling
|
|
// back to a direct run now would start a second, unqueued full suite beside one the queue is
|
|
// already holding a slot for — which is precisely the serialisation this script exists to
|
|
// provide. The "Lost contact with the test queue" branch below already decided this for the
|
|
// status-code form of the same condition; the network form gets the same answer.
|
|
//
|
|
// Not airtight, and the gap is worth naming rather than implying it away: the daemon enqueues
|
|
// INSIDE the response write, so the slot is taken before the client can observe it. Lose the
|
|
// response between those two points and this flag is still false while the run is queued —
|
|
// the one window where a duplicate can still happen. Closing it needs an idempotency key on
|
|
// the enqueue, not a flag here.
|
|
accepted = true;
|
|
|
|
if (run.status === 'queued') {
|
|
console.error(
|
|
run.paused
|
|
? `Queued at position ${run.position}. The queue is PAUSED (concurrency 0) — nothing starts until it is raised.`
|
|
: `Queued at position ${run.position} of ${run.queueLength} (${run.running}/${run.concurrency} running).`
|
|
);
|
|
}
|
|
|
|
let lastLog = -1;
|
|
for (;;) {
|
|
const res = await fetch(`${DAEMON}/test-runs/${run.id}`);
|
|
if (res.status === 404) {
|
|
console.error(
|
|
`The daemon forgot run ${run.id} — it was most likely restarted. Re-run this command.`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
if (!res.ok) {
|
|
console.error(`Lost contact with the test queue (${res.status}).`);
|
|
process.exit(2);
|
|
}
|
|
const state = await res.json();
|
|
|
|
const logs = await fetch(`${DAEMON}/test-runs/${run.id}/logs?since=${lastLog}`).then((r) =>
|
|
r.json()
|
|
);
|
|
for (const entry of logs.logs ?? []) {
|
|
console.log(entry.message);
|
|
lastLog = entry.index;
|
|
}
|
|
|
|
if (state.status !== 'queued' && state.status !== 'running') {
|
|
if (state.status !== 'completed')
|
|
console.error(`Run ${state.status}${state.error ? `: ${state.error}` : ''}`);
|
|
warnIfLogsDropped(state);
|
|
// The verdict comes from the queue's own `exitCodeFor`, never from a second copy of the rule
|
|
// here. The copy that used to live on this line read `state.exitCode || 1`, which passes a
|
|
// signal-killed run's recorded -1 straight through: `process.exit(-1)` gives the shell 255,
|
|
// the exact number `exitCodeFor` exists to avoid, and `[ $? -eq 1 ]` misreads it.
|
|
process.exit(exitCodeFor(state));
|
|
}
|
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
}
|
|
}
|
|
|
|
if (
|
|
import.meta.url === `file://${process.argv[1]}` ||
|
|
process.argv[1] === fileURLToPath(import.meta.url)
|
|
) {
|
|
const args = process.argv.slice(2);
|
|
const decision = queueDecision(args, process.env);
|
|
if (decision.queue && existsSync(CLI) && existsSync(QUEUE) && existsSync(PORT_MODULE)) {
|
|
// Un-awaited at top level, so anything runQueued throws would otherwise be an unhandled
|
|
// rejection that kills the process with no tests run.
|
|
//
|
|
// What it does about it depends on whether the queue took the run. Before acceptance,
|
|
// degrading to a direct run keeps the "Test queue unreachable" guarantee. AFTER acceptance
|
|
// it would start a
|
|
// second, unqueued suite beside the one the queue is holding a slot for, so it exits 2 — the
|
|
// same verdict :159 already gives when the poll comes back with a bad status.
|
|
//
|
|
// `err?.message` rather than `err.message`: a non-Error throw would otherwise print
|
|
// `(undefined)`, and a null throw would raise inside the handler and land back at the
|
|
// unhandled rejection this exists to prevent.
|
|
runQueued(args).catch((err) => {
|
|
const detail = err?.message ?? String(err);
|
|
if (accepted) {
|
|
console.error(`Lost contact with the test queue (${detail}). The run may still be queued.`);
|
|
process.exit(2);
|
|
}
|
|
console.error(`Test queue failed (${detail}); running directly.`);
|
|
runDirect(args);
|
|
});
|
|
} else runDirect(args);
|
|
}
|