@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
package/src/cli/watch.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
} from './watch-filter.js'
|
|
46
46
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
47
47
|
import { ChangeJudge } from './watch-judge.js'
|
|
48
|
-
import { restartTimings } from '../util/index.js'
|
|
48
|
+
import { hangupIgnored, restartTimings } from '../util/index.js'
|
|
49
49
|
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
50
50
|
|
|
51
51
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
@@ -103,7 +103,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const cwd = process.cwd()
|
|
106
|
-
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks)
|
|
106
|
+
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
|
|
107
107
|
if ('error' in resolved) {
|
|
108
108
|
process.stderr.write(`vx watch: ${resolved.error}\n`)
|
|
109
109
|
return 1
|
|
@@ -146,8 +146,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
146
146
|
}
|
|
147
147
|
process.once('SIGTERM', () => stop.abort('SIGTERM'))
|
|
148
148
|
// A task runs in its own session (exec/kill-tree.ts): the terminal
|
|
149
|
-
// closing reaches the loop alone, and the loop passes it on
|
|
150
|
-
|
|
149
|
+
// closing reaches the loop alone, and the loop passes it on — unless vx
|
|
150
|
+
// was started with it ignored (nohup).
|
|
151
|
+
if (!hangupIgnored()) process.once('SIGHUP', () => stop.abort('SIGHUP'))
|
|
151
152
|
|
|
152
153
|
// Enumerate projects-in-scope so we know what dirs to watch: the bare
|
|
153
154
|
// tasks' scope (`opts.projects`; undefined means "every project", or
|
|
@@ -406,6 +407,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
406
407
|
try {
|
|
407
408
|
await held?.stop()
|
|
408
409
|
held = undefined
|
|
410
|
+
// A Ctrl-C while the old server shut down ran a cycle anyway: a
|
|
411
|
+
// `not run` row and a footer printed above `stopped` (WD-22).
|
|
412
|
+
if (stop.aborted) break
|
|
409
413
|
restartTimings()
|
|
410
414
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
411
415
|
// run made within a tick of it carried an earlier mtime and read as
|
|
@@ -415,10 +419,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
415
419
|
held = cycle.persistent
|
|
416
420
|
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
417
421
|
changes.lastCycle = { start, end: Date.now() }
|
|
418
|
-
if (reread && !stop.aborted) {
|
|
419
|
-
reread = false
|
|
420
|
-
await rearm()
|
|
421
|
-
}
|
|
422
422
|
} catch (err) {
|
|
423
423
|
// A re-run can fail catastrophically when the workspace
|
|
424
424
|
// itself moved out from under us — e.g. the user deleted
|
|
@@ -430,6 +430,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
430
430
|
const message = err instanceof Error ? err.message : String(err)
|
|
431
431
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
432
432
|
}
|
|
433
|
+
// After a failed cycle too: a package added with a config that does
|
|
434
|
+
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
435
|
+
// was never an event.
|
|
436
|
+
if (reread && !stop.aborted) {
|
|
437
|
+
reread = false
|
|
438
|
+
await rearm()
|
|
439
|
+
}
|
|
433
440
|
// What landed mid-run is judged on settled state, one window
|
|
434
441
|
// after the run, under the label of what actually arrived.
|
|
435
442
|
if (changes.pending.size === 0 || stop.aborted) break
|
|
@@ -471,8 +478,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
471
478
|
|
|
472
479
|
/** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
|
|
473
480
|
const armedAt = fsClockNow(cacheDir)
|
|
474
|
-
/** What existed at the arm, so a file born and gone since is no deletion (watch-judge.ts). */
|
|
475
|
-
const
|
|
481
|
+
/** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
|
|
482
|
+
const atArm = gitFiles(workspaceRoot)
|
|
476
483
|
/** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
|
|
477
484
|
const changes = new ChangeJudge({
|
|
478
485
|
workspaceRoot,
|
|
@@ -480,7 +487,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
480
487
|
held: () => held !== undefined,
|
|
481
488
|
uncached: () => uncached,
|
|
482
489
|
fenced: (ownDir, abs) => fenced(ownDir, abs),
|
|
483
|
-
...(
|
|
490
|
+
...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
|
|
484
491
|
})
|
|
485
492
|
/** Per-project arms by directory, so `rearm` can add and drop them. */
|
|
486
493
|
const perProject = new Map<string, WatchHandle>()
|
|
@@ -71,7 +71,9 @@ export function parseCacheDirFlag(
|
|
|
71
71
|
const v = a === '--cache-dir' ? args[i + 1] : a.slice('--cache-dir='.length)
|
|
72
72
|
if (v === undefined || v === '') return { error: '--cache-dir requires a path' }
|
|
73
73
|
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
74
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
|
|
76
|
+
}
|
|
75
77
|
}
|
|
76
78
|
return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
|
|
77
79
|
}
|
package/src/config.ts
CHANGED
|
@@ -292,10 +292,10 @@ export interface SandboxConfig {
|
|
|
292
292
|
|
|
293
293
|
/** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
|
|
294
294
|
export interface SandboxIgnore {
|
|
295
|
-
read?: string[]
|
|
296
|
-
write?: string[]
|
|
297
|
-
systemInfo?: string[]
|
|
298
|
-
network?: string[]
|
|
295
|
+
read?: readonly string[]
|
|
296
|
+
write?: readonly string[]
|
|
297
|
+
systemInfo?: readonly string[]
|
|
298
|
+
network?: readonly string[]
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
export interface SandboxGrants {
|
|
@@ -304,29 +304,29 @@ export interface SandboxGrants {
|
|
|
304
304
|
* `node_modules`, NOT `cache.inputs.files`. A task that reads its own
|
|
305
305
|
* source in the sandbox says so here.
|
|
306
306
|
*/
|
|
307
|
-
read?: string[]
|
|
307
|
+
read?: readonly string[]
|
|
308
308
|
/**
|
|
309
309
|
* Writable paths or globs. The task can write NOTHING without these:
|
|
310
310
|
* declaring `cache.outputs.files` grants no write, and a task whose
|
|
311
311
|
* only declaration is its outputs writes into a tmpfs that evaporates
|
|
312
312
|
* (vx then warns that `cache.outputs` matched no files).
|
|
313
313
|
*/
|
|
314
|
-
write?: string[]
|
|
314
|
+
write?: readonly string[]
|
|
315
315
|
/**
|
|
316
316
|
* Reachable domains, `['*.example.com']` a pattern. The proxy is the
|
|
317
317
|
* run's: it filters every sandboxed task against the union of the lists
|
|
318
318
|
* the run's tasks declare, so `true` adds no domain and reaches only
|
|
319
319
|
* those (none in a run with no list). Omitted adds none either.
|
|
320
320
|
*/
|
|
321
|
-
network?: true | string[]
|
|
321
|
+
network?: true | readonly string[]
|
|
322
322
|
/**
|
|
323
323
|
* Kernel information the task may query, by info type — the name that
|
|
324
324
|
* appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
|
|
325
325
|
* probes free space at startup).
|
|
326
326
|
*/
|
|
327
|
-
systemInfo?: string[]
|
|
327
|
+
systemInfo?: readonly string[]
|
|
328
328
|
/** Unix socket paths the task may connect to. `true` allows any. */
|
|
329
|
-
unixSockets?: true | string[]
|
|
329
|
+
unixSockets?: true | readonly string[]
|
|
330
330
|
/**
|
|
331
331
|
* Bind and reach localhost ports — a test that boots a server for
|
|
332
332
|
* itself. A port LIST additionally makes those ports reachable from
|
|
@@ -335,9 +335,9 @@ export interface SandboxGrants {
|
|
|
335
335
|
* namespace, each listed port is bridged out to the host's loopback;
|
|
336
336
|
* on macOS the host already sees them and the list means `true`.
|
|
337
337
|
*/
|
|
338
|
-
localBinding?: boolean | number[]
|
|
338
|
+
localBinding?: boolean | readonly number[]
|
|
339
339
|
/** macOS: Mach service names the task may look up. */
|
|
340
|
-
machLookup?: string[]
|
|
340
|
+
machLookup?: readonly string[]
|
|
341
341
|
/** Acquire a TTY. Rare outside interactive tools. */
|
|
342
342
|
pty?: boolean
|
|
343
343
|
/** Write to `.git/config`. Most build tools should not. */
|
|
@@ -346,7 +346,7 @@ export interface SandboxGrants {
|
|
|
346
346
|
|
|
347
347
|
export interface SandboxDenials {
|
|
348
348
|
/** Domains blocked even if `allow.network` would admit them. */
|
|
349
|
-
network?: string[]
|
|
349
|
+
network?: readonly string[]
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
export interface ExecConfig {
|
|
@@ -458,7 +458,7 @@ export interface ExecEnv {
|
|
|
458
458
|
* folded into the cache key — for things like CI flags, regional
|
|
459
459
|
* vars, secrets that change between machines without affecting output.
|
|
460
460
|
*/
|
|
461
|
-
passThrough?: string[]
|
|
461
|
+
passThrough?: readonly string[]
|
|
462
462
|
/**
|
|
463
463
|
* Explicit `name: value` pairs. The values are set on the child AND
|
|
464
464
|
* folded into the cache key (they are literal in your config, captured
|
|
@@ -471,7 +471,7 @@ export interface ExecEnv {
|
|
|
471
471
|
* hold `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL`
|
|
472
472
|
* are masked without it; this reaches `GH_PAT`, `NPM_AUTH`.
|
|
473
473
|
*/
|
|
474
|
-
secret?: string[]
|
|
474
|
+
secret?: readonly string[]
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
export interface CacheConfig {
|
|
@@ -488,7 +488,7 @@ export interface CacheOutputs {
|
|
|
488
488
|
* Outputs are not filtered through gitignore — typical artifact dirs
|
|
489
489
|
* like `dist/` and `coverage/` are captured normally even when ignored.
|
|
490
490
|
*/
|
|
491
|
-
files: string[]
|
|
491
|
+
files: readonly string[]
|
|
492
492
|
/**
|
|
493
493
|
* Files produced OUTSIDE the project dir, as workspace-root-relative
|
|
494
494
|
* globs (the Turbo `$TURBO_ROOT$` / Nx `{workspaceRoot}` escape
|
|
@@ -500,7 +500,7 @@ export interface CacheOutputs {
|
|
|
500
500
|
* like overlapping `files` (vx cleans declared outputs before a run
|
|
501
501
|
* and before a restore, so the second would delete the first's).
|
|
502
502
|
*/
|
|
503
|
-
workspaceFiles?: string[]
|
|
503
|
+
workspaceFiles?: readonly string[]
|
|
504
504
|
}
|
|
505
505
|
|
|
506
506
|
export interface CacheInputs {
|
|
@@ -514,7 +514,7 @@ export interface CacheInputs {
|
|
|
514
514
|
* files are excluded automatically — a task cannot invalidate itself,
|
|
515
515
|
* and cannot read across project boundaries.
|
|
516
516
|
*/
|
|
517
|
-
files: string[]
|
|
517
|
+
files: readonly string[]
|
|
518
518
|
/**
|
|
519
519
|
* Workspace-root-relative globs (the Turbo `$TURBO_ROOT$` / Nx
|
|
520
520
|
* `{workspaceRoot}` equivalent), for inputs that live outside the
|
|
@@ -528,7 +528,7 @@ export interface CacheInputs {
|
|
|
528
528
|
* Declared outputs (both `outputs` fields) are excluded
|
|
529
529
|
* automatically (a task cannot invalidate itself).
|
|
530
530
|
*/
|
|
531
|
-
workspaceFiles?: string[]
|
|
531
|
+
workspaceFiles?: readonly string[]
|
|
532
532
|
/**
|
|
533
533
|
* Env var names whose runtime values from parent `process.env` are
|
|
534
534
|
* folded into the cache key. **Independent of `exec.env`** — declaring
|
|
@@ -536,7 +536,7 @@ export interface CacheInputs {
|
|
|
536
536
|
* invalidation. To both forward AND track, list the name in both
|
|
537
537
|
* `exec.env.passThrough` and here.
|
|
538
538
|
*/
|
|
539
|
-
env?: string[]
|
|
539
|
+
env?: readonly string[]
|
|
540
540
|
/**
|
|
541
541
|
* Which upstream tasks' cache keys participate in this task's key.
|
|
542
542
|
* Uses the same Turbo/Nx micro-syntax as `dependsOn`, plus
|
|
@@ -565,7 +565,7 @@ export interface CacheInputs {
|
|
|
565
565
|
* lock, the OUTPUT is resolved live every run — correct under
|
|
566
566
|
* `--frozen`. Use for project-specific runtime probes.
|
|
567
567
|
*/
|
|
568
|
-
runtime?: string[]
|
|
568
|
+
runtime?: readonly string[]
|
|
569
569
|
/**
|
|
570
570
|
* Like `runtime`, but commands run at the WORKSPACE ROOT and are
|
|
571
571
|
* deduped GLOBALLY per command across the whole run — a `node -v`
|
|
@@ -573,7 +573,7 @@ export interface CacheInputs {
|
|
|
573
573
|
* analog of `workspaceFiles`: per-task, root-anchored. Use for global
|
|
574
574
|
* tool versions, OS info, etc.
|
|
575
575
|
*/
|
|
576
|
-
workspaceRuntime?: string[]
|
|
576
|
+
workspaceRuntime?: readonly string[]
|
|
577
577
|
}
|
|
578
578
|
|
|
579
579
|
/**
|
|
@@ -608,6 +608,25 @@ type Known<T, Shape> = { [P in keyof T]: P extends keyof Shape ? unknown : never
|
|
|
608
608
|
/** `T[P]` when `T` has it; `unknown` (no keys to check) when it does not. */
|
|
609
609
|
type At<T, P extends PropertyKey> = T extends { readonly [Q in P]?: infer V } ? V : unknown
|
|
610
610
|
|
|
611
|
+
/** The objects inside each task's `exec`, held to their keys as `exec` is (D-156). */
|
|
612
|
+
type KnownExecs<T extends ProjectConfig> = {
|
|
613
|
+
tasks?: {
|
|
614
|
+
[K in keyof NonNullable<T['tasks']>]?: {
|
|
615
|
+
exec?: KnownExec<At<NonNullable<T['tasks']>[K], 'exec'>>
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
type KnownExec<E> = {
|
|
621
|
+
env?: Known<At<E, 'env'>, ExecEnv>
|
|
622
|
+
persistent?: Known<At<E, 'persistent'>, PersistentConfig>
|
|
623
|
+
sandbox?: Known<At<E, 'sandbox'>, SandboxConfig> & {
|
|
624
|
+
allow?: Known<At<At<E, 'sandbox'>, 'allow'>, SandboxGrants>
|
|
625
|
+
deny?: Known<At<At<E, 'sandbox'>, 'deny'>, SandboxDenials>
|
|
626
|
+
ignore?: Known<At<At<E, 'sandbox'>, 'ignore'>, SandboxIgnore>
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
611
630
|
/**
|
|
612
631
|
* Identity function — exists only so TypeScript narrows literal types
|
|
613
632
|
* and, crucially, **validates `dependsOn` against this project's own
|
|
@@ -617,6 +636,7 @@ type At<T, P extends PropertyKey> = T extends { readonly [Q in P]?: infer V } ?
|
|
|
617
636
|
*/
|
|
618
637
|
export function defineProject<const T extends ProjectConfig>(
|
|
619
638
|
config: T &
|
|
639
|
+
KnownExecs<T> &
|
|
620
640
|
Known<T, ProjectConfig> & {
|
|
621
641
|
tasks?: {
|
|
622
642
|
[K in keyof NonNullable<T['tasks']>]?: Known<NonNullable<T['tasks']>[K], TaskConfig> & {
|
|
@@ -640,6 +660,7 @@ export function defineWorkspace<T extends WorkspaceConfig>(
|
|
|
640
660
|
At<T, 'cacheRetention'>,
|
|
641
661
|
NonNullable<WorkspaceConfig['cacheRetention']>
|
|
642
662
|
>
|
|
663
|
+
rules?: Known<At<T, 'rules'>, WorkspaceRules>
|
|
643
664
|
},
|
|
644
665
|
): T {
|
|
645
666
|
return config
|
package/src/exec/executor.ts
CHANGED
|
@@ -145,7 +145,7 @@ export interface ExecuteRequest {
|
|
|
145
145
|
* `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
|
|
146
146
|
* work and returns: a child an executor spawned is not one core can
|
|
147
147
|
* reach (H-10, H-12). One already aborted is work not to start (B-55).
|
|
148
|
-
*
|
|
148
|
+
* Any exit after the timeout's abort, a 0 included, is a timeout. Core
|
|
149
149
|
* waits the kill grace (`VX_KILL_GRACE_MS`, 2 s) after the abort, then
|
|
150
150
|
* settles the attempt without `execute` and abandons it (H-14).
|
|
151
151
|
*/
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -64,17 +64,37 @@ export function closeSignalChannel(child: Child): void {
|
|
|
64
64
|
*/
|
|
65
65
|
let guardFd: number | null | undefined
|
|
66
66
|
|
|
67
|
+
// Three lists: groups (`+`/`-`), single pids (`=`/`_`) and paths to
|
|
68
|
+
// remove (`@`/`!`), killed and removed in that order at EOF.
|
|
67
69
|
const GUARD_SCRIPT = [
|
|
68
|
-
"g=' '",
|
|
70
|
+
"g=' ' k=' ' f=' '",
|
|
69
71
|
'while IFS= read -r l; do',
|
|
72
|
+
' p=${l#?}',
|
|
70
73
|
' case $l in',
|
|
71
|
-
' +*) g="$g$
|
|
72
|
-
' -*)
|
|
74
|
+
' +*) g="$g$p " ;;',
|
|
75
|
+
' -*) case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
|
|
76
|
+
' =*) k="$k$p " ;;',
|
|
77
|
+
' _*) case $k in *" $p "*) k="${k%% $p *} ${k#* $p }" ;; esac ;;',
|
|
78
|
+
' @*) f="$f$p " ;;',
|
|
79
|
+
' !*) case $f in *" $p "*) f="${f%%" $p "*} ${f#*" $p "}" ;; esac ;;',
|
|
73
80
|
' esac',
|
|
74
81
|
'done <&3',
|
|
75
82
|
'for p in $g; do kill -s KILL -- "-$p"; done 2>/dev/null',
|
|
83
|
+
'for p in $k; do kill -s KILL "$p"; done 2>/dev/null',
|
|
84
|
+
'set -f; for p in $f; do rm -rf -- "$p"; done 2>/dev/null',
|
|
76
85
|
].join('\n')
|
|
77
86
|
|
|
87
|
+
/** The guard's process, so a guard vx stops writing to is stopped too. */
|
|
88
|
+
let guardProc: Child | undefined
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How long a write waits on a guard that is behind. Bun opens the pipe
|
|
92
|
+
* nonblocking, and the kernel queues ~280 unread writes whatever their
|
|
93
|
+
* size: a burst of releases, or a guard the scheduler has not yet run,
|
|
94
|
+
* makes a write EAGAIN while the guard lives.
|
|
95
|
+
*/
|
|
96
|
+
const GUARD_STALL_MS = 1_000
|
|
97
|
+
|
|
78
98
|
function startGuard(): void {
|
|
79
99
|
if (guardFd !== undefined) return
|
|
80
100
|
guardFd = null
|
|
@@ -89,31 +109,49 @@ function startGuard(): void {
|
|
|
89
109
|
guard.unref()
|
|
90
110
|
const fd = guard.stdio[3] as number
|
|
91
111
|
guardFd = fd
|
|
112
|
+
guardProc = guard
|
|
92
113
|
// A guard that has died (killed, the OOM killer) is handed to no later
|
|
93
114
|
// spawn: its pipe is broken, and a shell whose printf buffers (bash as
|
|
94
115
|
// macOS's sh) flushed the failed `+<pgid>` line into the task's own
|
|
95
116
|
// stdout, cached replay included (B-10).
|
|
96
117
|
void guard.exited.then(() => {
|
|
97
|
-
if (guardFd
|
|
98
|
-
guardFd = null
|
|
99
|
-
try {
|
|
100
|
-
closeSync(fd)
|
|
101
|
-
} catch {
|
|
102
|
-
// already closed
|
|
103
|
-
}
|
|
118
|
+
if (guardFd === fd) stopGuard()
|
|
104
119
|
})
|
|
105
120
|
} catch {
|
|
106
121
|
// The limit as it was: nothing takes the groups down.
|
|
107
122
|
}
|
|
108
123
|
}
|
|
109
124
|
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Stop guarding for the rest of the process, and stop the guard with it:
|
|
127
|
+
* a guard that lived on would keep every group whose release it never got
|
|
128
|
+
* and SIGKILL them at vx's clean exit.
|
|
129
|
+
*/
|
|
130
|
+
function stopGuard(): void {
|
|
131
|
+
const fd = guardFd
|
|
132
|
+
guardFd = null
|
|
133
|
+
guardProc?.kill('SIGKILL')
|
|
134
|
+
if (typeof fd !== 'number') return
|
|
112
135
|
try {
|
|
113
|
-
|
|
136
|
+
closeSync(fd)
|
|
114
137
|
} catch {
|
|
115
|
-
|
|
138
|
+
// already closed
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function guardWrite(line: string): void {
|
|
143
|
+
if (typeof guardFd !== 'number') return
|
|
144
|
+
const deadline = Date.now() + GUARD_STALL_MS
|
|
145
|
+
for (;;) {
|
|
146
|
+
try {
|
|
147
|
+
writeSync(guardFd, line)
|
|
148
|
+
return
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
|
|
151
|
+
Bun.sleepSync(1)
|
|
152
|
+
}
|
|
116
153
|
}
|
|
154
|
+
stopGuard()
|
|
117
155
|
}
|
|
118
156
|
|
|
119
157
|
/**
|
|
@@ -157,6 +195,25 @@ export function releaseGroup(child: Child): void {
|
|
|
157
195
|
else guardWrite(`-${child.pid}\n`)
|
|
158
196
|
}
|
|
159
197
|
|
|
198
|
+
/**
|
|
199
|
+
* List what the guard takes down besides groups if vx dies: the sandbox
|
|
200
|
+
* runtime's session. The runtime spawns its bridge socat as a plain child
|
|
201
|
+
* in vx's OWN group, which the guard must never kill, and a `kill -9`
|
|
202
|
+
* leaves its sockets in the temp directory. So each pid is killed alone
|
|
203
|
+
* and each path removed, by a name only that session carries. The
|
|
204
|
+
* returned function strikes them once the session is down, so a pid the
|
|
205
|
+
* kernel reuses is never killed on its account. Starts the guard: a spawn
|
|
206
|
+
* follows. A path holding whitespace is not listed (the lists split on it).
|
|
207
|
+
*/
|
|
208
|
+
export function guardSession(pids: readonly number[], paths: readonly string[]): () => void {
|
|
209
|
+
startGuard()
|
|
210
|
+
const listed = paths.filter((p) => !/\s/.test(p))
|
|
211
|
+
guardWrite(pids.map((p) => `=${p}\n`).join('') + listed.map((p) => `@${p}\n`).join(''))
|
|
212
|
+
return () => {
|
|
213
|
+
guardWrite(pids.map((p) => `_${p}\n`).join('') + listed.map((p) => `!${p}\n`).join(''))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
160
217
|
/** Groups a teardown is still taking down: how many hold each, and whether its runner let it go. */
|
|
161
218
|
const holds = new Map<number, { count: number; released: boolean }>()
|
|
162
219
|
|
|
@@ -181,7 +238,7 @@ export function holdGroups(children: readonly Child[]): () => void {
|
|
|
181
238
|
if (hold === undefined) continue
|
|
182
239
|
if (--hold.count > 0) continue
|
|
183
240
|
holds.delete(pid)
|
|
184
|
-
if (hold.released) guardWrite(`-${pid}\n`)
|
|
241
|
+
if (owed.delete(pid) || hold.released) guardWrite(`-${pid}\n`)
|
|
185
242
|
}
|
|
186
243
|
}
|
|
187
244
|
}
|
|
@@ -197,9 +254,23 @@ export function holdGroups(children: readonly Child[]): () => void {
|
|
|
197
254
|
*/
|
|
198
255
|
const goneGroups = new WeakSet<Child>()
|
|
199
256
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
257
|
+
/** Server groups that outlived their leader: struck when a teardown next lets them go. */
|
|
258
|
+
const owed = new Set<number>()
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A persistent child's leader has exited. An empty group is struck and
|
|
262
|
+
* never signalled again. One with a member left stays listed until a
|
|
263
|
+
* teardown lets it go: the run's registry still owns a ready server after
|
|
264
|
+
* its shell exits and stops its group at the end, and struck here, the
|
|
265
|
+
* `server` of a `server & echo up` was left to nobody by a `kill -9` of vx.
|
|
266
|
+
*/
|
|
267
|
+
export function releaseServerGroup(child: Child): void {
|
|
268
|
+
if (groupAlive(child)) {
|
|
269
|
+
owed.add(child.pid)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
goneGroups.add(child)
|
|
273
|
+
releaseGroup(child)
|
|
203
274
|
}
|
|
204
275
|
|
|
205
276
|
export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void {
|
package/src/exec/proc-sample.ts
CHANGED
|
@@ -26,7 +26,7 @@ interface Proc {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Linux's `/proc/<pid>/stat` counts CPU in USER_HZ ticks, fixed at 100 by
|
|
29
|
-
* the ABI on every architecture; `tests/proc-sample.test.ts` pins it by
|
|
29
|
+
* the ABI on every architecture; `tests/proc-sample.unsafe.test.ts` pins it by
|
|
30
30
|
* burning a known span and reading it back.
|
|
31
31
|
*/
|
|
32
32
|
const TICK_MS = 10
|
package/src/exec/runner.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
closeSignalChannel,
|
|
19
19
|
holdGroups,
|
|
20
20
|
killTree,
|
|
21
|
-
markGroupIfGone,
|
|
22
21
|
releaseGroup,
|
|
22
|
+
releaseServerGroup,
|
|
23
23
|
signalThrough,
|
|
24
24
|
guardLine,
|
|
25
25
|
spawnGuarded,
|
|
@@ -105,6 +105,36 @@ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
|
|
|
105
105
|
: ([stdin, 'pipe', 'pipe'] as const)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read the terminal's modes before a task that holds it runs, and return
|
|
110
|
+
* what puts them back once it exits: a task killed in raw mode (or one
|
|
111
|
+
* that never undid its `stty raw -echo`) left the user's terminal with no
|
|
112
|
+
* echo and no Ctrl-C. Bun restores them at exit only when vx's stdout is
|
|
113
|
+
* the terminal, so `vx run … > log` kept them, and the rest of a run
|
|
114
|
+
* printed in them. `detached`, as the task is: a vx in a background job
|
|
115
|
+
* would be stopped by SIGTTOU on the set.
|
|
116
|
+
*/
|
|
117
|
+
function keepTerminalModes(): (() => void) | undefined {
|
|
118
|
+
const saved = stty('-g')
|
|
119
|
+
if (saved === undefined) return undefined
|
|
120
|
+
return () => {
|
|
121
|
+
if (stty('-g') !== saved) stty(saved)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stty(arg: string): string | undefined {
|
|
126
|
+
try {
|
|
127
|
+
const out = Bun.spawnSync(['stty', arg], {
|
|
128
|
+
stdio: ['inherit', 'pipe', 'ignore'],
|
|
129
|
+
detached: true,
|
|
130
|
+
})
|
|
131
|
+
return out.success ? out.stdout.toString().trim() : undefined
|
|
132
|
+
} catch {
|
|
133
|
+
// No stty: the modes stay the task's, as before.
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
108
138
|
export function shellQuote(arg: string): string {
|
|
109
139
|
if (arg === '') return `''`
|
|
110
140
|
// A `#` opens a comment only at a word's start, so `app#build` stays bare.
|
|
@@ -485,12 +515,14 @@ const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
|
485
515
|
|
|
486
516
|
/**
|
|
487
517
|
* Terminal escapes a `readyWhen` pattern is matched without: CSI (colour,
|
|
488
|
-
* cursor), OSC (titles, links)
|
|
518
|
+
* cursor), OSC (titles, links), charset picks (`tput sgr0` writes
|
|
519
|
+
* `\x1b(B\x1b[m`) and the two-byte forms. Vite under
|
|
489
520
|
* `FORCE_COLOR` prints `\x1b[1mLocal\x1b[22m:`, which `Local:` never matched
|
|
490
521
|
* (item 1059). The streamed bytes keep them; only the tested text drops them.
|
|
491
522
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
523
|
+
const TERMINAL_ESCAPE_RE =
|
|
524
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
525
|
+
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]+[0-~]|[@-Z\\-_])/g
|
|
494
526
|
|
|
495
527
|
/**
|
|
496
528
|
* Why a persistent task never became ready — the reason every label and
|
|
@@ -566,6 +598,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
566
598
|
const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
|
|
567
599
|
|
|
568
600
|
let child: ReturnType<typeof Bun.spawn>
|
|
601
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
569
602
|
try {
|
|
570
603
|
const signalFd = opts.signalChannel === true
|
|
571
604
|
child = spawnGuarded((guard) =>
|
|
@@ -597,6 +630,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
597
630
|
},
|
|
598
631
|
),
|
|
599
632
|
)
|
|
633
|
+
if (restoreModes !== undefined) void child.exited.then(restoreModes)
|
|
600
634
|
if (signalFd) {
|
|
601
635
|
signalThrough(child, child.stdio[3] as number)
|
|
602
636
|
const spawned = child
|
|
@@ -621,7 +655,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
621
655
|
})
|
|
622
656
|
|
|
623
657
|
const markReady = (): void => {
|
|
624
|
-
|
|
658
|
+
// A marker printed on the way down, once the wait gave up, is not ready.
|
|
659
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
625
660
|
readyAt = Date.now()
|
|
626
661
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
627
662
|
resolveReady()
|
|
@@ -709,40 +744,42 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
709
744
|
opts.liveChildren?.add(child)
|
|
710
745
|
opts.onSpawn?.(child.pid)
|
|
711
746
|
|
|
712
|
-
// Readiness deadline
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
747
|
+
// Readiness deadline: SIGTERM, wait the grace for the GROUP, SIGKILL
|
|
748
|
+
// what is left, and only then reject. The caller's teardown used to
|
|
749
|
+
// signal the group again a turn after this SIGTERM, and a server whose
|
|
750
|
+
// handler is a one-shot (`process.once('SIGTERM')`, a trap that resets
|
|
751
|
+
// itself) died on the second mid-cleanup. Cleared the moment ready
|
|
752
|
+
// fires so a healthy server is never killed by a stale timer.
|
|
716
753
|
let readyTimer: ReturnType<typeof setTimeout> | undefined
|
|
717
754
|
if (readyRe && opts.timeoutMs !== undefined) {
|
|
718
|
-
const giveUp = (): void => {
|
|
719
|
-
if (readyAt
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
)
|
|
728
|
-
// Listed on the group guard until the SIGKILL: the shell may die
|
|
729
|
-
// on the SIGTERM and let the group go while the server runs out
|
|
730
|
-
// the grace, and a vx that exits inside it (the timer is unref'd)
|
|
731
|
-
// leaves the server to the guard (kill-tree.ts, item 865).
|
|
732
|
-
const letGo = holdGroups([child])
|
|
755
|
+
const giveUp = async (): Promise<void> => {
|
|
756
|
+
if (readyAt !== undefined) return
|
|
757
|
+
gaveUpAt = Date.now()
|
|
758
|
+
// Listed on the group guard until the SIGKILL: the shell may die on
|
|
759
|
+
// the SIGTERM and let the group go while the server runs out the
|
|
760
|
+
// grace, and a vx killed inside it leaves the server to the guard
|
|
761
|
+
// (kill-tree.ts, item 865).
|
|
762
|
+
const letGo = holdGroups([child])
|
|
763
|
+
try {
|
|
733
764
|
killTree(child, 'SIGTERM')
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
killTimer.unref?.()
|
|
765
|
+
// A server that traps TERM and never became ready is not in the
|
|
766
|
+
// persistent registry, so nothing else would ever kill it.
|
|
767
|
+
const left = await untilGroupsGone([child], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
768
|
+
for (const c of left) killTree(c, 'SIGKILL')
|
|
769
|
+
await child.exited
|
|
770
|
+
} finally {
|
|
771
|
+
letGo()
|
|
742
772
|
}
|
|
773
|
+
rejectReady(
|
|
774
|
+
new PersistentReadyError(
|
|
775
|
+
`persistent task not ready within ${opts.timeoutMs}ms — ` +
|
|
776
|
+
`readyWhen pattern never matched; child killed`,
|
|
777
|
+
'timeout',
|
|
778
|
+
),
|
|
779
|
+
)
|
|
743
780
|
}
|
|
744
781
|
readyTimer = setTimeout(() => {
|
|
745
|
-
readyTimer = afterPendingExits(giveUp)
|
|
782
|
+
readyTimer = afterPendingExits(() => void giveUp())
|
|
746
783
|
}, opts.timeoutMs)
|
|
747
784
|
}
|
|
748
785
|
|
|
@@ -750,10 +787,9 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
750
787
|
// — reject the ready promise so the caller can surface it.
|
|
751
788
|
void child.exited.then((code) => {
|
|
752
789
|
opts.liveChildren?.delete(child)
|
|
753
|
-
|
|
754
|
-
releaseGroup(child)
|
|
790
|
+
releaseServerGroup(child)
|
|
755
791
|
if (readyTimer !== undefined) clearTimeout(readyTimer)
|
|
756
|
-
if (readyAt === undefined) {
|
|
792
|
+
if (readyAt === undefined && gaveUpAt === undefined) {
|
|
757
793
|
rejectReady(
|
|
758
794
|
new PersistentReadyError(
|
|
759
795
|
`persistent task exited before becoming ready (exit ${code ?? '?'})` +
|
|
@@ -798,6 +834,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
798
834
|
const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
|
|
799
835
|
|
|
800
836
|
let proc: ReturnType<typeof Bun.spawn>
|
|
837
|
+
const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
|
|
801
838
|
try {
|
|
802
839
|
proc = spawnGuarded((guard) =>
|
|
803
840
|
Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
|
|
@@ -832,6 +869,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
832
869
|
// lets a clean exit EOF immediately and only cuts off a stuck reader after a
|
|
833
870
|
// brief grace — without this the run hangs forever.
|
|
834
871
|
await proc.exited
|
|
872
|
+
restoreModes?.()
|
|
835
873
|
await timeout.settle()
|
|
836
874
|
let cut = false
|
|
837
875
|
if (timeout.timedOut()) ac.abort()
|