2525 * Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
2626 * lt-dev `running-check-script` skill relies on: non-zero === failed).
2727 */
28- import { spawn } from "node:child_process" ;
28+ import { execSync , spawn } from "node:child_process" ;
2929import { readdirSync , readFileSync } from "node:fs" ;
3030import { dirname , join } from "node:path" ;
3131import { fileURLToPath } from "node:url" ;
@@ -107,16 +107,29 @@ function toFixCommand(kind, cmd) {
107107}
108108
109109// ── metric parsers ─────────────────────────────────────────────────────────
110+ // Sum capture group 1 across every match of `re` (which must carry the `g` flag).
111+ // Returns null when nothing matched, so callers can tell "absent" from "zero".
112+ function sumMatches ( clean , re ) {
113+ let total = null ;
114+ for ( const m of clean . matchAll ( re ) ) {
115+ const n = Number ( m [ 1 ] ) ;
116+ if ( Number . isFinite ( n ) ) total = ( total ?? 0 ) + n ;
117+ }
118+ return total ;
119+ }
120+ // A single test step may invoke vitest more than once (e.g. `vitest:unit && vitest`),
121+ // emitting one summary block per run. Sum them all — reading only the first
122+ // silently under-reports every later run.
110123function parseVitest ( out ) {
111124 const clean = stripAnsi ( out ) ;
112- const tests = clean . match ( / T e s t s \s + (?: ( \d + ) \s + f a i l e d [ ^ \n ] * ?) ? ( \d + ) \s + p a s s e d / i ) ;
113- const files = clean . match ( / T e s t F i l e s \s + (?: ( \d + ) \s + f a i l e d [ ^ \n ] * ?) ? ( \d + ) \s + p a s s e d / i ) ;
114- const failed = clean . match ( / T e s t s \s + ( \d + ) \s + f a i l e d / i ) ;
115- if ( ! tests && ! files ) return null ;
125+ const passed = sumMatches ( clean , / T e s t s \s + (?: \d + \s + f a i l e d [ ^ \n ] * ?) ? ( \d + ) \s + p a s s e d / gi ) ;
126+ const files = sumMatches ( clean , / T e s t F i l e s \s + (?: \d + \s + f a i l e d [ ^ \n ] * ?) ? ( \d + ) \s + p a s s e d / gi ) ;
127+ const failed = sumMatches ( clean , / T e s t s \s + ( \d + ) \s + f a i l e d / gi ) ;
128+ if ( passed == null && files == null ) return null ;
116129 return {
117- failed : failed ? Number ( failed [ 1 ] ) : 0 ,
118- files : files ? Number ( files [ 2 ] ) : null ,
119- passed : tests ? Number ( tests [ 2 ] ) : null ,
130+ failed : failed ?? 0 ,
131+ files,
132+ passed,
120133 } ;
121134}
122135function parseLint ( out ) {
@@ -150,21 +163,100 @@ async function runAudit(auditCmd) {
150163 return { auditCmd, blocking : code !== 0 , counts, reason : counts ? null : out , total } ;
151164}
152165
166+ // Watchdog: kill a TEST step whose child produces NO output for this long. A
167+ // wedged test run (workers idle at 0% CPU — e.g. one spec file grinding through
168+ // retries after its app/socket state broke under load) otherwise spins the live
169+ // view forever: the spinner only proves the child process exists, not that it
170+ // progresses. Only test steps are watched: build / typecheck / audit
171+ // legitimately buffer all their output to the end (and go silent under a
172+ // non-TTY pipe), so watching them would false-kill a slow-but-progressing run.
173+ // Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
174+ // disables it.
175+ const IDLE_TIMEOUT_MS = ( ( ) => {
176+ const flag = process . argv . find ( ( a ) => a . startsWith ( "--idle-timeout=" ) ) ;
177+ const raw = flag ? flag . slice ( "--idle-timeout=" . length ) : process . env . CHECK_IDLE_TIMEOUT ;
178+ const DEFAULT_MS = 300 * 1000 ;
179+ if ( raw === undefined || raw === "" ) return DEFAULT_MS ;
180+ const seconds = Number ( raw ) ;
181+ if ( seconds === 0 ) return 0 ; // explicit opt-out
182+ if ( ! Number . isFinite ( seconds ) || seconds < 0 ) {
183+ process . stderr . write ( `[check] ignoring invalid idle-timeout "${ raw } ", using ${ DEFAULT_MS / 1000 } s\n` ) ;
184+ return DEFAULT_MS ;
185+ }
186+ return seconds * 1000 ;
187+ } ) ( ) ;
188+
153189// ── command runner ─────────────────────────────────────────────────────────
154190const RUNNING = new Set ( ) ;
155- function capture ( cmd , cwd ) {
191+
192+ // Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
193+ // fork workers). Killing only the direct child orphans the tree — exactly the
194+ // zombie workers a deadlock leaves behind. Children are collected via pgrep
195+ // and killed leaves-first.
196+ function killTree ( child , signal = "SIGTERM" ) {
197+ const pids = [ ] ;
198+ const collect = ( pid ) => {
199+ pids . push ( pid ) ;
200+ let out = "" ;
201+ try {
202+ out = execSync ( `pgrep -P ${ pid } ` , { stdio : [ "ignore" , "pipe" , "ignore" ] } )
203+ . toString ( )
204+ . trim ( ) ;
205+ } catch {
206+ /* no children */
207+ }
208+ if ( out ) for ( const p of out . split ( "\n" ) ) collect ( Number ( p ) ) ;
209+ } ;
210+ collect ( child . pid ) ;
211+ for ( const pid of pids . reverse ( ) ) {
212+ try {
213+ process . kill ( pid , signal ) ;
214+ } catch {
215+ /* already gone */
216+ }
217+ }
218+ }
219+
220+ // idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
221+ // runs it unwatched. Only callers that KNOW the child streams progress (test
222+ // steps) should pass a timeout — see runGroup.
223+ function capture ( cmd , cwd , idleTimeoutMs = 0 ) {
156224 return new Promise ( ( resolve ) => {
157225 const child = spawn ( cmd , { cwd, shell : true } ) ;
158226 RUNNING . add ( child ) ;
159227 let out = "" ;
228+ let idleTimer = null ;
229+ let killTimer = null ;
230+ let watchdogHit = false ;
231+ const armWatchdog = ( ) => {
232+ if ( ! idleTimeoutMs ) return ;
233+ clearTimeout ( idleTimer ) ;
234+ idleTimer = setTimeout ( ( ) => {
235+ watchdogHit = true ;
236+ killTree ( child ) ;
237+ killTimer = setTimeout ( ( ) => killTree ( child , "SIGKILL" ) , 5000 ) ;
238+ killTimer . unref ( ) ;
239+ } , idleTimeoutMs ) ;
240+ } ;
160241 const onData = ( d ) => {
161242 out += d ;
243+ armWatchdog ( ) ;
162244 if ( VERBOSE ) process . stdout . write ( d ) ;
163245 } ;
246+ armWatchdog ( ) ;
164247 child . stdout . on ( "data" , onData ) ;
165248 child . stderr . on ( "data" , onData ) ;
166249 const done = ( code , extra ) => {
250+ clearTimeout ( idleTimer ) ;
251+ clearTimeout ( killTimer ) ;
167252 RUNNING . delete ( child ) ;
253+ if ( watchdogHit ) {
254+ const note =
255+ `[watchdog] step produced no output for ${ Math . round ( idleTimeoutMs / 1000 ) } s — ` +
256+ "process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
257+ `not a slow run. Re-run the step directly to debug: \`${ cmd } \`` ;
258+ return resolve ( { code : 1 , out : `${ out } \n${ note } ` } ) ;
259+ }
168260 resolve ( { code, out : extra ? `${ out } \n${ extra } ` : out } ) ;
169261 } ;
170262 child . on ( "close" , ( code ) => done ( code ?? 1 ) ) ;
@@ -174,13 +266,36 @@ function capture(cmd, cwd) {
174266function killAll ( ) {
175267 for ( const child of RUNNING ) {
176268 try {
177- child . kill ( "SIGTERM" ) ;
269+ killTree ( child ) ;
178270 } catch {
179271 /* already gone */
180272 }
181273 }
182274}
183275
276+ // A child killed by a signal surfaces through the package manager as a
277+ // "Command failed with exit code 143/137" line (SIGTERM/SIGKILL), NOT as a test
278+ // assertion failure — and the outer shell then reports its own generic exit 1,
279+ // so `code` alone never reveals it. Surface the signal so the reason isn't
280+ // mistaken for a real failure: the usual cause is resource pressure (parallel
281+ // checks/builds swapping the machine) or an external kill.
282+ function signalExitHint ( out ) {
283+ const clean = stripAnsi ( out ) ;
284+ // The watchdog also kills via SIGTERM, so pnpm's "exit code 143" ends up in
285+ // the output — but that path already carries its own [watchdog] note with the
286+ // correct (deadlock) diagnosis. Don't stack a contradictory "external kill"
287+ // hint on top of it.
288+ if ( / \[ w a t c h d o g \] / . test ( clean ) ) return null ;
289+ const m = clean . match ( / C o m m a n d f a i l e d w i t h e x i t c o d e ( 1 3 7 | 1 4 3 ) \b / ) ;
290+ if ( ! m ) return null ;
291+ const sig = m [ 1 ] === "143" ? "SIGTERM" : "SIGKILL" ;
292+ return (
293+ `[check] step ended via ${ sig } (exit ${ m [ 1 ] } ) — the process was killed, not an assertion failure. ` +
294+ "Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. " +
295+ "Re-run this project's check alone to confirm."
296+ ) ;
297+ }
298+
184299// ── live multi-line status (one line per running project) ────────────────────
185300const FRAMES = [ "⠋" , "⠙" , "⠹" , "⠸" , "⠼" , "⠴" , "⠦" , "⠧" , "⠇" , "⠏" ] ;
186301let liveCount = 0 ;
@@ -328,7 +443,10 @@ async function runGroup(group, states, results, abort) {
328443 st . current = step . label ;
329444 st . stepStart = Date . now ( ) ;
330445 if ( ! TTY ) process . stdout . write ( ` ${ C . dim ( "→" ) } ${ shortRel ( rel ) } · ${ step . label } \n` ) ;
331- const { code, out } = await capture ( step . cmd , step . cwd ) ;
446+ // Watchdog only on test steps (see IDLE_TIMEOUT_MS): a test runner streams
447+ // output continuously, so prolonged silence == deadlocked workers. Other
448+ // steps buffer their output and must run unwatched.
449+ const { code, out } = await capture ( step . cmd , step . cwd , step . kind === "test" ? IDLE_TIMEOUT_MS : 0 ) ;
332450 const dur = Date . now ( ) - st . stepStart ;
333451 const r = { dur, kind : step . kind , label : step . label , project : rel } ;
334452 if ( step . kind === "test" ) r . tests = parseVitest ( out ) ;
@@ -338,7 +456,12 @@ async function runGroup(group, states, results, abort) {
338456 st . failed = step . label ;
339457 if ( ! abort . hit ) {
340458 abort . hit = true ;
341- abort . failure = { out, project : rel , step : `${ shortRel ( rel ) } · ${ step . label } ` } ;
459+ const hint = signalExitHint ( out ) ;
460+ abort . failure = {
461+ out : hint ? `${ out } \n${ hint } ` : out ,
462+ project : rel ,
463+ step : `${ shortRel ( rel ) } · ${ step . label } ` ,
464+ } ;
342465 killAll ( ) ;
343466 }
344467 return ;
@@ -383,9 +506,9 @@ async function main() {
383506 if ( ! TTY ) process . stdout . write ( ` ${ C . dim ( "→" ) } audit\n` ) ;
384507 else drawLive ( [ `${ C . cyan ( FRAMES [ 0 ] ) } audit` ] ) ;
385508 const audit = await runAudit ( auditCmd ) ;
386- liveCount = 0 ;
387509 const dur = Date . now ( ) - t ;
388510 if ( audit . blocking ) {
511+ liveCount = 0 ; // the failure line must survive — nothing may overwrite it
389512 const summary = audit . counts
390513 ? `${ audit . total } vuln (${ renderVulnLine ( audit . counts ) } )`
391514 : "failed" ;
@@ -396,10 +519,16 @@ async function main() {
396519 started ,
397520 ) ;
398521 }
399- console . log (
400- `${ C . green ( "✓" ) } audit ${ audit . counts ? renderVulnLine ( audit . counts ) : C . dim ( "0" ) } ${ C . dim ( `(${ fmtDuration ( dur ) } )` ) } ` ,
401- ) ;
522+ if ( ! TTY ) {
523+ process . stdout . write (
524+ ` ${ C . green ( "✓" ) } audit ${ audit . counts ? renderVulnLine ( audit . counts ) : C . dim ( "0" ) } ${ C . dim ( `(${ fmtDuration ( dur ) } )` ) } \n` ,
525+ ) ;
526+ }
527+ // TTY success: NO permanent line — the live status view overwrites the audit
528+ // row (like every other step); the result lands in the report twice: the
529+ // Steps list (entry below) and the Vulnerabilities section.
402530 results . push ( { audit, kind : "audit" } ) ;
531+ results . push ( { dur, kind : "step" , label : "audit" , project : "." } ) ;
403532 }
404533
405534 // Per-project steps — parallel by default, serial with --sequential.
0 commit comments