@vzn/vx 0.0.21 → 0.0.22
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 +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { constants as osConstants } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { execWord, exitSignal } from '../exec/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The frame line for a shell verdict the task's own output leaves a mystery.
|
|
8
|
+
* `sh -c 'exec <word> …'` exits 127 ("not found") and 126 ("cannot
|
|
9
|
+
* execute"), and its one line names the word and nothing about why: a bare
|
|
10
|
+
* word is a PATH lookup, and the PATH is vx's (item 257); a word with a
|
|
11
|
+
* slash is a file, and the file itself says why — missing, a directory, no
|
|
12
|
+
* execute bit, a `#!` interpreter that is not there (dash and bash 5 say
|
|
13
|
+
* "not found" and blame the file; bash 3.2 names it itself and exits 1),
|
|
14
|
+
* or no `#!` line at all (item 258). An exit above 128 is the shell's
|
|
15
|
+
* report of a death by signal, and the number alone says nothing about
|
|
16
|
+
* what sent it — the OOM killer, a crash in native code, an abort, a
|
|
17
|
+
* closed pipe (item 259).
|
|
18
|
+
*/
|
|
19
|
+
export function shellVerdict(args: {
|
|
20
|
+
code: number
|
|
21
|
+
command: string
|
|
22
|
+
cwd: string
|
|
23
|
+
bins: string[]
|
|
24
|
+
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
|
+
signal?: string | undefined
|
|
26
|
+
}): string | undefined {
|
|
27
|
+
if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
|
|
28
|
+
const word = execWord(args.command)
|
|
29
|
+
const what = word ?? 'a command in this task'
|
|
30
|
+
if (word !== undefined && word.includes('/')) {
|
|
31
|
+
const file = path.resolve(args.cwd, word)
|
|
32
|
+
return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${fileVerdict(file)}`
|
|
33
|
+
}
|
|
34
|
+
if (args.code === 126) {
|
|
35
|
+
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
36
|
+
}
|
|
37
|
+
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why the shell could not run a file, read from the file. */
|
|
41
|
+
function fileVerdict(file: string): string {
|
|
42
|
+
if (!existsSync(file)) {
|
|
43
|
+
return `does not exist — looked for ${file}, relative to the task's working directory`
|
|
44
|
+
}
|
|
45
|
+
let head: string
|
|
46
|
+
try {
|
|
47
|
+
const st = statSync(file)
|
|
48
|
+
if (st.isDirectory()) return 'is a directory'
|
|
49
|
+
if ((st.mode & 0o111) === 0) return 'is not executable — chmod +x it'
|
|
50
|
+
head = readFileSync(file, 'latin1').slice(0, 256)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return `could not be read (${(err as Error).message})`
|
|
53
|
+
}
|
|
54
|
+
if (!head.startsWith('#!')) {
|
|
55
|
+
return 'has no #! line, so it ran as a binary the loader refused — add a #! line or build it for this platform'
|
|
56
|
+
}
|
|
57
|
+
const nl = head.indexOf('\n')
|
|
58
|
+
const line = head.slice(2, nl === -1 ? head.length : nl)
|
|
59
|
+
if (line.endsWith('\r')) {
|
|
60
|
+
return `exists, and its #! line ends in CRLF, so the interpreter the shell looked for is "${line.trim()}\\r" — convert the file to LF line endings`
|
|
61
|
+
}
|
|
62
|
+
const interp = line.trim().split(/\s+/)[0] ?? ''
|
|
63
|
+
if (interp.startsWith('/') && !existsSync(interp)) {
|
|
64
|
+
return `exists, and its #! interpreter ${interp} does not — install it or fix the line`
|
|
65
|
+
}
|
|
66
|
+
return `exists and is executable, and the shell still could not run it — check its #! line (${line.trim()})`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The signal behind an exit above 128, from the runner's own report or the code alone. */
|
|
70
|
+
function signalVerdict(code: number, signal: string | undefined): string | undefined {
|
|
71
|
+
const signals = osConstants.signals as Partial<Record<string, number>>
|
|
72
|
+
let name: string | undefined
|
|
73
|
+
let num: number | undefined
|
|
74
|
+
if (signal !== undefined) {
|
|
75
|
+
name = signal
|
|
76
|
+
num = signals[signal]
|
|
77
|
+
} else {
|
|
78
|
+
name = exitSignal(code)
|
|
79
|
+
num = code - 128
|
|
80
|
+
}
|
|
81
|
+
if (name === undefined || num === undefined) return undefined
|
|
82
|
+
// A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
|
|
83
|
+
// the orchestrator reverts the task to aborted — that path has its own line.
|
|
84
|
+
if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
|
|
85
|
+
const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
|
|
86
|
+
const how =
|
|
87
|
+
signal !== undefined
|
|
88
|
+
? `is how the shell reports a death by ${name} (${num})`
|
|
89
|
+
: `is 128 + ${num}, the shell's report of a death by ${name} in the last command (or that command exited ${code} itself)`
|
|
90
|
+
return `[vx] exit ${code} ${how}: ${why}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const SIGNAL_WHY: Record<string, string> = {
|
|
94
|
+
SIGKILL:
|
|
95
|
+
"nothing catches it — on Linux the kernel's OOM killer (dmesg, or the memory limit of the container's cgroup) or an explicit kill; vx's own timeout reports itself as a timeout",
|
|
96
|
+
SIGSEGV:
|
|
97
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
98
|
+
SIGBUS:
|
|
99
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
100
|
+
SIGILL:
|
|
101
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
102
|
+
SIGFPE:
|
|
103
|
+
'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
|
|
104
|
+
SIGTRAP:
|
|
105
|
+
"the program hit a breakpoint or a trap instruction — a debugger, or a runtime's fatal check",
|
|
106
|
+
SIGABRT:
|
|
107
|
+
"the program aborted itself — an assertion, or an allocator failure (a JS runtime's heap limit reports here)",
|
|
108
|
+
SIGTERM:
|
|
109
|
+
"something outside vx asked the process to stop (a runner's cancellation, a container stop, a kill)",
|
|
110
|
+
SIGINT: 'something outside vx interrupted the process (a Ctrl-C reaching it, a kill -INT)',
|
|
111
|
+
SIGHUP: 'the process lost its controlling terminal or session (a hangup, a closed SSH session)',
|
|
112
|
+
SIGPIPE: 'it wrote to a pipe whose reader had gone — a reader in the command left early',
|
|
113
|
+
SIGXCPU: "a CPU-time limit (ulimit -t, or a sandbox's) was hit",
|
|
114
|
+
SIGXFSZ: 'a file-size limit (ulimit -f) was hit',
|
|
115
|
+
SIGSYS: 'a system call was refused — a seccomp filter, or the sandbox',
|
|
116
|
+
SIGQUIT: 'the process was sent a quit (Ctrl-\\, or a kill -QUIT); a core dump may be beside it',
|
|
117
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// A SIGINT/SIGTERM mid-run
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A SIGINT/SIGTERM mid-run stops the run the way `RunOptions.signal` does
|
|
2
|
+
// — the scheduler dispatches nothing more, and the signal is forwarded to
|
|
3
|
+
// everything live, SIGKILLed after a bounded grace — then waits, bounded,
|
|
4
|
+
// for run() to leave its own end-of-run path (telemetry flush, each
|
|
5
|
+
// plugin's teardown, the cache close), and exits 128+signo (130/143). A
|
|
6
|
+
// second signal exits at once. Without the forward, a
|
|
4
7
|
// programmatic signal to the vx process alone (CI cancellation,
|
|
5
8
|
// `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
|
|
6
9
|
// via process-group propagation. Without the escalation (added
|
|
@@ -11,7 +14,8 @@
|
|
|
11
14
|
// them to the runner around every spawn.
|
|
12
15
|
|
|
13
16
|
import { signalExitCode } from '../exec/index.js'
|
|
14
|
-
import { killGraceMs } from '../util/index.js'
|
|
17
|
+
import { killGraceMs, settleWithin } from '../util/index.js'
|
|
18
|
+
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
15
19
|
import type { Logger } from './logger.js'
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -25,33 +29,60 @@ export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
|
25
29
|
type Child = ReturnType<typeof Bun.spawn>
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
* SIGHUP too: a task runs in its own session (kill-tree.ts), so the
|
|
33
|
+
* terminal closing no longer reaches it — only vx hears the hang-up,
|
|
34
|
+
* and vx must pass it on or the tree outlives the window.
|
|
35
|
+
*/
|
|
36
|
+
export type StopSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP'
|
|
37
|
+
|
|
38
|
+
/** What a teardown sends a task's group before the SIGKILL. */
|
|
39
|
+
export type ForwardedSignal = 'SIGINT' | 'SIGTERM'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The signal a stop forwards to every task group: the one vx received.
|
|
43
|
+
* A task runs in its own session, so a terminal's Ctrl-C reaches vx
|
|
44
|
+
* alone; forwarding SIGTERM in its place skipped every SIGINT-only
|
|
45
|
+
* cleanup — a Node SIGINT listener, a shell's `trap … INT`
|
|
46
|
+
* (turborepo#444, #12652, #13097 and nx#23585 reproduced on vx,
|
|
47
|
+
* 2026-09-24). A hang-up forwards SIGTERM: to many servers SIGHUP means
|
|
48
|
+
* "reload", not "stop". Anything else — an embedder's abort reason that
|
|
49
|
+
* names no signal — is a SIGTERM.
|
|
50
|
+
*/
|
|
51
|
+
export function forwardedSignal(received: unknown): ForwardedSignal {
|
|
52
|
+
return received === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send `signal` to every child `live()` returns, wait the grace for each one's
|
|
57
|
+
* process GROUP to go, then SIGKILL every group with a member left and
|
|
58
|
+
* whatever `live()` returns NOW — re-read, because the run loop may still
|
|
59
|
+
* be dispatching during the grace and a child spawned after the first
|
|
60
|
+
* sweep must not survive the second. The group, not the leader: a shell
|
|
61
|
+
* that dies at once on the SIGTERM ended the wait, and its backgrounded
|
|
62
|
+
* server was SIGKILLed mid-cleanup or, once the runner had dropped the
|
|
63
|
+
* shell, not at all. Resolves once the survivors are reaped. The one
|
|
64
|
+
* teardown behind the process-signal handler below, `RunOptions.signal`,
|
|
65
|
+
* and the foreground keep-alive.
|
|
34
66
|
*/
|
|
35
67
|
export async function terminateChildren(
|
|
36
68
|
live: () => Child[],
|
|
69
|
+
signal: ForwardedSignal = 'SIGTERM',
|
|
37
70
|
graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
|
|
38
71
|
): Promise<void> {
|
|
39
72
|
const children = live()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
new
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
for (const child of survivors) child.kill('SIGKILL')
|
|
54
|
-
await Promise.allSettled(survivors.map((c) => c.exited))
|
|
73
|
+
// On the group guard's list until the sweep below is done: a shell that
|
|
74
|
+
// dies on the signal lets its group go while what it forked runs out
|
|
75
|
+
// the grace (kill-tree.ts, `holdGroups`).
|
|
76
|
+
const letGo = holdGroups(children)
|
|
77
|
+
try {
|
|
78
|
+
for (const child of children) killTree(child, signal)
|
|
79
|
+
const left = await untilGroupsGone(children, graceMs)
|
|
80
|
+
const survivors = [...new Set([...left, ...live()])]
|
|
81
|
+
for (const child of survivors) killTree(child, 'SIGKILL')
|
|
82
|
+
await Promise.allSettled(survivors.map((c) => c.exited))
|
|
83
|
+
} finally {
|
|
84
|
+
letGo()
|
|
85
|
+
}
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
export interface SignalForwarding {
|
|
@@ -64,14 +95,28 @@ export function forwardSignals(args: {
|
|
|
64
95
|
enabled: boolean
|
|
65
96
|
log: Logger
|
|
66
97
|
cache: { close(): void }
|
|
98
|
+
/**
|
|
99
|
+
* Stop the run as `RunOptions.signal` would: the scheduler stops
|
|
100
|
+
* dispatching and run()'s abort listener tears the children down.
|
|
101
|
+
*/
|
|
102
|
+
stop: (signal: StopSignal) => void
|
|
103
|
+
/**
|
|
104
|
+
* Settles when run() has left its `finally`. A signal waits for it
|
|
105
|
+
* before exiting: `process.exit` runs no `finally`, and plugin.md
|
|
106
|
+
* promises a plugin its teardown and a sink its flush at the end of
|
|
107
|
+
* every run — a Ctrl-C skipped both (item 849).
|
|
108
|
+
*/
|
|
109
|
+
done: Promise<void>
|
|
110
|
+
/** How long a signal waits for `done` before it exits anyway. */
|
|
111
|
+
boundMs: number
|
|
67
112
|
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
68
113
|
liveChildren: ReadonlySet<Child>
|
|
69
114
|
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
70
115
|
persistentRegistry: ReadonlyMap<string, Child>
|
|
71
116
|
}): SignalForwarding {
|
|
72
117
|
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
73
|
-
const exit = (signal:
|
|
74
|
-
for (const child of everyChild()) child
|
|
118
|
+
const exit = (signal: StopSignal): never => {
|
|
119
|
+
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
75
120
|
try {
|
|
76
121
|
args.cache.close()
|
|
77
122
|
} catch {
|
|
@@ -79,8 +124,8 @@ export function forwardSignals(args: {
|
|
|
79
124
|
}
|
|
80
125
|
process.exit(signalExitCode(signal))
|
|
81
126
|
}
|
|
82
|
-
let stopping:
|
|
83
|
-
const onSignal = (signal:
|
|
127
|
+
let stopping: StopSignal | undefined
|
|
128
|
+
const onSignal = (signal: StopSignal): void => {
|
|
84
129
|
// A second signal during the grace is the user saying "now": SIGKILL
|
|
85
130
|
// and go. The exit code stays the first signal's — that is the one
|
|
86
131
|
// that ended the run.
|
|
@@ -94,18 +139,29 @@ export function forwardSignals(args: {
|
|
|
94
139
|
} catch {
|
|
95
140
|
// teardown must not throw on the way out
|
|
96
141
|
}
|
|
97
|
-
|
|
142
|
+
args.stop(signal)
|
|
143
|
+
void settleWithin(args.done, args.boundMs)
|
|
144
|
+
// The run's output may still be in the pipe: `process.exit` drops
|
|
145
|
+
// what a reader has not taken (CLAUDE.md). An empty `write`'s
|
|
146
|
+
// callback fired early, and a CI-mode run lost 0.8 of 2 MiB (item
|
|
147
|
+
// 857); `end`'s waits for the pipe on Bun >= 1.4 (bin.ts). Bounded,
|
|
148
|
+
// for a reader that has gone and never takes it.
|
|
149
|
+
.then(() => settleWithin(new Promise<void>((r) => process.stdout.end(() => r())), 2_000))
|
|
150
|
+
.then(() => exit(signal))
|
|
98
151
|
}
|
|
99
152
|
const onSigint = (): void => onSignal('SIGINT')
|
|
100
153
|
const onSigterm = (): void => onSignal('SIGTERM')
|
|
154
|
+
const onSighup = (): void => onSignal('SIGHUP')
|
|
101
155
|
if (args.enabled) {
|
|
102
156
|
process.on('SIGINT', onSigint)
|
|
103
157
|
process.on('SIGTERM', onSigterm)
|
|
158
|
+
process.on('SIGHUP', onSighup)
|
|
104
159
|
}
|
|
105
160
|
return {
|
|
106
161
|
remove: () => {
|
|
107
162
|
process.off('SIGINT', onSigint)
|
|
108
163
|
process.off('SIGTERM', onSigterm)
|
|
164
|
+
process.off('SIGHUP', onSighup)
|
|
109
165
|
},
|
|
110
166
|
}
|
|
111
167
|
}
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
15
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
-
import { normalizeGlob, relPosix } from '../util/index.js'
|
|
16
|
+
import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
|
|
17
|
+
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
17
18
|
import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
|
|
19
|
+
import { filterUpstreamHashes, keyUpstream } from './upstream.js'
|
|
18
20
|
|
|
19
21
|
export interface DeriveStableKeysArgs {
|
|
20
22
|
nodes: Map<string, TaskNode>
|
|
@@ -55,10 +57,47 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
55
57
|
// dep's accumulated producers fold forward.
|
|
56
58
|
// - outputProjects: the project names of every upstream task declaring
|
|
57
59
|
// cache.outputs.files (project-relative outputs land in the producer's
|
|
58
|
-
// own dir)
|
|
60
|
+
// own dir), and of every upstream with no cache block that may write
|
|
61
|
+
// in its own project (`undeclaredWriteReach`, item 743). A bitset over
|
|
62
|
+
// project indexes: a union per dep was a string Set copied tasks ×
|
|
63
|
+
// deps × projects times, 101 ms of a 513 ms warm run at 476 packages
|
|
64
|
+
// (item 744).
|
|
59
65
|
// - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
|
|
60
|
-
// (root-anchored, boundary-ignoring outputs)
|
|
61
|
-
|
|
66
|
+
// (root-anchored, boundary-ignoring outputs), or has no cache block
|
|
67
|
+
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
68
|
+
// a file the workspace fingerprint folds (a root `pnpm install`): every
|
|
69
|
+
// key folds that digest, taken once per run.
|
|
70
|
+
// - rewritersById / unfoldedById: the projects of every upstream CACHED
|
|
71
|
+
// task that may rewrite its own inputs in place (a formatter declaring
|
|
72
|
+
// no outputs; `commandWriteReach`), and the subset this task's key does
|
|
73
|
+
// not fold. A key that folds the rewriter's covers what it writes — its
|
|
74
|
+
// writes are its inputs', which its key names — so only a reader
|
|
75
|
+
// folding no path to it (`tasks: []`) is preliminary. A rewriter an
|
|
76
|
+
// unfolded dependency covered is still uncovered here: its key is not
|
|
77
|
+
// in this one.
|
|
78
|
+
// - wsRewriters: the same for a cached rewriter whose reach is every
|
|
79
|
+
// project (a write grant elsewhere in the workspace, or a fingerprinted
|
|
80
|
+
// file, `mayWriteFingerprint`); one this key does not fold makes it
|
|
81
|
+
// preliminary. A dependency this key folds needs no pass-through here:
|
|
82
|
+
// a keyed task with such a rewriter unfolded is unstable itself, and
|
|
83
|
+
// instability is inherited below.
|
|
84
|
+
const projects = new ProjectIndex(args.nodes)
|
|
85
|
+
const outputProjectsById = new Map<string, ProjectSet>()
|
|
86
|
+
const rewritersById = new Map<string, ProjectSet>()
|
|
87
|
+
const unfoldedById = new Map<string, ProjectSet>()
|
|
88
|
+
const wsRewriters = new Set<string>()
|
|
89
|
+
// A cached rewriter matters only to a key that leaves a dependency's key
|
|
90
|
+
// out: a `cache.inputs.tasks` filter, or a persistent dependency, which
|
|
91
|
+
// has no key. With neither in the graph every edge folds and the sets
|
|
92
|
+
// above stay empty, so they are not built (about 2 ms, median, of a 27 ms
|
|
93
|
+
// memoised walk over the 3,000-task bench; item 750).
|
|
94
|
+
let anyUnfolded = false
|
|
95
|
+
for (const n of args.nodes.values()) {
|
|
96
|
+
if (n.config.cache?.inputs?.tasks !== undefined || n.config.exec?.persistent !== undefined) {
|
|
97
|
+
anyUnfolded = true
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
62
101
|
const wsOutputUpstreamById = new Map<string, boolean>()
|
|
63
102
|
const stableKeys: StableKey[] = []
|
|
64
103
|
// Workspace-relative project dirs, for the reach test of a
|
|
@@ -76,19 +115,68 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
76
115
|
|
|
77
116
|
// Fold every dep's accumulated producers + the dep's own declared
|
|
78
117
|
// outputs into this node's transitive-upstream producer sets.
|
|
79
|
-
const outputProjects =
|
|
118
|
+
const outputProjects = projects.empty()
|
|
119
|
+
let rewriters: ProjectSet | undefined
|
|
120
|
+
let unfolded: ProjectSet | undefined
|
|
121
|
+
let folds: ((dep: string) => boolean) | undefined
|
|
122
|
+
let wsRewriter = false
|
|
123
|
+
let wsUnfoldedHere = false
|
|
80
124
|
let wsOutputUpstream = false
|
|
81
125
|
for (const dep of node.deps) {
|
|
82
126
|
const depNode = args.nodes.get(dep)
|
|
83
127
|
if (!depNode) continue
|
|
84
|
-
|
|
128
|
+
const upstreamOfDep = outputProjectsById.get(dep)
|
|
129
|
+
if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
|
|
85
130
|
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
86
131
|
const depOut = depNode.config.cache?.outputs
|
|
87
132
|
if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
|
|
88
133
|
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
134
|
+
// An uncached producer declares no outputs, yet a same-project reader
|
|
135
|
+
// after it (`tasks: []` decouples the key, not the order) read the
|
|
136
|
+
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
137
|
+
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
138
|
+
const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
|
|
139
|
+
if (reach === 'project') outputProjects.add(depNode.projectName)
|
|
140
|
+
else if (reach === 'workspace') wsOutputUpstream = true
|
|
141
|
+
const cached = depNode.config.cache !== undefined
|
|
142
|
+
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
|
|
143
|
+
if (!anyUnfolded) continue
|
|
144
|
+
// The cached twin: a formatter rewriting its own input ahead of a
|
|
145
|
+
// reader that folds no key of it replayed seed B's build under seed A
|
|
146
|
+
// on the fourth run, the same way (item 750).
|
|
147
|
+
const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
|
|
148
|
+
const rewritesWs =
|
|
149
|
+
cached && (rewrites === 'workspace' || mayWriteFingerprint(depNode, args.workspaceRoot))
|
|
150
|
+
const rewritersOfDep = rewritersById.get(dep)
|
|
151
|
+
const wsRewriterOfDep = wsRewriters.has(dep)
|
|
152
|
+
if (
|
|
153
|
+
rewritersOfDep === undefined &&
|
|
154
|
+
rewrites !== 'project' &&
|
|
155
|
+
!wsRewriterOfDep &&
|
|
156
|
+
!rewritesWs
|
|
157
|
+
) {
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
rewriters ??= projects.empty()
|
|
161
|
+
if (rewritersOfDep !== undefined) rewriters.addAll(rewritersOfDep)
|
|
162
|
+
if (rewrites === 'project') rewriters.add(depNode.projectName)
|
|
163
|
+
if (wsRewriterOfDep || rewritesWs) wsRewriter = true
|
|
164
|
+
folds ??= foldedBy(node, upstream, keyById)
|
|
165
|
+
if (folds(dep)) {
|
|
166
|
+
const unfoldedOfDep = unfoldedById.get(dep)
|
|
167
|
+
if (unfoldedOfDep !== undefined) (unfolded ??= projects.empty()).addAll(unfoldedOfDep)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
unfolded ??= projects.empty()
|
|
171
|
+
if (rewritersOfDep !== undefined) unfolded.addAll(rewritersOfDep)
|
|
172
|
+
if (rewrites === 'project') unfolded.add(depNode.projectName)
|
|
173
|
+
if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
|
|
89
174
|
}
|
|
90
175
|
outputProjectsById.set(id, outputProjects)
|
|
91
176
|
wsOutputUpstreamById.set(id, wsOutputUpstream)
|
|
177
|
+
if (rewriters !== undefined) rewritersById.set(id, rewriters)
|
|
178
|
+
if (unfolded !== undefined) unfoldedById.set(id, unfolded)
|
|
179
|
+
if (wsRewriter) wsRewriters.add(id)
|
|
92
180
|
|
|
93
181
|
if (isGroupTask(node)) {
|
|
94
182
|
// Groups have no exec/cache; they only fold upstream keys so
|
|
@@ -100,6 +188,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
100
188
|
continue
|
|
101
189
|
}
|
|
102
190
|
|
|
191
|
+
// A persistent task has no key on the live path (its outcome carries no
|
|
192
|
+
// hash, so a dependent folds nothing of it); recording one here gave
|
|
193
|
+
// its dependents a second key, which a `--force` or remote run never
|
|
194
|
+
// saved under.
|
|
195
|
+
if (node.config.exec?.persistent !== undefined) continue
|
|
196
|
+
|
|
103
197
|
const hash = await computeTaskHash({
|
|
104
198
|
node,
|
|
105
199
|
upstream,
|
|
@@ -115,7 +209,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
115
209
|
|
|
116
210
|
const unstable =
|
|
117
211
|
node.deps.some((d) => unstableById.has(d)) ||
|
|
118
|
-
dependsOnSiblingOutputs(
|
|
212
|
+
dependsOnSiblingOutputs(
|
|
213
|
+
node,
|
|
214
|
+
unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
|
|
215
|
+
wsOutputUpstream || wsUnfoldedHere,
|
|
216
|
+
dirByProject,
|
|
217
|
+
)
|
|
119
218
|
if (unstable) unstableById.add(id)
|
|
120
219
|
|
|
121
220
|
const cacheEnabled = node.config.cache !== undefined
|
|
@@ -124,6 +223,28 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
124
223
|
return stableKeys
|
|
125
224
|
}
|
|
126
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Does this task's key fold the dependency's? What `computeTaskHash` folds
|
|
228
|
+
* (`filterUpstreamHashes` over the same synthetic outcomes); a group folds
|
|
229
|
+
* every keyed member. A dependency with no key (persistent) is folded by
|
|
230
|
+
* none.
|
|
231
|
+
*/
|
|
232
|
+
function foldedBy(
|
|
233
|
+
node: TaskNode,
|
|
234
|
+
upstream: TaskOutcome[],
|
|
235
|
+
keyById: Map<string, string>,
|
|
236
|
+
): (dep: string) => boolean {
|
|
237
|
+
const filter = node.config.cache?.inputs?.tasks
|
|
238
|
+
if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
|
|
239
|
+
const hashes = new Set(
|
|
240
|
+
filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
|
|
241
|
+
)
|
|
242
|
+
return (dep) => {
|
|
243
|
+
const h = keyById.get(dep)
|
|
244
|
+
return h !== undefined && hashes.has(h)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
127
248
|
/**
|
|
128
249
|
* Synthetic upstream outcomes for key derivation. Only `node` + `hash`
|
|
129
250
|
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
@@ -146,7 +267,7 @@ function synthUpstream(
|
|
|
146
267
|
hash: keyById.get(dep),
|
|
147
268
|
} as TaskOutcome)
|
|
148
269
|
}
|
|
149
|
-
return out
|
|
270
|
+
return keyUpstream(node, out)
|
|
150
271
|
}
|
|
151
272
|
|
|
152
273
|
/**
|
|
@@ -176,7 +297,7 @@ function synthUpstream(
|
|
|
176
297
|
*/
|
|
177
298
|
export function dependsOnSiblingOutputs(
|
|
178
299
|
node: TaskNode,
|
|
179
|
-
upstreamOutputProjects:
|
|
300
|
+
upstreamOutputProjects: ProjectNames,
|
|
180
301
|
hasWsOutputUpstream: boolean,
|
|
181
302
|
dirByProject?: ReadonlyMap<string, string>,
|
|
182
303
|
): boolean {
|
|
@@ -232,7 +353,7 @@ export function workspaceInputsReach(
|
|
|
232
353
|
const segments = entry.split('/')
|
|
233
354
|
const literal: string[] = []
|
|
234
355
|
for (const seg of segments) {
|
|
235
|
-
if (
|
|
356
|
+
if (!isLiteralPattern(seg)) break
|
|
236
357
|
literal.push(seg)
|
|
237
358
|
}
|
|
238
359
|
if (literal.length === 0) return true
|
|
@@ -246,6 +367,77 @@ export function workspaceInputsReach(
|
|
|
246
367
|
return false
|
|
247
368
|
}
|
|
248
369
|
|
|
370
|
+
/** What the stability gate reads of a set of project names. */
|
|
371
|
+
interface ProjectNames extends Iterable<string> {
|
|
372
|
+
has(name: string): boolean
|
|
373
|
+
readonly size: number
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every project of the graph, numbered, so a set of them is a bitset. */
|
|
377
|
+
class ProjectIndex {
|
|
378
|
+
readonly names: string[] = []
|
|
379
|
+
readonly indexOf = new Map<string, number>()
|
|
380
|
+
readonly words: number
|
|
381
|
+
|
|
382
|
+
constructor(nodes: Map<string, TaskNode>) {
|
|
383
|
+
for (const node of nodes.values()) {
|
|
384
|
+
if (this.indexOf.has(node.projectName)) continue
|
|
385
|
+
this.indexOf.set(node.projectName, this.names.length)
|
|
386
|
+
this.names.push(node.projectName)
|
|
387
|
+
}
|
|
388
|
+
this.words = (this.names.length + 31) >>> 5
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
empty(): ProjectSet {
|
|
392
|
+
return new ProjectSet(this, new Uint32Array(this.words))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
class ProjectSet implements ProjectNames {
|
|
397
|
+
constructor(
|
|
398
|
+
private readonly index: ProjectIndex,
|
|
399
|
+
private readonly bits: Uint32Array,
|
|
400
|
+
) {}
|
|
401
|
+
|
|
402
|
+
add(name: string): void {
|
|
403
|
+
const i = this.index.indexOf.get(name)!
|
|
404
|
+
this.bits[i >>> 5]! |= 1 << (i & 31)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
addAll(other: ProjectSet): void {
|
|
408
|
+
for (let w = 0; w < this.bits.length; w++) this.bits[w]! |= other.bits[w]!
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
or(other: ProjectSet): ProjectSet {
|
|
412
|
+
const bits = this.bits.slice()
|
|
413
|
+
for (let w = 0; w < bits.length; w++) bits[w]! |= other.bits[w]!
|
|
414
|
+
return new ProjectSet(this.index, bits)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
has(name: string): boolean {
|
|
418
|
+
const i = this.index.indexOf.get(name)
|
|
419
|
+
return i !== undefined && (this.bits[i >>> 5]! & (1 << (i & 31))) !== 0
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
get size(): number {
|
|
423
|
+
let n = 0
|
|
424
|
+
for (const word of this.bits) {
|
|
425
|
+
let v = word
|
|
426
|
+
while (v !== 0) {
|
|
427
|
+
v &= v - 1
|
|
428
|
+
n++
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return n
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
*[Symbol.iterator](): Iterator<string> {
|
|
435
|
+
for (let i = 0; i < this.index.names.length; i++) {
|
|
436
|
+
if ((this.bits[i >>> 5]! & (1 << (i & 31))) !== 0) yield this.index.names[i]!
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
249
441
|
function topoOrder(nodes: Map<string, TaskNode>): string[] {
|
|
250
442
|
const indegree = new Map<string, number>()
|
|
251
443
|
const dependents = new Map<string, string[]>()
|
|
@@ -42,6 +42,8 @@ export interface OutputWriter {
|
|
|
42
42
|
* `setStatus`; the two share one display slot.
|
|
43
43
|
*/
|
|
44
44
|
setRegion(lines: readonly string[], opts?: { force?: boolean }): void
|
|
45
|
+
/** Hand over what `coalesce` holds and write straight through from now on. */
|
|
46
|
+
settle(): void
|
|
45
47
|
/** Permanently remove the status display; later set* is a no-op. */
|
|
46
48
|
clearStatus(): void
|
|
47
49
|
}
|
|
@@ -61,6 +63,13 @@ export interface OutputWriterOptions {
|
|
|
61
63
|
forceFloorMs?: number
|
|
62
64
|
/** Injectable clock for tests. */
|
|
63
65
|
now?: () => number
|
|
66
|
+
/**
|
|
67
|
+
* Off a TTY, hold writes and hand them to the stream once per turn of
|
|
68
|
+
* the event loop until `settle()`. A warm 476-package run wrote each of
|
|
69
|
+
* its 952 task lines as its own syscall (item 753). Off by default: a
|
|
70
|
+
* caller reading its stream right after a write sees it.
|
|
71
|
+
*/
|
|
72
|
+
coalesce?: boolean
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export function createOutputWriter(
|
|
@@ -71,6 +80,17 @@ export function createOutputWriter(
|
|
|
71
80
|
const minRedrawMs = opts.minRedrawMs ?? 100
|
|
72
81
|
const forceFloorMs = opts.forceFloorMs ?? 30
|
|
73
82
|
const now = opts.now ?? Date.now
|
|
83
|
+
// The coalesced writes not yet handed over; null once settled or when
|
|
84
|
+
// not coalescing.
|
|
85
|
+
let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
|
|
86
|
+
let flushQueued = false
|
|
87
|
+
const flushHeld = (): void => {
|
|
88
|
+
flushQueued = false
|
|
89
|
+
if (held === null || held.length === 0) return
|
|
90
|
+
const text = held.join('')
|
|
91
|
+
held.length = 0
|
|
92
|
+
stream.write(text)
|
|
93
|
+
}
|
|
74
94
|
|
|
75
95
|
let current: readonly string[] | null = null
|
|
76
96
|
let shown = false
|
|
@@ -169,7 +189,15 @@ export function createOutputWriter(
|
|
|
169
189
|
enabled,
|
|
170
190
|
write(chunk) {
|
|
171
191
|
if (!enabled) {
|
|
172
|
-
|
|
192
|
+
if (held === null) {
|
|
193
|
+
stream.write(chunk)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
held.push(chunk)
|
|
197
|
+
if (!flushQueued) {
|
|
198
|
+
flushQueued = true
|
|
199
|
+
setImmediate(flushHeld)
|
|
200
|
+
}
|
|
173
201
|
return
|
|
174
202
|
}
|
|
175
203
|
if (shown) {
|
|
@@ -187,6 +215,10 @@ export function createOutputWriter(
|
|
|
187
215
|
setRegion(lines, o = {}) {
|
|
188
216
|
set(lines, o)
|
|
189
217
|
},
|
|
218
|
+
settle() {
|
|
219
|
+
flushHeld()
|
|
220
|
+
held = null
|
|
221
|
+
},
|
|
190
222
|
clearStatus() {
|
|
191
223
|
if (!enabled || dead) return
|
|
192
224
|
dead = true
|