@vzn/vx 0.0.596 → 0.0.598
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- 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 +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
package/src/cli/upgrade.ts
CHANGED
|
@@ -200,7 +200,7 @@ export async function fetchRelease(tag: string | undefined): Promise<unknown> {
|
|
|
200
200
|
}
|
|
201
201
|
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
202
202
|
// page served with a 200) is the same refusal as a cut one.
|
|
203
|
-
return await readOrRefuse(() => res.json()
|
|
203
|
+
return await readOrRefuse(() => res.json(), url, 'read the release')
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// One watch cycle's run, stoppable on its own. A cycle whose dev server
|
|
2
|
+
// never prints its `readyWhen` line and has no `exec.timeout` never ended,
|
|
3
|
+
// so every later edit (the fix included) waited behind it for good (WD-15).
|
|
4
|
+
// While nothing but persistent tasks is in flight, an edit stops the cycle
|
|
5
|
+
// as Ctrl-C would and the loop starts the next one; a cycle running any
|
|
6
|
+
// other work is never stopped.
|
|
7
|
+
|
|
8
|
+
import { createEventBus, type RunOptions } from '../orchestrator/index.js'
|
|
9
|
+
|
|
10
|
+
export interface WatchCycle {
|
|
11
|
+
/** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
|
|
12
|
+
readonly opts: RunOptions
|
|
13
|
+
/** Every task in flight is a persistent one not yet ready. */
|
|
14
|
+
waitsOnReadiness(): boolean
|
|
15
|
+
/** Stop the run as a SIGTERM would; watch keeps going. */
|
|
16
|
+
interrupt(): void
|
|
17
|
+
readonly interrupted: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `onWaiting` hears each task event after which the cycle waits on readiness alone. */
|
|
21
|
+
export function watchCycle(opts: RunOptions, stop: AbortSignal, onWaiting: () => void): WatchCycle {
|
|
22
|
+
const own = new AbortController()
|
|
23
|
+
const bus = createEventBus()
|
|
24
|
+
// id → persistent; a group task has no process and holds nothing up.
|
|
25
|
+
const active = new Map<string, boolean>()
|
|
26
|
+
const waitsOnReadiness = (): boolean =>
|
|
27
|
+
active.size > 0 && [...active.values()].every((persistent) => persistent)
|
|
28
|
+
bus.subscribe((e) => {
|
|
29
|
+
if (e.kind === 'task:start' && e.node.config.exec !== undefined)
|
|
30
|
+
active.set(e.node.id, e.node.config.exec.persistent !== undefined)
|
|
31
|
+
else if (e.kind === 'task:complete') active.delete(e.node.id)
|
|
32
|
+
else return
|
|
33
|
+
if (waitsOnReadiness()) onWaiting()
|
|
34
|
+
})
|
|
35
|
+
return {
|
|
36
|
+
opts: { ...opts, bus, signal: AbortSignal.any([stop, own.signal]) },
|
|
37
|
+
waitsOnReadiness,
|
|
38
|
+
interrupt: () => own.abort('SIGTERM'),
|
|
39
|
+
get interrupted() {
|
|
40
|
+
return own.signal.aborted
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -349,7 +349,9 @@ export function modifiedBefore(abs: string, t: number): boolean {
|
|
|
349
349
|
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
350
350
|
// such an edit was "before the arm" and never ran (item 945). No
|
|
351
351
|
// process can set a ctime, and a rename or a write moves it.
|
|
352
|
-
|
|
352
|
+
// The link's own times, not its target's: a link made after the arm
|
|
353
|
+
// to an old file is a new input, and followed it read as the target's.
|
|
354
|
+
const st = fs.lstatSync(abs)
|
|
353
355
|
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
354
356
|
} catch {
|
|
355
357
|
return false
|
|
@@ -398,6 +400,10 @@ export class WatcherPool {
|
|
|
398
400
|
// network mount, a container bind — the attempt costs a denied syscall
|
|
399
401
|
// and a two-second wait before the fallback takes over anyway.
|
|
400
402
|
private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
403
|
+
// The stop closes the pool while a cycle may still be re-arming: an arm
|
|
404
|
+
// after it, or a proof that fails because it closed the watcher, would
|
|
405
|
+
// leave a live watcher or poller nothing ever closes.
|
|
406
|
+
private closed = false
|
|
401
407
|
|
|
402
408
|
constructor(private readonly skip: (dir: string, rel: string) => boolean) {
|
|
403
409
|
if (this.forcePoll)
|
|
@@ -411,6 +417,7 @@ export class WatcherPool {
|
|
|
411
417
|
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
412
418
|
// swapped for a poller in place, and a drop must close what is there.
|
|
413
419
|
arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
|
|
420
|
+
if (this.closed) return CLOSED
|
|
414
421
|
const watchers = this.watchers
|
|
415
422
|
const at = watchers.length
|
|
416
423
|
const handle: WatchHandle = {
|
|
@@ -442,8 +449,8 @@ export class WatcherPool {
|
|
|
442
449
|
armed.ready.then((ok) => {
|
|
443
450
|
if (ok) return
|
|
444
451
|
armed.watcher.close()
|
|
445
|
-
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
446
|
-
if (watchers[at] !== armed.watcher) return
|
|
452
|
+
// Dropped by a rearm or the stop before its proof settled: nothing to swap in.
|
|
453
|
+
if (this.closed || watchers[at] !== armed.watcher) return
|
|
447
454
|
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
448
455
|
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
449
456
|
// the poller rather than run a loop that silently never fires.
|
|
@@ -462,6 +469,7 @@ export class WatcherPool {
|
|
|
462
469
|
}
|
|
463
470
|
|
|
464
471
|
closeAll(): void {
|
|
472
|
+
this.closed = true
|
|
465
473
|
for (const w of this.watchers) {
|
|
466
474
|
try {
|
|
467
475
|
w.close()
|
package/src/cli/watch.ts
CHANGED
|
@@ -45,7 +45,8 @@ 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 {
|
|
48
|
+
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
49
|
+
import { hangupIgnored, maskedLine, restartTimings } from '../util/index.js'
|
|
49
50
|
import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
|
|
50
51
|
|
|
51
52
|
/** One line for a watcher or re-read the OS refused; the loop goes on without it. */
|
|
@@ -146,8 +147,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
146
147
|
}
|
|
147
148
|
process.once('SIGTERM', () => stop.abort('SIGTERM'))
|
|
148
149
|
// 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
|
-
|
|
150
|
+
// closing reaches the loop alone, and the loop passes it on — unless vx
|
|
151
|
+
// was started with it ignored (nohup).
|
|
152
|
+
if (!hangupIgnored()) process.once('SIGHUP', () => stop.abort('SIGHUP'))
|
|
151
153
|
|
|
152
154
|
// Enumerate projects-in-scope so we know what dirs to watch: the bare
|
|
153
155
|
// tasks' scope (`opts.projects`; undefined means "every project", or
|
|
@@ -375,11 +377,31 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
375
377
|
/** The cycle in flight, so the stop path can wait for its teardown before resolving. */
|
|
376
378
|
let inFlight: Promise<void> = Promise.resolve()
|
|
377
379
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
380
|
+
/** The run in flight, while one is. */
|
|
381
|
+
let current: WatchCycle | undefined
|
|
382
|
+
/** What stopped a cycle that waited on a server's readiness; the loop runs it next. */
|
|
383
|
+
let interruptedBy: string | undefined
|
|
384
|
+
let interruptTimer: ReturnType<typeof setTimeout> | null = null
|
|
385
|
+
// Judged on settled state one window later, as an idle edit is, and only
|
|
386
|
+
// if the cycle still waits on readiness alone by then.
|
|
387
|
+
const interruptIfWaiting = (): void => {
|
|
388
|
+
if (changes.pending.size === 0 || current?.waitsOnReadiness() !== true) return
|
|
389
|
+
if (interruptTimer) clearTimeout(interruptTimer)
|
|
390
|
+
interruptTimer = setTimeout(() => {
|
|
391
|
+
interruptTimer = null
|
|
392
|
+
const c = current
|
|
393
|
+
if (c === undefined || c.interrupted || !c.waitsOnReadiness() || stop.aborted) return
|
|
394
|
+
const label = changes.judge()
|
|
395
|
+
if (label === undefined) return
|
|
396
|
+
interruptedBy = label
|
|
397
|
+
c.interrupt()
|
|
398
|
+
}, DEBOUNCE_MS)
|
|
399
|
+
}
|
|
378
400
|
let windowOpened = 0
|
|
379
401
|
|
|
380
402
|
const trigger = (label: string, abs: string): void => {
|
|
381
403
|
if (!changes.pending.has(abs)) changes.pending.set(abs, label)
|
|
382
|
-
if (running) return
|
|
404
|
+
if (running) return interruptIfWaiting()
|
|
383
405
|
if (debounceTimer) {
|
|
384
406
|
if (Date.now() - windowOpened >= DEBOUNCE_MAX_MS) return
|
|
385
407
|
clearTimeout(debounceTimer)
|
|
@@ -406,19 +428,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
406
428
|
try {
|
|
407
429
|
await held?.stop()
|
|
408
430
|
held = undefined
|
|
431
|
+
// A Ctrl-C while the old server shut down ran a cycle anyway: a
|
|
432
|
+
// `not run` row and a footer printed above `stopped` (WD-22).
|
|
433
|
+
if (stop.aborted) break
|
|
409
434
|
restartTimings()
|
|
410
435
|
// On the mtime clock, as the arm is: from `Date.now()` a write the
|
|
411
436
|
// run made within a tick of it carried an earlier mtime and read as
|
|
412
437
|
// an edit (WD-20). The end needs no stamp: an mtime never leads it.
|
|
413
438
|
const start = fsClockNow(cacheDir)
|
|
414
|
-
|
|
439
|
+
current = watchCycle(opts, stop, interruptIfWaiting)
|
|
440
|
+
const cycle = await runOrchestrator(current.opts)
|
|
441
|
+
current = undefined
|
|
415
442
|
held = cycle.persistent
|
|
416
443
|
if (cycle.refused !== undefined) process.stderr.write(`vx watch: ${cycle.refused}\n`)
|
|
417
444
|
changes.lastCycle = { start, end: Date.now() }
|
|
418
|
-
if (reread && !stop.aborted) {
|
|
419
|
-
reread = false
|
|
420
|
-
await rearm()
|
|
421
|
-
}
|
|
422
445
|
} catch (err) {
|
|
423
446
|
// A re-run can fail catastrophically when the workspace
|
|
424
447
|
// itself moved out from under us — e.g. the user deleted
|
|
@@ -427,12 +450,27 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
427
450
|
// message but DON'T let it crash the watch loop; the next
|
|
428
451
|
// FS event (if any) will retry. The dispose() on SIGINT
|
|
429
452
|
// is the canonical exit; we don't unilaterally abort here.
|
|
430
|
-
|
|
453
|
+
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
454
|
+
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
431
455
|
process.stderr.write(`vx watch: cycle failed: ${message}\n`)
|
|
432
456
|
}
|
|
457
|
+
// After a failed cycle too: a package added with a config that does
|
|
458
|
+
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
459
|
+
// was never an event.
|
|
460
|
+
if (reread && !stop.aborted) {
|
|
461
|
+
reread = false
|
|
462
|
+
await rearm()
|
|
463
|
+
}
|
|
433
464
|
// What landed mid-run is judged on settled state, one window
|
|
434
465
|
// after the run, under the label of what actually arrived.
|
|
435
|
-
|
|
466
|
+
current = undefined
|
|
467
|
+
if (stop.aborted) break
|
|
468
|
+
if (interruptedBy !== undefined) {
|
|
469
|
+
label = interruptedBy
|
|
470
|
+
interruptedBy = undefined
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
if (changes.pending.size === 0) break
|
|
436
474
|
await Bun.sleep(DEBOUNCE_MS)
|
|
437
475
|
label = changes.judge()
|
|
438
476
|
}
|
|
@@ -849,6 +887,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
849
887
|
const cleanup = async (): Promise<void> => {
|
|
850
888
|
pool.closeAll()
|
|
851
889
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
890
|
+
if (interruptTimer) clearTimeout(interruptTimer)
|
|
852
891
|
// The aborted cycle is tearing its children down; resolve only once
|
|
853
892
|
// it has returned, so the process never exits over a live child.
|
|
854
893
|
await inFlight
|
|
@@ -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
|
@@ -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,40 @@ 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. A
|
|
69
|
+
// group is listed once however often its line arrives: a `-` strikes
|
|
70
|
+
// one entry, and a shell that keeps a failed printf buffered (bash as
|
|
71
|
+
// macOS's sh, B-10) sends the line again with its retry.
|
|
67
72
|
const GUARD_SCRIPT = [
|
|
68
|
-
"g=' '",
|
|
73
|
+
"g=' ' k=' ' f=' '",
|
|
69
74
|
'while IFS= read -r l; do',
|
|
75
|
+
' p=${l#?}',
|
|
70
76
|
' case $l in',
|
|
71
|
-
' +*) g="$g$
|
|
72
|
-
' -*)
|
|
77
|
+
' +*) case $g in *" $p "*) ;; *) g="$g$p " ;; esac ;;',
|
|
78
|
+
' -*) case $g in *" $p "*) g="${g%% $p *} ${g#* $p }" ;; esac ;;',
|
|
79
|
+
' =*) k="$k$p " ;;',
|
|
80
|
+
' _*) case $k in *" $p "*) k="${k%% $p *} ${k#* $p }" ;; esac ;;',
|
|
81
|
+
' @*) f="$f$p " ;;',
|
|
82
|
+
' !*) case $f in *" $p "*) f="${f%%" $p "*} ${f#*" $p "}" ;; esac ;;',
|
|
73
83
|
' esac',
|
|
74
84
|
'done <&3',
|
|
75
85
|
'for p in $g; do kill -s KILL -- "-$p"; done 2>/dev/null',
|
|
86
|
+
'for p in $k; do kill -s KILL "$p"; done 2>/dev/null',
|
|
87
|
+
'set -f; for p in $f; do rm -rf -- "$p"; done 2>/dev/null',
|
|
76
88
|
].join('\n')
|
|
77
89
|
|
|
90
|
+
/** The guard's process, so a guard vx stops writing to is stopped too. */
|
|
91
|
+
let guardProc: Child | undefined
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How long a write waits on a guard that is behind. Bun opens the pipe
|
|
95
|
+
* nonblocking, and the kernel queues ~280 unread writes whatever their
|
|
96
|
+
* size: a burst of releases, or a guard the scheduler has not yet run,
|
|
97
|
+
* makes a write EAGAIN while the guard lives.
|
|
98
|
+
*/
|
|
99
|
+
const GUARD_STALL_MS = 1_000
|
|
100
|
+
|
|
78
101
|
function startGuard(): void {
|
|
79
102
|
if (guardFd !== undefined) return
|
|
80
103
|
guardFd = null
|
|
@@ -89,33 +112,51 @@ function startGuard(): void {
|
|
|
89
112
|
guard.unref()
|
|
90
113
|
const fd = guard.stdio[3] as number
|
|
91
114
|
guardFd = fd
|
|
115
|
+
guardProc = guard
|
|
92
116
|
// A guard that has died (killed, the OOM killer) is handed to no later
|
|
93
117
|
// spawn: its pipe is broken, and a shell whose printf buffers (bash as
|
|
94
118
|
// macOS's sh) flushed the failed `+<pgid>` line into the task's own
|
|
95
119
|
// stdout, cached replay included (B-10).
|
|
96
120
|
void guard.exited.then(() => {
|
|
97
|
-
if (guardFd
|
|
98
|
-
guardFd = null
|
|
99
|
-
try {
|
|
100
|
-
closeSync(fd)
|
|
101
|
-
} catch {
|
|
102
|
-
// already closed
|
|
103
|
-
}
|
|
121
|
+
if (guardFd === fd) stopGuard()
|
|
104
122
|
})
|
|
105
123
|
} catch {
|
|
106
124
|
// The limit as it was: nothing takes the groups down.
|
|
107
125
|
}
|
|
108
126
|
}
|
|
109
127
|
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Stop guarding for the rest of the process, and stop the guard with it:
|
|
130
|
+
* a guard that lived on would keep every group whose release it never got
|
|
131
|
+
* and SIGKILL them at vx's clean exit.
|
|
132
|
+
*/
|
|
133
|
+
function stopGuard(): void {
|
|
134
|
+
const fd = guardFd
|
|
135
|
+
guardFd = null
|
|
136
|
+
guardProc?.kill('SIGKILL')
|
|
137
|
+
if (typeof fd !== 'number') return
|
|
112
138
|
try {
|
|
113
|
-
|
|
139
|
+
closeSync(fd)
|
|
114
140
|
} catch {
|
|
115
|
-
|
|
141
|
+
// already closed
|
|
116
142
|
}
|
|
117
143
|
}
|
|
118
144
|
|
|
145
|
+
function guardWrite(line: string): void {
|
|
146
|
+
if (typeof guardFd !== 'number') return
|
|
147
|
+
const deadline = Date.now() + GUARD_STALL_MS
|
|
148
|
+
for (;;) {
|
|
149
|
+
try {
|
|
150
|
+
writeSync(guardFd, line)
|
|
151
|
+
return
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if ((err as NodeJS.ErrnoException).code !== 'EAGAIN' || Date.now() >= deadline) break
|
|
154
|
+
Bun.sleepSync(1)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
stopGuard()
|
|
158
|
+
}
|
|
159
|
+
|
|
119
160
|
/**
|
|
120
161
|
* Spawn a task child whose group the guard kills if vx dies holding it.
|
|
121
162
|
* The CHILD lists its own group, before it runs anything: vx wrote the
|
|
@@ -130,18 +171,45 @@ function guardWrite(line: string): void {
|
|
|
130
171
|
*/
|
|
131
172
|
export function spawnGuarded(spawn: (guard: number | undefined) => Child): Child {
|
|
132
173
|
startGuard()
|
|
133
|
-
|
|
174
|
+
if (typeof guardFd !== 'number') return spawn(undefined)
|
|
175
|
+
handingTo = guardProc?.pid
|
|
176
|
+
try {
|
|
177
|
+
return spawn(guardFd)
|
|
178
|
+
} finally {
|
|
179
|
+
handingTo = undefined
|
|
180
|
+
}
|
|
134
181
|
}
|
|
135
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The guard whose pipe `spawnGuarded` is handing over right now. Only a
|
|
185
|
+
* line built for that hand-over retries: its pipe is broken only once
|
|
186
|
+
* the guard has exited, so `kill -0` ends the wait. A line for any other
|
|
187
|
+
* pipe would wait on a guard that has nothing to do with it.
|
|
188
|
+
*/
|
|
189
|
+
let handingTo: number | undefined
|
|
190
|
+
|
|
136
191
|
/**
|
|
137
192
|
* The shell line that lists `$$`'s group on the guard's pipe at `fd` and
|
|
138
193
|
* closes it, so the task never holds the guard open. `$$` is the spawned
|
|
139
194
|
* shell, which leads its group (every guarded spawn is `detached`); a
|
|
140
195
|
* program that is not a shell is `exec`'d after it. A write to a guard
|
|
141
196
|
* that has died is ignored rather than a SIGPIPE that kills the task.
|
|
197
|
+
* The pipe is vx's own nonblocking one, so a full queue fails the write
|
|
198
|
+
* (EAGAIN) and the group went unlisted: the write is retried while the
|
|
199
|
+
* guard lives. The shell cannot tell EAGAIN from EPIPE, so the guard's
|
|
200
|
+
* pid ends the wait, and vx SIGKILLs a guard it gives up on.
|
|
142
201
|
*/
|
|
143
202
|
export function guardLine(fd: number): string {
|
|
144
|
-
|
|
203
|
+
const add = `printf '+%s\\n' $$ >&${fd} 2>/dev/null`
|
|
204
|
+
// `kill -0` counts a zombie, and a guard vx has not reaped (its event
|
|
205
|
+
// loop busy, or an init that never reaps an orphan after vx's kill -9)
|
|
206
|
+
// kept the shell spinning. Where /proc is ours, its state says dead;
|
|
207
|
+
// `##*) ` cuts at the LAST ')', as comm may hold one.
|
|
208
|
+
const alive = procfsIsOwn()
|
|
209
|
+
? `{ read -r vx_g </proc/${handingTo}/stat; } 2>/dev/null && case "\${vx_g##*) }" in [ZX]*) false ;; esac`
|
|
210
|
+
: `kill -0 ${handingTo} 2>/dev/null`
|
|
211
|
+
const retry = handingTo === undefined ? '' : ` || until ${add}; do ${alive} || break; done`
|
|
212
|
+
return `trap '' PIPE; ${add}${retry}; trap - PIPE; exec ${fd}>&-; `
|
|
145
213
|
}
|
|
146
214
|
|
|
147
215
|
/**
|
|
@@ -157,6 +225,25 @@ export function releaseGroup(child: Child): void {
|
|
|
157
225
|
else guardWrite(`-${child.pid}\n`)
|
|
158
226
|
}
|
|
159
227
|
|
|
228
|
+
/**
|
|
229
|
+
* List what the guard takes down besides groups if vx dies: the sandbox
|
|
230
|
+
* runtime's session. The runtime spawns its bridge socat as a plain child
|
|
231
|
+
* in vx's OWN group, which the guard must never kill, and a `kill -9`
|
|
232
|
+
* leaves its sockets in the temp directory. So each pid is killed alone
|
|
233
|
+
* and each path removed, by a name only that session carries. The
|
|
234
|
+
* returned function strikes them once the session is down, so a pid the
|
|
235
|
+
* kernel reuses is never killed on its account. Starts the guard: a spawn
|
|
236
|
+
* follows. A path holding whitespace is not listed (the lists split on it).
|
|
237
|
+
*/
|
|
238
|
+
export function guardSession(pids: readonly number[], paths: readonly string[]): () => void {
|
|
239
|
+
startGuard()
|
|
240
|
+
const listed = paths.filter((p) => !/\s/.test(p))
|
|
241
|
+
guardWrite(pids.map((p) => `=${p}\n`).join('') + listed.map((p) => `@${p}\n`).join(''))
|
|
242
|
+
return () => {
|
|
243
|
+
guardWrite(pids.map((p) => `_${p}\n`).join('') + listed.map((p) => `!${p}\n`).join(''))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
160
247
|
/** Groups a teardown is still taking down: how many hold each, and whether its runner let it go. */
|
|
161
248
|
const holds = new Map<number, { count: number; released: boolean }>()
|
|
162
249
|
|
|
@@ -181,7 +268,7 @@ export function holdGroups(children: readonly Child[]): () => void {
|
|
|
181
268
|
if (hold === undefined) continue
|
|
182
269
|
if (--hold.count > 0) continue
|
|
183
270
|
holds.delete(pid)
|
|
184
|
-
if (hold.released) guardWrite(`-${pid}\n`)
|
|
271
|
+
if (owed.delete(pid) || hold.released) guardWrite(`-${pid}\n`)
|
|
185
272
|
}
|
|
186
273
|
}
|
|
187
274
|
}
|
|
@@ -197,9 +284,23 @@ export function holdGroups(children: readonly Child[]): () => void {
|
|
|
197
284
|
*/
|
|
198
285
|
const goneGroups = new WeakSet<Child>()
|
|
199
286
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
287
|
+
/** Server groups that outlived their leader: struck when a teardown next lets them go. */
|
|
288
|
+
const owed = new Set<number>()
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A persistent child's leader has exited. An empty group is struck and
|
|
292
|
+
* never signalled again. One with a member left stays listed until a
|
|
293
|
+
* teardown lets it go: the run's registry still owns a ready server after
|
|
294
|
+
* its shell exits and stops its group at the end, and struck here, the
|
|
295
|
+
* `server` of a `server & echo up` was left to nobody by a `kill -9` of vx.
|
|
296
|
+
*/
|
|
297
|
+
export function releaseServerGroup(child: Child): void {
|
|
298
|
+
if (groupAlive(child)) {
|
|
299
|
+
owed.add(child.pid)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
goneGroups.add(child)
|
|
303
|
+
releaseGroup(child)
|
|
203
304
|
}
|
|
204
305
|
|
|
205
306
|
export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL'): void {
|