@vzn/vx 0.0.650 → 0.0.651
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.
- package/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/why.ts
CHANGED
|
@@ -18,19 +18,9 @@ import {
|
|
|
18
18
|
whyDidThisRerun,
|
|
19
19
|
} from '../history/index.js'
|
|
20
20
|
import { resolveRunId } from '../orchestrator/index.js'
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
MASKED,
|
|
25
|
-
nearMatches,
|
|
26
|
-
paint,
|
|
27
|
-
printable,
|
|
28
|
-
refusal,
|
|
29
|
-
SUCCESS,
|
|
30
|
-
ui,
|
|
31
|
-
UserError,
|
|
32
|
-
WARN,
|
|
33
|
-
} from '../util/index.js'
|
|
21
|
+
import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
|
|
22
|
+
import * as ui from '../ui/index.js'
|
|
23
|
+
import type { Style } from '../ui/index.js'
|
|
34
24
|
import { runRules } from '../settings.js'
|
|
35
25
|
import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
|
|
36
26
|
import { findCwdProject } from './select.js'
|
|
@@ -165,6 +155,7 @@ async function resolveTarget(
|
|
|
165
155
|
cache: Cache,
|
|
166
156
|
target: string,
|
|
167
157
|
cwdProject: () => Promise<string | null>,
|
|
158
|
+
declared: () => Promise<boolean>,
|
|
168
159
|
): Promise<string> {
|
|
169
160
|
const db = indexDb(cache)
|
|
170
161
|
if (target.includes('#')) {
|
|
@@ -202,11 +193,21 @@ async function resolveTarget(
|
|
|
202
193
|
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
203
194
|
const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
|
|
204
195
|
if (ids.length === 0 && !invoked) {
|
|
196
|
+
// `vx run` of a task no project declares is refused: its advice was that.
|
|
197
|
+
const known = await declared()
|
|
205
198
|
throw refusal(
|
|
206
|
-
|
|
199
|
+
known
|
|
200
|
+
? { tone: 'info', headline: `no runs of ${target} yet`, fix: `vx run ${target}` }
|
|
201
|
+
: {
|
|
202
|
+
tone: 'info',
|
|
203
|
+
headline: `no project declares ${target}`,
|
|
204
|
+
fix: 'vx show lists the tasks',
|
|
205
|
+
},
|
|
207
206
|
{
|
|
208
207
|
code: 'VX_E_NO_HISTORY',
|
|
209
|
-
message:
|
|
208
|
+
message: known
|
|
209
|
+
? `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`
|
|
210
|
+
: `vx why: no project declares "${target}" (vx show lists the tasks)`,
|
|
210
211
|
},
|
|
211
212
|
)
|
|
212
213
|
}
|
|
@@ -264,8 +265,12 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
264
265
|
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
265
266
|
try {
|
|
266
267
|
const db = indexDb(cache)
|
|
267
|
-
const
|
|
268
|
-
|
|
268
|
+
const target = await rootSpelled(root, parsed.target)
|
|
269
|
+
const taskId = await resolveTarget(
|
|
270
|
+
cache,
|
|
271
|
+
target,
|
|
272
|
+
() => findCwdProject(process.cwd()),
|
|
273
|
+
() => declares(root, target),
|
|
269
274
|
)
|
|
270
275
|
let runId: string | null
|
|
271
276
|
if (parsed.runId !== undefined) {
|
|
@@ -290,14 +295,23 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
290
295
|
// the persisted entry metadata so the verb still says something useful.
|
|
291
296
|
const explanation = explainCacheKey(db, taskId)
|
|
292
297
|
if (parsed.format === 'json') {
|
|
293
|
-
|
|
298
|
+
ui.data('out', `${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
|
|
294
299
|
} else {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
ui.print(
|
|
301
|
+
'out',
|
|
302
|
+
ui.section(
|
|
303
|
+
'Why',
|
|
304
|
+
taskId,
|
|
305
|
+
ui.text('recorded runs carry no run id: the latest cache entry instead'),
|
|
306
|
+
ui.fields(
|
|
307
|
+
explanation.latestEntry !== null
|
|
308
|
+
? [
|
|
309
|
+
['key', explanation.latestEntry.hash],
|
|
310
|
+
['command', ui.cmd(explanation.latestEntry.command)],
|
|
311
|
+
]
|
|
312
|
+
: [['key', 'no cache entry either']],
|
|
313
|
+
),
|
|
314
|
+
),
|
|
301
315
|
)
|
|
302
316
|
}
|
|
303
317
|
return 0
|
|
@@ -326,27 +340,22 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
326
340
|
const writers = inputWriters(db, root, taskId, diff.entries, roots)
|
|
327
341
|
|
|
328
342
|
if (parsed.format === 'json') {
|
|
329
|
-
|
|
343
|
+
ui.data('out', `${JSON.stringify({ taskId, runId, why, diff, roots, writers })}\n`)
|
|
330
344
|
return 0
|
|
331
345
|
}
|
|
332
346
|
|
|
333
|
-
const lines: string[] = []
|
|
334
|
-
const c = detectColors(process.stdout)
|
|
335
|
-
const dim = (text: string): string => paint('', text, c, { dim: true })
|
|
336
|
-
const head = (text: string): string => paint('', text, c, { bold: true })
|
|
337
347
|
const t = why.thisRun!
|
|
338
|
-
|
|
339
|
-
|
|
348
|
+
const head: ui.FieldRow[] = [
|
|
349
|
+
['run', runId],
|
|
350
|
+
['this run', runFacts(t)],
|
|
351
|
+
]
|
|
340
352
|
if (why.previousRun == null && t.hash === '') {
|
|
341
353
|
// Only keyed runs are compared: a persistent task's tenth run read
|
|
342
354
|
// as its first, and the note saying why went unprinted.
|
|
343
|
-
|
|
344
|
-
lines.push(` ${dim('verdict')} ${why.note}`)
|
|
355
|
+
head.push(['previous', 'none with a cache key'], ['verdict', why.note])
|
|
345
356
|
} else if (why.previousRun == null) {
|
|
346
|
-
|
|
357
|
+
head.push(['previous', 'none: first recorded run of this task'])
|
|
347
358
|
} else {
|
|
348
|
-
const p = why.previousRun
|
|
349
|
-
lines.push(` ${dim('previous')} ${runFacts(p)}`)
|
|
350
359
|
// The key moved and the diff says by what: the verdict names it, so
|
|
351
360
|
// an env-only change reads as one line, not "inputs differ" above a
|
|
352
361
|
// table to scan.
|
|
@@ -354,56 +363,71 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
354
363
|
const moved = diff.entries.map((e) =>
|
|
355
364
|
e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
|
|
356
365
|
)
|
|
357
|
-
|
|
358
|
-
|
|
366
|
+
head.push(
|
|
367
|
+
['previous', runFacts(why.previousRun)],
|
|
368
|
+
[
|
|
369
|
+
'verdict',
|
|
359
370
|
why.hashChanged === true && moved.length > 0
|
|
360
|
-
?
|
|
361
|
-
|
|
371
|
+
? ui.tone(
|
|
372
|
+
'warn',
|
|
362
373
|
`cache key changed: ${moved.slice(0, 3).join(', ')}${moved.length > 3 ? ` and ${moved.length - 3} more` : ''}`,
|
|
363
|
-
c,
|
|
364
374
|
)
|
|
365
|
-
: why.note
|
|
366
|
-
|
|
375
|
+
: why.note,
|
|
376
|
+
],
|
|
367
377
|
)
|
|
368
378
|
}
|
|
379
|
+
if (diff.entries.length === 0 && why.hashChanged === true) head.push(['detail', diff.note])
|
|
380
|
+
const blocks: ui.Block[] = [ui.section('Why', taskId, ui.fields(head))]
|
|
369
381
|
|
|
370
382
|
if (diff.entries.length > 0) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
383
|
+
blocks.push(
|
|
384
|
+
ui.section(
|
|
385
|
+
'What changed',
|
|
386
|
+
`${diff.entries.length} of ${diff.entries.length + diff.unchangedCount}`,
|
|
387
|
+
ui.table(
|
|
388
|
+
['change', 'kind', 'name', 'before → after'],
|
|
389
|
+
diff.entries.map((e) => [
|
|
390
|
+
ui.tone(CHANGE_HUE[e.change] ?? 'warn', e.change),
|
|
391
|
+
e.kind,
|
|
392
|
+
ui.strong(printable(e.name)),
|
|
393
|
+
// A secret-named env input shows its name and its change, no hash.
|
|
394
|
+
e.before === MASKED || e.after === MASKED
|
|
395
|
+
? ''
|
|
396
|
+
: e.change === 'added'
|
|
397
|
+
? `+ ${e.after}`
|
|
398
|
+
: e.change === 'removed'
|
|
399
|
+
? `- ${e.before}`
|
|
400
|
+
: `${e.before} → ${e.after}`,
|
|
401
|
+
]),
|
|
402
|
+
),
|
|
403
|
+
),
|
|
374
404
|
)
|
|
375
|
-
const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
|
|
376
|
-
for (const e of diff.entries) {
|
|
377
|
-
// A secret-named env input shows its name and its change, no hash.
|
|
378
|
-
const beforeAfter =
|
|
379
|
-
e.before === MASKED || e.after === MASKED
|
|
380
|
-
? ''
|
|
381
|
-
: e.change === 'added'
|
|
382
|
-
? `+ ${e.after}`
|
|
383
|
-
: e.change === 'removed'
|
|
384
|
-
? `- ${e.before}`
|
|
385
|
-
: `${e.before} → ${e.after}`
|
|
386
|
-
lines.push(
|
|
387
|
-
` ${paint(CHANGE_HUE[e.change] ?? WARN, e.change.padEnd(7), c)} ${dim(e.kind.padEnd(kindW))} ${printable(e.name)} ${dim(beforeAfter)}`.trimEnd(),
|
|
388
|
-
)
|
|
389
|
-
}
|
|
390
405
|
if (roots.length > 0) {
|
|
391
|
-
lines.
|
|
392
|
-
for (const r of roots.slice(0, ROOTS_SHOWN)) {
|
|
406
|
+
const lines: ui.Inline[] = roots.slice(0, ROOTS_SHOWN).map((r) => {
|
|
393
407
|
const what = r.entries.map((e) =>
|
|
394
408
|
e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
|
|
395
409
|
)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
410
|
+
return [
|
|
411
|
+
...r.chain.flatMap((id, i) => [...(i > 0 ? [ui.dim(' \u2190 ')] : []), ui.id(id)]),
|
|
412
|
+
ui.dim(' \u2190 '),
|
|
413
|
+
ui.strong(
|
|
414
|
+
`${what.slice(0, 3).join(', ')}${what.length > 3 ? ` and ${what.length - 3} more` : ''}`,
|
|
415
|
+
),
|
|
416
|
+
]
|
|
417
|
+
})
|
|
418
|
+
if (roots.length > ROOTS_SHOWN) lines.push(`and ${roots.length - ROOTS_SHOWN} more`)
|
|
419
|
+
blocks.push(ui.section('Root cause', ui.text(...lines)))
|
|
401
420
|
}
|
|
402
421
|
if (writers.length > 0) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
422
|
+
blocks.push(
|
|
423
|
+
ui.section(
|
|
424
|
+
'Changed during a run',
|
|
425
|
+
ui.table(
|
|
426
|
+
['file', 'written while'],
|
|
427
|
+
writers.map((w) => [ui.path(printable(w.file)), w.writers.join(', ')]),
|
|
428
|
+
),
|
|
429
|
+
),
|
|
430
|
+
)
|
|
407
431
|
}
|
|
408
432
|
const rootKinds = roots.flatMap((r) => r.entries.map((e) => e.kind))
|
|
409
433
|
// An upstream row names the task to ask next: the command, not a
|
|
@@ -418,31 +442,26 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
418
442
|
k in WHAT_TO_DO && !(k === 'upstream' && (roots.length > 0 || upstream.length === 0)),
|
|
419
443
|
)
|
|
420
444
|
if (kinds.length > 0) {
|
|
421
|
-
|
|
422
|
-
// Its own column: a root cause's kind and the `written` row are
|
|
423
|
-
// not in the table above, and overflowed its width.
|
|
424
|
-
const todoW = Math.max(kindW, ...kinds.map((k) => k.length), writers.length > 0 ? 7 : 0)
|
|
445
|
+
const todo: ui.FieldRow[] = []
|
|
425
446
|
for (const k of kinds) {
|
|
426
447
|
if (k === 'upstream') {
|
|
427
|
-
|
|
448
|
+
todo.push([k, ...upstream])
|
|
428
449
|
continue
|
|
429
450
|
}
|
|
430
|
-
|
|
451
|
+
todo.push([
|
|
452
|
+
k,
|
|
431
453
|
k === 'config'
|
|
432
454
|
? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
|
|
433
|
-
: WHAT_TO_DO[k]
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
if (writers.length > 0) {
|
|
437
|
-
// A task re-writing a file a key reads moves that key every run:
|
|
438
|
-
// the miss is right, and only moving the write ends it.
|
|
439
|
-
lines.push(` ${dim('written'.padEnd(todoW))} ${WRITTEN_TODO}`)
|
|
455
|
+
: WHAT_TO_DO[k]!,
|
|
456
|
+
])
|
|
440
457
|
}
|
|
458
|
+
// A task re-writing a file a key reads moves that key every run:
|
|
459
|
+
// the miss is right, and only moving the write ends it.
|
|
460
|
+
if (writers.length > 0) todo.push(['written', WRITTEN_TODO])
|
|
461
|
+
blocks.push(ui.section('What to do', ui.fields(todo)))
|
|
441
462
|
}
|
|
442
|
-
} else if (why.hashChanged === true) {
|
|
443
|
-
lines.push(` ${dim('detail')} ${diff.note}`)
|
|
444
463
|
}
|
|
445
|
-
|
|
464
|
+
ui.print('out', ...blocks)
|
|
446
465
|
return 0
|
|
447
466
|
} finally {
|
|
448
467
|
cache.close()
|
|
@@ -451,7 +470,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
451
470
|
|
|
452
471
|
const ROOTS_SHOWN = 5
|
|
453
472
|
|
|
454
|
-
const CHANGE_HUE: Record<string,
|
|
473
|
+
const CHANGE_HUE: Record<string, Style> = { added: 'ok', removed: 'error', changed: 'warn' }
|
|
455
474
|
|
|
456
475
|
type DiffEntry = ReturnType<typeof cacheKeyDiff>['entries'][number]
|
|
457
476
|
|
|
@@ -489,6 +508,21 @@ function upstreamTodo(upstream: readonly DiffEntry[], unrecorded: readonly DiffE
|
|
|
489
508
|
return out
|
|
490
509
|
}
|
|
491
510
|
|
|
511
|
+
/** Whether a project declares `target` (`pkg#task`, or a bare task); true when no config loads. */
|
|
512
|
+
async function declares(root: string, target: string): Promise<boolean> {
|
|
513
|
+
try {
|
|
514
|
+
const projects = await loadCliProjects(root, await discoverCliProjects(root), 'all')
|
|
515
|
+
const [project, task] = target.includes('#') ? splitTaskId(target) : [undefined, target]
|
|
516
|
+
return [...projects.values()].some(
|
|
517
|
+
(p) =>
|
|
518
|
+
(project === undefined || p.name === project) && Object.hasOwn(p.config.tasks ?? {}, task),
|
|
519
|
+
)
|
|
520
|
+
} catch {
|
|
521
|
+
// quiet: with no lock or a broken config, the run says why itself
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
492
526
|
/**
|
|
493
527
|
* Each group in `taskId`'s graph with the tasks it gathers. Empty when a
|
|
494
528
|
* config no longer loads: the advice then names the group to `vx show`.
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import type { WorkspaceConfig } from '../config.js'
|
|
10
|
-
import { UserError
|
|
10
|
+
import { UserError } from '../util/index.js'
|
|
11
|
+
import { sayLine } from '../ui/index.js'
|
|
11
12
|
import {
|
|
12
13
|
discoverProjects,
|
|
13
14
|
loadProjects,
|
package/src/exec/executor.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import type { SandboxConfig } from '../config.js'
|
|
17
17
|
import type { CaptureConfig, RunResult } from './runner.js'
|
|
18
18
|
import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
|
|
19
|
-
import { thrownMessage, UserError
|
|
19
|
+
import { thrownMessage, UserError } from '../util/index.js'
|
|
20
|
+
import * as ui from '../ui/index.js'
|
|
20
21
|
|
|
21
22
|
/** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
|
|
22
23
|
export interface ExecuteSandbox {
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -98,6 +98,9 @@ let guardProc: Child | undefined
|
|
|
98
98
|
*/
|
|
99
99
|
const GUARD_STALL_MS = 1_000
|
|
100
100
|
|
|
101
|
+
/** Bytes per write to the guard: under a socket's buffer on every platform. */
|
|
102
|
+
const GUARD_PIECE = 1024
|
|
103
|
+
|
|
101
104
|
function startGuard(): void {
|
|
102
105
|
if (guardFd !== undefined) return
|
|
103
106
|
guardFd = null
|
|
@@ -114,6 +117,8 @@ function startGuard(): void {
|
|
|
114
117
|
const fd = guard.stdio[3] as number
|
|
115
118
|
guardFd = fd
|
|
116
119
|
guardProc = guard
|
|
120
|
+
// A clean exit strikes what it released: the EOF kills what is listed.
|
|
121
|
+
process.once('exit', flushGuard)
|
|
117
122
|
// A guard that has died (killed, the OOM killer) is handed to no later
|
|
118
123
|
// spawn: its pipe is broken, and a shell whose printf buffers (bash as
|
|
119
124
|
// macOS's sh) flushed the failed `+<pgid>` line into the task's own
|
|
@@ -134,6 +139,7 @@ function startGuard(): void {
|
|
|
134
139
|
function stopGuard(): void {
|
|
135
140
|
const fd = guardFd
|
|
136
141
|
guardFd = null
|
|
142
|
+
pending = ''
|
|
137
143
|
guardProc?.kill('SIGKILL')
|
|
138
144
|
if (typeof fd !== 'number') return
|
|
139
145
|
try {
|
|
@@ -145,11 +151,18 @@ function stopGuard(): void {
|
|
|
145
151
|
|
|
146
152
|
function guardWrite(line: string): void {
|
|
147
153
|
if (typeof guardFd !== 'number') return
|
|
148
|
-
|
|
154
|
+
let deadline = Date.now() + GUARD_STALL_MS
|
|
155
|
+
// A turn's batch can be more than the queue takes at once: it goes in
|
|
156
|
+
// pieces as the guard drains it, and only a guard that takes nothing for
|
|
157
|
+
// the whole stall is gone. With one deadline for the batch, 2000 strikes
|
|
158
|
+
// outlasted it on macOS and the guard was stopped under a live run.
|
|
159
|
+
let bytes = Buffer.from(line)
|
|
149
160
|
for (;;) {
|
|
150
161
|
try {
|
|
151
|
-
writeSync(guardFd,
|
|
152
|
-
return
|
|
162
|
+
const n = writeSync(guardFd, bytes, 0, Math.min(bytes.length, GUARD_PIECE))
|
|
163
|
+
if (n === bytes.length) return
|
|
164
|
+
bytes = bytes.subarray(n)
|
|
165
|
+
deadline = Date.now() + GUARD_STALL_MS
|
|
153
166
|
} catch (err) {
|
|
154
167
|
if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
|
|
155
168
|
Bun.sleepSync(1)
|
|
@@ -158,6 +171,43 @@ function guardWrite(line: string): void {
|
|
|
158
171
|
stopGuard()
|
|
159
172
|
}
|
|
160
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Lines waiting for this turn's one write: strikes, and the listings of
|
|
176
|
+
* spare shells. Each line was a write of its own and a wake of the
|
|
177
|
+
* guard's shell: on a cold run of 500 tasks, 38 ms of vx's main thread
|
|
178
|
+
* for the strikes and 140 ms of the guard's CPU (2026-11-03). If vx dies
|
|
179
|
+
* in the turn a task ended, what that task left in the background dies
|
|
180
|
+
* with it, as when vx dies a moment before the task ends.
|
|
181
|
+
*/
|
|
182
|
+
let pending = ''
|
|
183
|
+
let pendingTimer: ReturnType<typeof setImmediate> | undefined
|
|
184
|
+
|
|
185
|
+
function queueLine(line: string): void {
|
|
186
|
+
pending += line
|
|
187
|
+
pendingTimer ??= setImmediate(flushGuard)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Write the lines this turn queued now: before a listed child is handed its work. */
|
|
191
|
+
export function flushGuard(): void {
|
|
192
|
+
if (pendingTimer !== undefined) clearImmediate(pendingTimer)
|
|
193
|
+
pendingTimer = undefined
|
|
194
|
+
const lines = pending
|
|
195
|
+
pending = ''
|
|
196
|
+
if (lines !== '') guardWrite(lines)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* List `pid`'s group from vx's side, for a child that runs nothing until
|
|
201
|
+
* vx hands it work (a spare shell waiting on its script): the line goes
|
|
202
|
+
* with the turn's others, and `flushGuard` before the hand-over. A spare
|
|
203
|
+
* vx dies before listing reads the end of its script's pipe and exits,
|
|
204
|
+
* having run nothing. Starts the guard.
|
|
205
|
+
*/
|
|
206
|
+
export function listGroup(pid: number): void {
|
|
207
|
+
startGuard()
|
|
208
|
+
if (typeof guardFd === 'number' && pid > 0) queueLine(`+${pid}\n`)
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
/**
|
|
162
212
|
* Spawn a task child whose group the guard kills if vx dies holding it.
|
|
163
213
|
* The CHILD lists its own group, before it runs anything: vx wrote the
|
|
@@ -170,8 +220,11 @@ function guardWrite(line: string): void {
|
|
|
170
220
|
* races it. The guard starts BEFORE the spawn, so there is a pipe to hand.
|
|
171
221
|
* `undefined` when the guard could not start: nothing lists anything.
|
|
172
222
|
*/
|
|
173
|
-
export function spawnGuarded(spawn: (guard: number | undefined) =>
|
|
223
|
+
export function spawnGuarded<C = Child>(spawn: (guard: number | undefined) => C): C {
|
|
174
224
|
startGuard()
|
|
225
|
+
// The turn's lines go first, as they did one write each: a child's own
|
|
226
|
+
// listing behind them, not raced by a batch vx writes once it runs.
|
|
227
|
+
flushGuard()
|
|
175
228
|
if (typeof guardFd !== 'number') return spawn(undefined)
|
|
176
229
|
handingTo = guardProc?.pid
|
|
177
230
|
try {
|
|
@@ -223,7 +276,7 @@ export function releaseGroup(child: Child): void {
|
|
|
223
276
|
if (!(child.pid > 0)) return
|
|
224
277
|
const hold = holds.get(child.pid)
|
|
225
278
|
if (hold !== undefined) hold.released = true
|
|
226
|
-
else
|
|
279
|
+
else queueLine(`-${child.pid}\n`)
|
|
227
280
|
}
|
|
228
281
|
|
|
229
282
|
/**
|
|
@@ -44,6 +44,7 @@ export function localExecutor(): TaskExecutor {
|
|
|
44
44
|
...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
|
|
45
45
|
...(req.onSpawn !== undefined ? { onSpawn: req.onSpawn } : {}),
|
|
46
46
|
...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
|
|
47
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
47
48
|
}
|
|
48
49
|
if (req.sandbox === undefined) {
|
|
49
50
|
const res = await runCommand(req.terminal === true ? { ...common, terminal: true } : common)
|
|
@@ -56,7 +57,6 @@ export function localExecutor(): TaskExecutor {
|
|
|
56
57
|
reportWithin: req.sandbox.reportWithin,
|
|
57
58
|
reportLinked: req.sandbox.reportLinked,
|
|
58
59
|
config: req.sandbox.config,
|
|
59
|
-
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
60
60
|
})
|
|
61
61
|
},
|
|
62
62
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// A task's stdout, stderr and held stdin as pipes vx makes itself (Linux),
|
|
2
|
+
// shared by the runner, the spare shells and the sandbox's spawn.
|
|
3
|
+
|
|
4
|
+
import type { Pointer } from 'bun:ffi'
|
|
5
|
+
import { closeSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const O_NONBLOCK = 0o4000
|
|
8
|
+
const O_CLOEXEC = 0o2000000
|
|
9
|
+
const F_SETFL = 4
|
|
10
|
+
const F_SETPIPE_SZ = 1031
|
|
11
|
+
/**
|
|
12
|
+
* A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
|
|
13
|
+
* through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
|
|
14
|
+
* at 256 KiB it drained in 230. Past the user's pipe quota the kernel
|
|
15
|
+
* refuses the resize and the pipe keeps its 64 KiB.
|
|
16
|
+
*/
|
|
17
|
+
const PIPE_BYTES = 256 * 1024
|
|
18
|
+
|
|
19
|
+
type PipeCalls = {
|
|
20
|
+
pipe2: (fds: Pointer, flags: number) => number
|
|
21
|
+
fcntl: (fd: number, cmd: number, arg: number) => number
|
|
22
|
+
ptr: (view: Int32Array, byteOffset?: number) => Pointer
|
|
23
|
+
}
|
|
24
|
+
let pipeCalls: PipeCalls | null | undefined
|
|
25
|
+
|
|
26
|
+
/** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
|
|
27
|
+
function linuxPipeCalls(): PipeCalls | null {
|
|
28
|
+
if (pipeCalls !== undefined) return pipeCalls
|
|
29
|
+
pipeCalls = null
|
|
30
|
+
if (process.platform !== 'linux') return null
|
|
31
|
+
try {
|
|
32
|
+
// Loaded at the first spawn, not with the module: a static import cost
|
|
33
|
+
// every vx start 0.3 ms, a warm run that spawns nothing included.
|
|
34
|
+
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
|
|
35
|
+
// glibc's soname; musl's loader answers any `libc.` name with itself
|
|
36
|
+
// (ldso/dynlink.c's reserved names).
|
|
37
|
+
const { symbols } = dlopen('libc.so.6', {
|
|
38
|
+
pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
39
|
+
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
40
|
+
})
|
|
41
|
+
pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
|
|
42
|
+
} catch {
|
|
43
|
+
// Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
|
|
44
|
+
}
|
|
45
|
+
return pipeCalls
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Two pipes, `[out read, out write, err read, err write]`, or undefined
|
|
50
|
+
* when libc is not bound or a descriptor is not to be had (Bun's own
|
|
51
|
+
* socketpair then fails the spawn with the reason).
|
|
52
|
+
*/
|
|
53
|
+
function realPipes(): Int32Array | undefined {
|
|
54
|
+
const calls = linuxPipeCalls()
|
|
55
|
+
if (calls === null) return undefined
|
|
56
|
+
const fds = new Int32Array(4)
|
|
57
|
+
// Bun's spawn closes every descriptor it is not handed (probed, Bun
|
|
58
|
+
// 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
|
|
59
|
+
if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
|
|
60
|
+
if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
|
|
61
|
+
closeSync(fds[0]!)
|
|
62
|
+
closeSync(fds[1]!)
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
// vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
|
|
66
|
+
calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
|
|
67
|
+
calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
|
|
68
|
+
calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
69
|
+
calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
|
|
70
|
+
return fds
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
|
|
74
|
+
export function heldPipe(): Int32Array | undefined {
|
|
75
|
+
const calls = linuxPipeCalls()
|
|
76
|
+
if (calls === null) return undefined
|
|
77
|
+
const fds = new Int32Array(2)
|
|
78
|
+
return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
|
|
83
|
+
* opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
|
|
84
|
+
* (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
|
|
85
|
+
* every task, where a terminal or Turbo hands real pipes (X-113). So on
|
|
86
|
+
* Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
|
|
87
|
+
* the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
|
|
88
|
+
* which a socket allows, so Bun's pipe stays there.
|
|
89
|
+
*
|
|
90
|
+
* `held`: stdin is a pipe vx keeps open and never writes (a persistent
|
|
91
|
+
* task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
|
|
92
|
+
* socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
|
|
93
|
+
*/
|
|
94
|
+
export class TaskPipes {
|
|
95
|
+
/** Descriptor 0 for `Bun.spawn`'s `stdio`. */
|
|
96
|
+
readonly stdin: number | 'pipe' | 'ignore'
|
|
97
|
+
/** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
|
|
98
|
+
readonly stdio: readonly [number | 'pipe', number | 'pipe']
|
|
99
|
+
readonly #read: readonly number[]
|
|
100
|
+
readonly #stdinWrite: number | undefined
|
|
101
|
+
|
|
102
|
+
constructor(terminal?: boolean, held?: boolean) {
|
|
103
|
+
const fds = terminal === true ? undefined : realPipes()
|
|
104
|
+
this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
|
|
105
|
+
this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
|
|
106
|
+
const inFds = held === true && terminal !== true ? heldPipe() : undefined
|
|
107
|
+
this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
|
|
108
|
+
this.#stdinWrite = inFds?.[1]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Close vx's copies of the child's ends once the spawn has returned or
|
|
113
|
+
* thrown: the child holds its own, and a write end left here is a writer
|
|
114
|
+
* the reader's EOF would wait for.
|
|
115
|
+
*/
|
|
116
|
+
spawned(): void {
|
|
117
|
+
for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The child's stdout and stderr. */
|
|
121
|
+
streams(
|
|
122
|
+
child: ReturnType<typeof Bun.spawn>,
|
|
123
|
+
): [
|
|
124
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
125
|
+
ReadableStream<Uint8Array> | number | undefined,
|
|
126
|
+
] {
|
|
127
|
+
const [out, err] = this.#read
|
|
128
|
+
return out === undefined
|
|
129
|
+
? [child.stdout, child.stderr]
|
|
130
|
+
: [Bun.file(out).stream(), Bun.file(err!).stream()]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Close the read ends, once both streams have ended or been cancelled.
|
|
135
|
+
* Bun reads a dup of each, but takes it on the stream's first pull: a
|
|
136
|
+
* number closed before then was reused by the next spawn's pipe, and
|
|
137
|
+
* the stream read THAT (probed, Bun 1.4.2).
|
|
138
|
+
*/
|
|
139
|
+
close(): void {
|
|
140
|
+
for (const fd of this.#read) closeSync(fd)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Close the held stdin's write end, once the child has exited and its
|
|
145
|
+
* output has ended. Not with the output alone: a server that sends it
|
|
146
|
+
* to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
|
|
147
|
+
* and exited.
|
|
148
|
+
*/
|
|
149
|
+
closeStdin(): void {
|
|
150
|
+
if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
|
|
151
|
+
}
|
|
152
|
+
}
|