@@ -25,6 +25,8 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, sta
2525import { tmpdir } from 'node:os' ;
2626import path from 'node:path' ;
2727import { fileURLToPath } from 'node:url' ;
28+ // The parser the hook-budget block at the bottom of this file reads THIS file with.
29+ import ts from 'typescript' ;
2830// The registry itself, so the denominator block at the bottom of this file can
2931// hold the gate's output answerable to it rather than to a copied list (#18133).
3032import {
@@ -44,6 +46,15 @@ const SPEC = path.resolve(HERE, '../..');
4446const GATE = path . join ( HERE , 'check-liveness.mts' ) ;
4547const LEDGERS = path . join ( SPEC , 'liveness' ) ;
4648
49+ // The budget the gate-spawning CASES run under: `testTimeout` in vitest.config.ts
50+ // (60_000 in both projects). A HOOK runs under `hookTimeout` instead, vitest's
51+ // 10_000 ms default, which that config does not set — so a hook that spawns the
52+ // whole gate names the case budget itself (#21421). A literal, not an import: the
53+ // config cannot be imported from a test (its top level runs the filter preflights
54+ // against process.argv) and exports no constant. The last block of this file holds
55+ // this value at or above the config's, so the two cannot drift apart unseen.
56+ const GATE_BUDGET_MS = 60_000 ;
57+
4758// A repo-rooted path shaped exactly like a real pointer (so `evidence.mts`
4859// extracts it) that this repo has never contained.
4960const ROTTED = 'packages/plugins/driver-sql/src/sql-driver.ts' ;
@@ -1398,6 +1409,8 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
13981409 let tmp : string ;
13991410 let carrier : Carrier ;
14001411
1412+ // The one hook in this file that spawns the gate: it runs under the case budget
1413+ // (GATE_BUDGET_MS, last argument), not vitest's 10 s `hookTimeout` default (#21421).
14011414 beforeAll ( ( ) => {
14021415 tmp = mkdtempSync ( path . join ( tmpdir ( ) , 'os-liveness-tombstone-' ) ) ;
14031416
@@ -1428,7 +1441,7 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
14281441 enumerated : enumerated . length ,
14291442 eligible : eligible . length ,
14301443 } ;
1431- } ) ;
1444+ } , GATE_BUDGET_MS ) ;
14321445 afterAll ( ( ) => rmSync ( tmp , { recursive : true , force : true } ) ) ;
14331446
14341447 /**
@@ -1671,3 +1684,94 @@ describe('check:liveness — a `live` row may not opt into `authorWarn` (#21127)
16711684 expect ( output . split ( '\n' ) . filter ( ( l ) => l . startsWith ( '✗' ) ) , output ) . toHaveLength ( 1 ) ;
16721685 } ) ;
16731686} ) ;
1687+
1688+ // ---------------------------------------------------------------------------
1689+ // A hook that spawns the gate runs under the case budget (#21421)
1690+ //
1691+ // The cases above spawn the whole gate under `testTimeout`; a hook spawning the
1692+ // same gate defaults to `hookTimeout` (10 s), so a 5 to 9 s run had 1 to 5 s of
1693+ // margin and a loaded CI shard spent it — the `beforeAll` of the #19062 block
1694+ // timed out and its cases were skipped behind it. This reads THIS file's own
1695+ // source for every lifecycle hook whose callback reaches `spawnSync`, directly
1696+ // or through another function here, and holds each to `GATE_BUDGET_MS` as its
1697+ // timeout argument. Parsed rather than matched as text, so a hook is judged by
1698+ // its call, never by what a comment or this block's own prose happens to spell.
1699+ // ---------------------------------------------------------------------------
1700+ describe ( 'check:liveness — a hook that spawns the gate carries the case budget (#21421)' , ( ) => {
1701+ const HOOKS = new Set ( [ 'beforeAll' , 'beforeEach' , 'afterAll' , 'afterEach' ] ) ;
1702+ const self = fileURLToPath ( import . meta. url ) ;
1703+ const sf = ts . createSourceFile ( self , readFileSync ( self , 'utf8' ) , ts . ScriptTarget . Latest , true ) ;
1704+
1705+ const calleeOf = ( n : ts . Node ) : string => ( ts . isCallExpression ( n ) && ts . isIdentifier ( n . expression ) ? n . expression . text : '' ) ;
1706+ const callsAny = ( root : ts . Node , names : ReadonlySet < string > ) : boolean => {
1707+ let hit = false ;
1708+ const walk = ( n : ts . Node ) : void => {
1709+ if ( hit ) return ;
1710+ if ( names . has ( calleeOf ( n ) ) ) hit = true ;
1711+ else ts . forEachChild ( n , walk ) ;
1712+ } ;
1713+ walk ( root ) ;
1714+ return hit ;
1715+ } ;
1716+
1717+ // Every function in this file that reaches `spawnSync`, to a fixpoint.
1718+ const bodies = new Map < string , ts . Node > ( ) ;
1719+ const collect = ( n : ts . Node ) : void => {
1720+ if ( ts . isFunctionDeclaration ( n ) && n . name && n . body ) bodies . set ( n . name . text , n . body ) ;
1721+ if ( ts . isVariableDeclaration ( n ) && ts . isIdentifier ( n . name ) && n . initializer
1722+ && ( ts . isArrowFunction ( n . initializer ) || ts . isFunctionExpression ( n . initializer ) ) ) {
1723+ bodies . set ( n . name . text , n . initializer . body ) ;
1724+ }
1725+ ts . forEachChild ( n , collect ) ;
1726+ } ;
1727+ collect ( sf ) ;
1728+ const spawners = new Set ( [ 'spawnSync' ] ) ;
1729+ for ( let grew = true ; grew ; ) {
1730+ grew = false ;
1731+ for ( const [ name , body ] of bodies ) {
1732+ if ( ! spawners . has ( name ) && callsAny ( body , spawners ) ) {
1733+ spawners . add ( name ) ;
1734+ grew = true ;
1735+ }
1736+ }
1737+ }
1738+
1739+ const hooks : { hook : string ; line : number ; spawns : boolean ; timeout : string | null } [ ] = [ ] ;
1740+ const visit = ( n : ts . Node ) : void => {
1741+ if ( HOOKS . has ( calleeOf ( n ) ) ) {
1742+ const [ fn , timeout ] = ( n as ts . CallExpression ) . arguments ;
1743+ hooks . push ( {
1744+ hook : calleeOf ( n ) ,
1745+ line : sf . getLineAndCharacterOfPosition ( n . getStart ( sf ) ) . line + 1 ,
1746+ spawns : fn !== undefined && callsAny ( fn , spawners ) ,
1747+ timeout : timeout === undefined ? null : timeout . getText ( sf ) ,
1748+ } ) ;
1749+ }
1750+ ts . forEachChild ( n , visit ) ;
1751+ } ;
1752+ visit ( sf ) ;
1753+
1754+ // The scan must be capable of finding something: a refactor that renames
1755+ // `runGate` or the hooks would otherwise leave the next case green over nothing.
1756+ it ( 'sees the gate runner and this file\'s hooks' , ( ) => {
1757+ expect ( spawners . has ( 'runGate' ) , 'the scan no longer recognises runGate as reaching spawnSync' ) . toBe ( true ) ;
1758+ expect ( hooks . length , 'the scan found no lifecycle hook in this file' ) . toBeGreaterThan ( 0 ) ;
1759+ } ) ;
1760+
1761+ it ( 'every hook whose callback reaches the gate passes GATE_BUDGET_MS as its timeout' , ( ) => {
1762+ const unbudgeted = hooks
1763+ . filter ( ( h ) => h . spawns && h . timeout !== 'GATE_BUDGET_MS' )
1764+ . map ( ( h ) => `${ h . hook } at line ${ h . line } : timeout argument ${ h . timeout ?? 'absent (vitest hookTimeout default, 10 s)' } ` ) ;
1765+ expect (
1766+ unbudgeted ,
1767+ 'a hook that spawns the gate must run under the case budget — pass GATE_BUDGET_MS as its second argument' ,
1768+ ) . toEqual ( [ ] ) ;
1769+ } ) ;
1770+
1771+ it ( 'GATE_BUDGET_MS is not below the testTimeout vitest.config.ts gives the cases' , ( ) => {
1772+ const config = readFileSync ( path . join ( SPEC , 'vitest.config.ts' ) , 'utf8' ) ;
1773+ const budgets = [ ...config . matchAll ( / \b t e s t T i m e o u t : \s * ( [ \d _ ] + ) / g) ] . map ( ( m ) => Number ( ( m [ 1 ] ?? '' ) . replaceAll ( '_' , '' ) ) ) ;
1774+ expect ( budgets . length , 'no testTimeout found in vitest.config.ts' ) . toBeGreaterThan ( 0 ) ;
1775+ expect ( GATE_BUDGET_MS , `vitest.config.ts testTimeout values: ${ budgets . join ( ', ' ) } ` ) . toBeGreaterThanOrEqual ( Math . max ( ...budgets ) ) ;
1776+ } ) ;
1777+ } ) ;
0 commit comments