rvlib-shipkit 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,7 +41,8 @@ export const DEFAULT_BUDGET_MS = 2000
41
41
  * 5s default while the preload said otherwise. */
42
42
  export const FULL_TIMEOUT_MS = 120_000
43
43
  /** the wall ceiling of one `bun test` phase. bun's per test timeout never
44
- * fires on a worker stuck in a SYNCHRONOUS call, and bun's spawnSync has been
44
+ * fires on a worker stuck in a SYNCHRONOUS call, or in `expect(p).resolves`
45
+ * or `.rejects` on a promise that never settles, and bun's spawnSync has been
45
46
  * seen spinning on a child that already exited (planPhases avoids the known
46
47
  * trigger), so without this a hang lasts until something outside kills it. The quick
47
48
  * tier answers in seconds, so two minutes is far from any real run. */
@@ -182,14 +183,16 @@ export function poolWorkers(p: { cores: number; size: number; held: number }): n
182
183
 
183
184
  type Timed = { file: string; ms: number }
184
185
 
185
- export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
186
+ export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full'; red?: string[] }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
186
187
  const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
187
188
  const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
189
+ const red = new Set((p.red ?? []).map(normSuitePath))
188
190
  const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
189
191
  return {
190
192
  // the budget guards the tier every edit waits on. The full tier is
191
- // where slow suites belong, and it only reports
192
- over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file)) : [],
193
+ // where slow suites belong, and it only reports. A red suite took
194
+ // the time of its failure (a timeout, a crash), which its red explains
195
+ over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file) && !red.has(t.file)) : [],
193
196
  // half the budget, so a declaration does not flap on timing noise
194
197
  noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
195
198
  slowest: timed.slice(0, 3),
@@ -253,6 +256,20 @@ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>,
253
256
  return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
254
257
  }
255
258
 
259
+ /** a lone run over the budget on CPU runs once more. Under load the
260
+ * scheduler can spill a lone run onto the efficiency cores, where the same
261
+ * retired instructions cost up to 4x the CPU time (measured on Apple
262
+ * silicon), and a spill only ever raises the figure. A wait or a wall
263
+ * verdict is not CPU, so it keeps its one run */
264
+ export const retryAlone = (t: Timed, run: AloneRun, budgetMs: number, load: MachineLoad): boolean =>
265
+ confirmOver([t], { [t.file]: run }, budgetMs, load).over[0]?.over === 'cpu'
266
+
267
+ /** the run charged less, as confirmOver charges it: the smaller of CPU and wall */
268
+ export const smallerRun = (a: AloneRun, b: AloneRun): AloneRun => {
269
+ const charge = (r: AloneRun): number => Math.min(r.cpuMs ?? r.wallMs, r.wallMs)
270
+ return charge(b) < charge(a) ? b : a
271
+ }
272
+
256
273
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
257
274
 
258
275
  /** the line a confirmed suite fails with */
@@ -358,6 +375,37 @@ export function failLines(output: string): Map<string, string> {
358
375
 
359
376
  const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
360
377
 
378
+ /** the clause the headline carries for the gate line: the command phase one
379
+ * ran, and the flags the runner added to the script's own. Per file ignores
380
+ * stay out, the slow and serial counts already say what they leave out */
381
+ export function ranAs(phase: string[], rawArgs: string[]): string {
382
+ const shown = phase.filter((a) => !a.startsWith('--path-ignore-patterns'))
383
+ const added = shown.filter((a) => !rawArgs.includes(a))
384
+ return `as \`${['bun', 'test', ...shown].join(' ')}\`${added.length > 0 ? ` (added ${added.join(' ')})` : ''}`
385
+ }
386
+
387
+ /** the suites bun lists under `Interrupted while still running:` when a
388
+ * phase is killed, one indented `<file> (<n>s)` line each */
389
+ export function interruptedSuites(output: string): string[] {
390
+ const out: string[] = []
391
+ let inBlock = false
392
+ for (const line of output.split('\n')) {
393
+ if (/^Interrupted while still running:\s*$/.test(line)) { inBlock = true; continue }
394
+ if (!inBlock) continue
395
+ const m = /^\s+(\S+)\s+\(\d+(?:\.\d+)?m?s\)\s*$/.exec(line)
396
+ if (m == null) { inBlock = false; continue }
397
+ out.push(normSuitePath(m[1]!))
398
+ }
399
+ return out
400
+ }
401
+
402
+ /** the line a phase killed at its ceiling fails with. Measured on bun 1.4.3:
403
+ * a plain await of a promise that never settles fails at the per test
404
+ * timeout, while `expect(p).resolves` or `.rejects` on one blocks the worker
405
+ * past it, as a synchronous loop does */
406
+ export const stallWords = (name: string, ceilingMs: number, files: string[]): string =>
407
+ `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test blocked its worker past the per test timeout, either a synchronous loop or expect(...).resolves or .rejects on a promise that never settles`
408
+
361
409
  /** the suites bun names as a crashed parallel worker */
362
410
  export function crashedWorkers(output: string): { file: string; signal: string }[] {
363
411
  return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
@@ -20,7 +20,7 @@ import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.t
20
20
  import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
21
21
  import { gitRoot } from '../core/git-root.ts'
22
22
  import { loadConfig } from '../ts/config.ts'
23
- import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
23
+ import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
24
24
 
25
25
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
26
26
 
@@ -194,7 +194,9 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
194
194
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
195
195
  const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
196
196
  const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
197
- const judged = judgeSuites({ timings, tests, tier })
197
+ const crashed = crashedWorkers(p1.out)
198
+ const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
199
+ const judged = judgeSuites({ timings, tests, tier, red: [...red] })
198
200
  const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
199
201
 
200
202
  // a parallel timing carries every other suite's load, so a suspect
@@ -205,10 +207,20 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
205
207
  const parallelCpu = phaseCpu.cpu
206
208
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
207
209
  const aloneAt = Date.now()
208
- for (const [i, t] of rerun.entries()) {
209
- const run = await runPhase([...plan.alone, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false, null, rec)
210
+ const runAlone = async (t: { file: string }, name: string): Promise<AloneRun | null> => {
211
+ const run = await runPhase([...plan.alone, t.file], join(dir, `${name}.json`), ceilingMs, tmp, false, null, rec)
210
212
  const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
211
- if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
213
+ return wallMs == null ? null : { wallMs, cpuMs: run.cpuMs }
214
+ }
215
+ for (const [i, t] of rerun.entries()) {
216
+ const first = await runAlone(t, `alone-${i}`)
217
+ if (first == null) continue
218
+ aloneRuns[t.file] = first
219
+ if (!retryAlone(t, first, budget, load)) continue
220
+ const second = await runAlone(t, `alone-${i}-again`)
221
+ if (second == null) continue
222
+ aloneRuns[t.file] = smallerRun(first, second)
223
+ notes.push(`note: ${t.file} ran alone twice, ${secs(first.cpuMs ?? first.wallMs)} then ${secs(second.cpuMs ?? second.wallMs)} of CPU, and the smaller stands: a lone run the scheduler spills onto slower cores burns more CPU for the same work`)
212
224
  }
213
225
  const aloneMs = Date.now() - aloneAt
214
226
  const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
@@ -218,12 +230,9 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
218
230
  // a ceiling kill makes bun report every worker it held as crashed, and
219
231
  // those are the suites that never finished, not an outside kill
220
232
  const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
221
- p?.stalled === true
222
- ? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
223
- : null
224
- const crashed = crashedWorkers(p1.out)
233
+ p?.stalled === true ? stallWords(name, ceilingMs, [...new Set([...files, ...interruptedSuites(p.out)])]) : null
225
234
  const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
226
- const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
235
+ const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null).map(normSuitePath))]
227
236
  for (const line of stalls) if (line != null) killed.unshift(line)
228
237
  if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
229
238
  if (p2?.signal != null) {
@@ -233,7 +242,6 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
233
242
  // the record behind the hub's test files to fix: every suite this run
234
243
  // timed, with the alone timing where one was taken, and red when a
235
244
  // test in it failed or its worker died
236
- const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
237
245
  const overSet = new Set(over.map((o) => o.file))
238
246
  ctx.complete = killed.length === 0
239
247
  ctx.out = phases.map((p) => p.out).join('\n')
@@ -241,7 +249,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
241
249
  const at = Date.now()
242
250
  const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
243
251
  const why = failLines(phases.map((p) => p.out).join('\n'))
244
- for (const c of crashedWorkers(p1.out)) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
252
+ for (const c of crashed) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
245
253
  // which phase timed each file, and the CPU spent on it: an alone
246
254
  // rerun, the parallel phase's own figure, or a phase that ran that one file
247
255
  const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
@@ -289,7 +297,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
289
297
  if (fails.length > 0) { console.log(fails[0]); return 1 }
290
298
  const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
291
299
  const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
292
- console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
300
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier, ${ranAs(plan.parallel, rawArgs)}${slowest}`)
293
301
  return 0
294
302
  } finally {
295
303
  rmSync(dir, { recursive: true, force: true })
@@ -0,0 +1,11 @@
1
+ // the gate's typecheck keeps its own incremental cache, one file per project.
2
+ // a tsconfig's tsBuildInfoFile is shared by every tsc that runs that config (a
3
+ // watcher task, a raw script), and a verdict read off a cache another process
4
+ // writes is not a verdict on this tree. no fs here: it only names paths.
5
+ import { basename, dirname, join } from 'path'
6
+
7
+ export const gateBuildInfoArgs = (tsconfig: string): string[] => [
8
+ '--incremental',
9
+ '--tsBuildInfoFile',
10
+ join(dirname(tsconfig), 'node_modules/.cache/shipkit', `${basename(tsconfig, '.json')}.tsbuildinfo`),
11
+ ]
@@ -10,6 +10,7 @@
10
10
  // who fixes the one error tsc showed and re-runs into a second one has been
11
11
  // told the truth twice and believed it once.
12
12
  import { resolveTypecheckProjects } from 'rvlib-shipkit/src/ts/tsconfig-projects.ts'
13
+ import { gateBuildInfoArgs } from 'rvlib-shipkit/src/ts/typecheck-cache.ts'
13
14
 
14
15
  export function typecheckCli(argv: string[]): number {
15
16
  const [tsc, tsconfig] = argv
@@ -30,7 +31,7 @@ export function typecheckCli(argv: string[]): number {
30
31
 
31
32
  let worst = 0
32
33
  for (const p of resolved.projects) {
33
- const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { stdout: 'inherit', stderr: 'inherit' })
34
+ const r = Bun.spawnSync([tsc, '--noEmit', '-p', p, ...gateBuildInfoArgs(p)], { stdout: 'inherit', stderr: 'inherit' })
34
35
  if (r.exitCode !== 0) {
35
36
  console.log(`FAIL: ${p}`)
36
37
  worst = r.exitCode ?? 1