@vzn/vx 0.0.596 → 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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +31 -15
- 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/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 +4 -4
- package/src/cli/run.ts +42 -15
- 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-fs.ts +11 -3
- package/src/cli/watch.ts +14 -7
- 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 +89 -18
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +168 -49
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- 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/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +49 -26
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +10 -3
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-lock.ts +25 -24
- 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-eval.ts +27 -6
- 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 +15 -2
- package/src/workspace/workspace.ts +36 -14
package/src/cli/lock.ts
CHANGED
|
@@ -83,9 +83,11 @@ function bareNote(bare: number): string {
|
|
|
83
83
|
|
|
84
84
|
async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
85
85
|
const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
86
|
+
// `listProjects` sorts by name — stable lockfile diffs for free. Defined,
|
|
87
|
+
// not assigned: a project named `__proto__` set the map's prototype (D-152).
|
|
88
|
+
const projects: Record<string, LockfileEntry> = Object.fromEntries(
|
|
89
|
+
metas.map((m, i) => [m.name, entries[i]!]),
|
|
90
|
+
)
|
|
89
91
|
const leaks = secretsIn(projects)
|
|
90
92
|
if (leaks.length > 0) {
|
|
91
93
|
process.stderr.write(
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
|
|
2
2
|
|
|
3
3
|
import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import {
|
|
4
|
+
import { formatElapsed } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -37,7 +37,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
37
37
|
const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
|
|
38
38
|
const shortHash = t.hash ? t.hash.slice(0, 8) : ''
|
|
39
39
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
40
|
-
const eta = executes && t.p50Ms !== undefined ? ` ~${
|
|
40
|
+
const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
|
|
41
41
|
// Placement, by EXECUTOR NAME rather than a local/remote word: the
|
|
42
42
|
// summary line below already spends "local" and "remote" on the cache
|
|
43
43
|
// tier, and a task placed on a named executor is what the reader can
|
|
@@ -94,8 +94,8 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
94
94
|
const p = plan.predicted
|
|
95
95
|
const executes = miss + nocache
|
|
96
96
|
if (p !== undefined && executes > 0 && executes > p.unknownCount) {
|
|
97
|
-
const parts = [`predicted: ~${
|
|
98
|
-
parts.push(`~${
|
|
97
|
+
const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
|
|
98
|
+
parts.push(`~${formatElapsed(p.workMs)} total execution`)
|
|
99
99
|
if (p.unknownCount > 0) {
|
|
100
100
|
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
|
|
101
101
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
4
5
|
import { translateForeign } from './foreign-flags.js'
|
|
5
6
|
import { flagHint, seeHelp } from './help.js'
|
|
6
7
|
import {
|
|
@@ -13,7 +14,12 @@ import {
|
|
|
13
14
|
type RunResult,
|
|
14
15
|
} from '../orchestrator/index.js'
|
|
15
16
|
import type { ContinueMode, TaskOutcome } from '../graph/index.js'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type CachePolicy,
|
|
19
|
+
FULL_CACHE_POLICY,
|
|
20
|
+
gitRefusal,
|
|
21
|
+
parseCachePolicy,
|
|
22
|
+
} from '../cache/index.js'
|
|
17
23
|
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
18
24
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
19
25
|
import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
@@ -175,7 +181,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
175
181
|
if (n === null)
|
|
176
182
|
return {
|
|
177
183
|
...out,
|
|
178
|
-
error:
|
|
184
|
+
error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
|
|
179
185
|
}
|
|
180
186
|
out.concurrency = n
|
|
181
187
|
} else if (a === '--all') {
|
|
@@ -261,14 +267,17 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
261
267
|
out.download = v
|
|
262
268
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
263
269
|
const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
|
|
264
|
-
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a
|
|
270
|
+
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
|
|
265
271
|
// Unlike every other value flag, a cache dir is an arbitrary
|
|
266
272
|
// string — nothing about its shape rejects a swallowed flag. An
|
|
267
273
|
// unquoted empty shell var (`--cache-dir $EMPTY --force`) would
|
|
268
274
|
// otherwise create a directory literally named `--force` and drop
|
|
269
275
|
// the flag. A path starting with `-` needs the `=` form.
|
|
270
276
|
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
271
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
...out,
|
|
279
|
+
error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
|
|
280
|
+
}
|
|
272
281
|
}
|
|
273
282
|
out.cacheDir = v
|
|
274
283
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
@@ -323,14 +332,15 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
323
332
|
if (v === undefined)
|
|
324
333
|
return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
|
|
325
334
|
const n = parseDecimalInt(v)
|
|
326
|
-
if (n === null)
|
|
335
|
+
if (n === null)
|
|
336
|
+
return { ...out, error: `--verbosity must be a non-negative integer (got ${v})` }
|
|
327
337
|
out.verbosity = n
|
|
328
338
|
} else if (a === '--dry') {
|
|
329
339
|
out.dry = 'text'
|
|
330
340
|
} else if (a?.startsWith('--dry=')) {
|
|
331
341
|
const fmt = a.slice('--dry='.length)
|
|
332
342
|
if (fmt !== 'text' && fmt !== 'json') {
|
|
333
|
-
return { ...out, error:
|
|
343
|
+
return { ...out, error: `--dry must be text or json (got ${fmt})` }
|
|
334
344
|
}
|
|
335
345
|
out.dry = fmt
|
|
336
346
|
} else if (a === '--graph') {
|
|
@@ -358,7 +368,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
358
368
|
if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
|
|
359
369
|
// Split on the FIRST `=` so values may contain `=` (e.g. a URL).
|
|
360
370
|
const eq = raw.indexOf('=')
|
|
361
|
-
if (eq <= 0) return { ...out, error:
|
|
371
|
+
if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
|
|
362
372
|
out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
|
|
363
373
|
} else if (a === '--report-file' || a?.startsWith('--report-file=')) {
|
|
364
374
|
const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
|
|
@@ -367,7 +377,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
367
377
|
// nothing about its shape rejects a swallowed flag. A path starting
|
|
368
378
|
// with `-` needs the `=` form.
|
|
369
379
|
if (a === '--report-file' && v.startsWith('-')) {
|
|
370
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
...out,
|
|
382
|
+
error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
|
|
383
|
+
}
|
|
371
384
|
}
|
|
372
385
|
out.reportFile = v
|
|
373
386
|
} else if (a === '--report') {
|
|
@@ -375,7 +388,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
375
388
|
} else if (a?.startsWith('--report=')) {
|
|
376
389
|
const fmt = a.slice('--report='.length)
|
|
377
390
|
if (fmt !== 'markdown') {
|
|
378
|
-
return { ...out, error:
|
|
391
|
+
return { ...out, error: `--report must be markdown (got ${fmt})` }
|
|
379
392
|
}
|
|
380
393
|
out.report = fmt
|
|
381
394
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
@@ -620,6 +633,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
620
633
|
|
|
621
634
|
const cwd = process.cwd()
|
|
622
635
|
let tasks = [...parsed.tasks]
|
|
636
|
+
let picked: string | undefined
|
|
623
637
|
|
|
624
638
|
// No positionals → interactive picker (TTY only). Yields a single
|
|
625
639
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
@@ -638,6 +652,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
638
652
|
)
|
|
639
653
|
return 1
|
|
640
654
|
}
|
|
655
|
+
// Every run needs git: refused here, not after the user has chosen.
|
|
656
|
+
const refusal = gitRefusal(await findWorkspaceRoot(cwd))
|
|
657
|
+
if (refusal !== undefined) {
|
|
658
|
+
process.stderr.write(`${refusal.message}\n`)
|
|
659
|
+
return 1
|
|
660
|
+
}
|
|
641
661
|
const load = {
|
|
642
662
|
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
643
663
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
@@ -662,10 +682,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
662
682
|
}
|
|
663
683
|
only = new Set(selected.names)
|
|
664
684
|
}
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
if (!
|
|
668
|
-
|
|
685
|
+
const chosen = await pickTask(cwd, {}, load, only)
|
|
686
|
+
if (chosen === 'interrupted') return 130
|
|
687
|
+
if (!chosen) return 1
|
|
688
|
+
picked = `${chosen.project}#${chosen.task}`
|
|
689
|
+
tasks = [picked]
|
|
669
690
|
}
|
|
670
691
|
|
|
671
692
|
const resolved = await resolveRunOptions(parsed, cwd, tasks)
|
|
@@ -680,8 +701,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
680
701
|
const opts = resolved
|
|
681
702
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
682
703
|
// show what was actually run. `args` is everything after `run`, quoted
|
|
683
|
-
// so `vx last` replays one paste-able line
|
|
684
|
-
|
|
704
|
+
// so `vx last` replays one paste-able line; a picked task is named in
|
|
705
|
+
// it, or the line would open the picker again.
|
|
706
|
+
opts.command = [
|
|
707
|
+
'vx',
|
|
708
|
+
'run',
|
|
709
|
+
...(picked !== undefined ? [shellQuote(picked)] : []),
|
|
710
|
+
...args.map(shellQuote),
|
|
711
|
+
].join(' ')
|
|
685
712
|
|
|
686
713
|
// Planning paths short-circuit execution. Both build the full task
|
|
687
714
|
// graph + probe the cache; the difference is just the formatter.
|
package/src/cli/select.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import type { ProjectConfig } from '../config.js'
|
|
27
27
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
28
28
|
import { parseDependencySpec } from '../graph/index.js'
|
|
29
|
-
import { declaresInput } from '../cache/index.js'
|
|
29
|
+
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
30
30
|
import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
|
|
31
31
|
import {
|
|
32
32
|
claimedAffected,
|
|
@@ -60,12 +60,15 @@ export async function workspaceGlobOwners(
|
|
|
60
60
|
load: CliLoadOptions = {},
|
|
61
61
|
stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
|
|
62
62
|
loadCliProjects(root, projects, 'all', load),
|
|
63
|
+
/** The changed nested repositories (`AffectedChanges.nested`). */
|
|
64
|
+
nested: readonly string[] = [],
|
|
63
65
|
): Promise<string[]> {
|
|
64
66
|
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
65
67
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
66
68
|
const cache = task.cache
|
|
67
69
|
if (cache === undefined) continue
|
|
68
70
|
if (changed.some((rel) => declaresInput(cache, null, rel))) return true
|
|
71
|
+
if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
|
|
69
72
|
}
|
|
70
73
|
return false
|
|
71
74
|
}
|
|
@@ -319,8 +322,8 @@ export async function resolveFilters(
|
|
|
319
322
|
workspaceRoot: root,
|
|
320
323
|
since: f.gitSince,
|
|
321
324
|
projects,
|
|
322
|
-
workspaceGlobOwners: (changed: readonly string[]) =>
|
|
323
|
-
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
325
|
+
workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
|
|
326
|
+
workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
|
|
324
327
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
325
328
|
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
326
329
|
...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
|
package/src/cli/show.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
41
41
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
42
42
|
else if (a === '--filter') {
|
|
43
43
|
const v = args[++i]
|
|
44
|
-
|
|
44
|
+
// `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
|
|
45
|
+
if (v === undefined || v === '' || v.startsWith('--'))
|
|
46
|
+
return {
|
|
47
|
+
...out,
|
|
48
|
+
error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
|
|
49
|
+
}
|
|
45
50
|
out.filters.push(v)
|
|
46
51
|
} else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
|
|
47
52
|
else if (a === '--affected') out.affected = ''
|
|
@@ -55,7 +60,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
55
60
|
return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
|
|
56
61
|
// An empty name is a part of every name: its "did you mean" listed the
|
|
57
62
|
// whole workspace.
|
|
58
|
-
else if (a === '')
|
|
63
|
+
else if (a === '')
|
|
64
|
+
return { ...out, error: `empty target (omit it to list every project)${seeHelp('show')}` }
|
|
59
65
|
else out.target = a
|
|
60
66
|
|
|
61
67
|
if (format !== undefined) {
|
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
|
/**
|
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,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. */
|
|
@@ -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
|
|
@@ -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,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 {
|