@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
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
// waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
|
|
44
44
|
// lock naming OUR pid is stale unless this process placed that entry and
|
|
45
45
|
// has not left it (`placed`) — and on Linux the entry also carries the holder's start
|
|
46
|
-
// time, so a pid another process now wears is stale too
|
|
46
|
+
// time, so a pid another process now wears is stale too, and so is a
|
|
47
|
+
// holder procfs shows as a zombie (killed, not yet reaped). Where the lock
|
|
47
48
|
// cannot be made or read for any reason but "held" (a temp directory this
|
|
48
49
|
// user cannot write), the run says so once and proceeds unlocked: the
|
|
49
50
|
// lock is a courtesy between cooperating runs, and refusing to run would
|
|
@@ -65,7 +66,8 @@ import { rmdirSync, unlinkSync } from 'node:fs'
|
|
|
65
66
|
import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
|
|
66
67
|
import os from 'node:os'
|
|
67
68
|
import path from 'node:path'
|
|
68
|
-
import {
|
|
69
|
+
import { VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
|
|
70
|
+
import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, UserError, xxh3hex } from '../util/index.js'
|
|
69
71
|
|
|
70
72
|
/** Runs in this process currently holding the lock, per lock directory. */
|
|
71
73
|
const heldHere = new Map<string, number>()
|
|
@@ -183,7 +185,7 @@ function ownLockRoot(lockDir: string): void {
|
|
|
183
185
|
|
|
184
186
|
interface Holder {
|
|
185
187
|
pid: number
|
|
186
|
-
/** The holder's start time as `
|
|
188
|
+
/** The holder's start time as `procStat` read it; null where the lock names none. */
|
|
187
189
|
start: string | null
|
|
188
190
|
/** The entry in the lock directory that names it. */
|
|
189
191
|
entry: string
|
|
@@ -223,17 +225,18 @@ async function holder(lockDir: string): Promise<Holder | 'free' | null> {
|
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* off Linux, where the answer costs a `ps`
|
|
229
|
-
* mounted for another pid namespace, and for a pid
|
|
228
|
+
* `/proc/<pid>/stat` from field 3 on: the state first, and at [19] field
|
|
229
|
+
* 22, the start time in clock ticks since boot, where two processes that
|
|
230
|
+
* wore one pid differ. Null off Linux, where the answer costs a `ps`
|
|
231
|
+
* spawn, under a procfs mounted for another pid namespace, and for a pid
|
|
232
|
+
* procfs does not show.
|
|
230
233
|
*/
|
|
231
|
-
function
|
|
234
|
+
function procStat(pid: number | 'self'): string[] | null {
|
|
232
235
|
if (!procfsIsOwn()) return null
|
|
233
236
|
try {
|
|
234
237
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
235
238
|
// Fields after the LAST ')' start at field 3; comm may hold spaces.
|
|
236
|
-
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
239
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
237
240
|
} catch {
|
|
238
241
|
return null
|
|
239
242
|
}
|
|
@@ -244,21 +247,24 @@ let ownId: string | undefined
|
|
|
244
247
|
/** Tells this process's takings of a lock apart, and their staging names. */
|
|
245
248
|
let takings = 0
|
|
246
249
|
function nextEntry(): string {
|
|
247
|
-
ownId ??= `h-${process.pid}-${
|
|
250
|
+
ownId ??= `h-${process.pid}-${procStat('self')?.[19] ?? 'x'}`
|
|
248
251
|
return `${ownId}-${++takings}`
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
/**
|
|
252
|
-
* Is the lock's holder a live run of another process?
|
|
253
|
-
* holder
|
|
254
|
-
*
|
|
255
|
+
* Is the lock's holder a live run of another process? Read on every poll:
|
|
256
|
+
* a holder killed whose parent does not reap it (a container's pid 1 that
|
|
257
|
+
* never waits) is a zombie, which signal 0 still reaches and whose start
|
|
258
|
+
* time still matches, and a wait that trusted either waited for it until
|
|
259
|
+
* the reaper came, or for good.
|
|
255
260
|
*/
|
|
256
|
-
function holderLive(h: Holder
|
|
261
|
+
function holderLive(h: Holder): boolean {
|
|
257
262
|
if (h.pid === process.pid) return placed.has(h.entry)
|
|
258
263
|
if (!alive(h.pid)) return false
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
const stat = procStat(h.pid)
|
|
265
|
+
if (stat === null) return true
|
|
266
|
+
if (stat[0] === 'Z' || stat[0] === 'X') return false
|
|
267
|
+
return h.start === null || stat[19] === h.start
|
|
262
268
|
}
|
|
263
269
|
|
|
264
270
|
function alive(pid: number): boolean {
|
|
@@ -324,9 +330,16 @@ export async function acquireRunLock(
|
|
|
324
330
|
opts: RunLockOptions,
|
|
325
331
|
): Promise<() => Promise<void>> {
|
|
326
332
|
const lockDir = runLockPath(workspaceRoot, opts.dir)
|
|
333
|
+
// A task's own process (`vx cache prune` in a task) would wait for the
|
|
334
|
+
// run that started it, which holds the lock until that task ends.
|
|
335
|
+
const outer = process.env[VX_RUN_WORKSPACE_ENV]
|
|
336
|
+
if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
|
|
337
|
+
throw new UserError(
|
|
338
|
+
`this vx runs inside task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} of a vx run on this workspace, which holds its run lock until it ends — run it before or after that run, not from one of its tasks`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
327
341
|
const started = Date.now()
|
|
328
342
|
let said = false
|
|
329
|
-
let lastLive: Holder | undefined
|
|
330
343
|
const release = async (): Promise<void> => {
|
|
331
344
|
const left = (heldHere.get(lockDir) ?? 1) - 1
|
|
332
345
|
if (left > 0) {
|
|
@@ -377,7 +390,7 @@ export async function acquireRunLock(
|
|
|
377
390
|
await rm(lockDir, { recursive: true, force: true })
|
|
378
391
|
continue
|
|
379
392
|
}
|
|
380
|
-
} else if (!holderLive(h
|
|
393
|
+
} else if (!holderLive(h)) {
|
|
381
394
|
if (h.entry === LEGACY_PID) await rm(lockDir, { recursive: true, force: true })
|
|
382
395
|
else await leave(lockDir, h.entry)
|
|
383
396
|
continue
|
|
@@ -391,12 +404,9 @@ export async function acquireRunLock(
|
|
|
391
404
|
)
|
|
392
405
|
return async () => {}
|
|
393
406
|
}
|
|
394
|
-
if (h !== null) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
said = true
|
|
398
|
-
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
399
|
-
}
|
|
407
|
+
if (h !== null && !said && Date.now() - started >= SAY_AFTER_MS) {
|
|
408
|
+
said = true
|
|
409
|
+
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
400
410
|
}
|
|
401
411
|
await Bun.sleep(POLL_MS)
|
|
402
412
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -631,6 +631,7 @@ async function runOnBus(
|
|
|
631
631
|
stopRun.abort(signal)
|
|
632
632
|
},
|
|
633
633
|
done: runLeft,
|
|
634
|
+
handsTerminal: holders.size > 0,
|
|
634
635
|
// The grace, then one flush and each teardown at their own bound, and
|
|
635
636
|
// slack for the summary and the cache close.
|
|
636
637
|
boundMs:
|
|
@@ -1762,7 +1763,7 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
|
1762
1763
|
const out: FlakyCandidate[] = []
|
|
1763
1764
|
for (const o of outcomes) {
|
|
1764
1765
|
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
1765
|
-
// "Same inputs,
|
|
1766
|
+
// "Same inputs, failed after passing" is a claim only a task with declared
|
|
1766
1767
|
// inputs can make: a task with no `cache` block keys on its config alone
|
|
1767
1768
|
// and runs every time, so one bad network day would read as a flake for
|
|
1768
1769
|
// thirty days. Groups do no work.
|
|
@@ -326,8 +326,11 @@ export interface WithheldLink {
|
|
|
326
326
|
* task is granted and what it is not.
|
|
327
327
|
*
|
|
328
328
|
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
329
|
-
* a package manager writes
|
|
330
|
-
*
|
|
329
|
+
* a package manager writes — and then the same in the `node_modules` of
|
|
330
|
+
* each granted target, so a dependency's own workspace dependencies are
|
|
331
|
+
* reached as the runtime reaches them. A target inside the granted
|
|
332
|
+
* `node_modules` or inside the package holding the link is dropped; what
|
|
333
|
+
* is left is a sibling project's real path.
|
|
331
334
|
*
|
|
332
335
|
* A link to the task's OWN project, or to a directory holding it, is
|
|
333
336
|
* dropped too: npm and Yarn classic link every workspace package at the
|
|
@@ -371,20 +374,39 @@ async function linkedDeps(
|
|
|
371
374
|
}
|
|
372
375
|
return found
|
|
373
376
|
}
|
|
377
|
+
const keyedDirs =
|
|
378
|
+
keyed === undefined ? undefined : new Set(await Promise.all([...keyed].map((d) => realpath(d))))
|
|
379
|
+
const grants = (target: string): boolean =>
|
|
380
|
+
keyedDirs === undefined || !atOrUnder(target, root) || keyedDirs.has(target)
|
|
374
381
|
// Merged in `dirs` order, so the link a hint names is the project's own
|
|
375
|
-
// when both have one.
|
|
382
|
+
// when both have one. A granted package is scanned in turn: pnpm and
|
|
383
|
+
// Bun link a dependency's own workspace dependencies only under ITS
|
|
384
|
+
// `node_modules`, so `app -> ui -> core` left core hidden and the import
|
|
385
|
+
// of ui failed with ENOENT, unreported.
|
|
376
386
|
const targets = new Map<string, { link: string; name: string }>()
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
387
|
+
let frontier: Array<[nodeModules: string, owner: string | undefined]> = dirs.map((d) => [
|
|
388
|
+
d,
|
|
389
|
+
undefined,
|
|
390
|
+
])
|
|
391
|
+
while (frontier.length > 0) {
|
|
392
|
+
const next: typeof frontier = []
|
|
393
|
+
const scans = await Promise.all(frontier.map(([d]) => scan(d, '')))
|
|
394
|
+
for (const [i, found] of scans.entries()) {
|
|
395
|
+
const owner = frontier[i]![1]
|
|
396
|
+
for (const [target, link, name] of found) {
|
|
397
|
+
if (targets.has(target) || (owner !== undefined && atOrUnder(target, owner))) continue
|
|
398
|
+
targets.set(target, { link, name })
|
|
399
|
+
if (grants(target)) {
|
|
400
|
+
next.push([path.join(target, 'node_modules'), target])
|
|
401
|
+
}
|
|
402
|
+
}
|
|
380
403
|
}
|
|
404
|
+
frontier = next
|
|
381
405
|
}
|
|
382
|
-
if (keyed === undefined) return { granted: [...targets.keys()], withheld: [] }
|
|
383
|
-
const keyedDirs = new Set(await Promise.all([...keyed].map((d) => realpath(d))))
|
|
384
406
|
const granted: string[] = []
|
|
385
407
|
const withheld: WithheldLink[] = []
|
|
386
408
|
for (const [target, { link, name }] of targets) {
|
|
387
|
-
if (
|
|
409
|
+
if (grants(target)) granted.push(target)
|
|
388
410
|
else
|
|
389
411
|
withheld.push({
|
|
390
412
|
dir: target,
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// — the scheduler dispatches nothing more, and the signal is forwarded to
|
|
3
3
|
// everything live, SIGKILLed after a bounded grace — then waits, bounded,
|
|
4
4
|
// for run() to leave its own end-of-run path (telemetry flush, each
|
|
5
|
-
// plugin's teardown, the cache close), and
|
|
6
|
-
// second signal
|
|
5
|
+
// plugin's teardown, the cache close), and dies of the signal (130/143 to
|
|
6
|
+
// its parent). A second signal does so at once. Without the forward, a
|
|
7
7
|
// programmatic signal to the vx process alone (CI cancellation,
|
|
8
8
|
// `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
|
|
9
9
|
// via process-group propagation. Without the escalation (added
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
|
|
17
|
+
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
19
|
import type { Logger } from './logger.js'
|
|
20
20
|
|
|
@@ -111,6 +111,13 @@ export function forwardSignals(args: {
|
|
|
111
111
|
done: Promise<void>
|
|
112
112
|
/** How long a signal waits for `done` before it exits anyway. */
|
|
113
113
|
boundMs: number
|
|
114
|
+
/**
|
|
115
|
+
* A task may be handed the terminal (`exec.interactive` on a TTY) and may
|
|
116
|
+
* leave it raw or without echo. Bun puts back the terminal it started
|
|
117
|
+
* with when it exits, not when a signal kills it, so this run exits with
|
|
118
|
+
* the signal's code rather than dying of the signal.
|
|
119
|
+
*/
|
|
120
|
+
handsTerminal?: boolean
|
|
114
121
|
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
115
122
|
liveChildren: ReadonlySet<Child>
|
|
116
123
|
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
@@ -124,6 +131,16 @@ export function forwardSignals(args: {
|
|
|
124
131
|
} catch {
|
|
125
132
|
// double-close race with the normal path; we're exiting anyway
|
|
126
133
|
}
|
|
134
|
+
// Die of the signal once the exit hooks have run: a shell running vx
|
|
135
|
+
// in a script stops it on Ctrl-C only when its child died of SIGINT,
|
|
136
|
+
// and a 130 exit ran the script's next line. The code stays the
|
|
137
|
+
// fallback should the signal not land.
|
|
138
|
+
if (args.handsTerminal !== true) {
|
|
139
|
+
process.once('exit', () => {
|
|
140
|
+
process.removeAllListeners(signal)
|
|
141
|
+
process.kill(process.pid, signal)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
127
144
|
process.exit(signalExitCode(signal))
|
|
128
145
|
}
|
|
129
146
|
let stopping: StopSignal | undefined
|
|
@@ -158,7 +175,8 @@ export function forwardSignals(args: {
|
|
|
158
175
|
if (args.enabled) {
|
|
159
176
|
process.on('SIGINT', onSigint)
|
|
160
177
|
process.on('SIGTERM', onSigterm)
|
|
161
|
-
|
|
178
|
+
// A hang-up vx was started deaf to (nohup) stays ignored.
|
|
179
|
+
if (!hangupIgnored()) process.on('SIGHUP', onSighup)
|
|
162
180
|
}
|
|
163
181
|
return {
|
|
164
182
|
remove: () => {
|
|
@@ -17,7 +17,7 @@ import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
|
|
|
17
17
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
18
18
|
import { foldedDeps } from './keyed-projects.js'
|
|
19
19
|
import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
|
|
20
|
-
import { filterUpstreamHashes, keyUpstream } from './upstream.js'
|
|
20
|
+
import { filterUpstreamHashes, keyedOutcome, keyUpstream } from './upstream.js'
|
|
21
21
|
|
|
22
22
|
export interface DeriveStableKeysArgs {
|
|
23
23
|
nodes: Map<string, TaskNode>
|
|
@@ -382,9 +382,7 @@ function foldedBy(
|
|
|
382
382
|
}
|
|
383
383
|
|
|
384
384
|
/**
|
|
385
|
-
* Synthetic upstream outcomes for key derivation
|
|
386
|
-
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
387
|
-
* TaskOutcome is irrelevant here, so we cast a minimal shape.
|
|
385
|
+
* Synthetic upstream outcomes for key derivation (`keyedOutcome`).
|
|
388
386
|
*/
|
|
389
387
|
function synthUpstream(
|
|
390
388
|
node: TaskNode,
|
|
@@ -395,13 +393,7 @@ function synthUpstream(
|
|
|
395
393
|
for (const dep of node.deps) {
|
|
396
394
|
const depNode = nodes.get(dep)
|
|
397
395
|
if (!depNode) continue
|
|
398
|
-
out.push(
|
|
399
|
-
node: depNode,
|
|
400
|
-
status: 'success',
|
|
401
|
-
exitCode: 0,
|
|
402
|
-
durationMs: 0,
|
|
403
|
-
hash: keyById.get(dep),
|
|
404
|
-
} as TaskOutcome)
|
|
396
|
+
out.push(keyedOutcome(depNode, keyById.get(dep)))
|
|
405
397
|
}
|
|
406
398
|
return keyUpstream(node, out)
|
|
407
399
|
}
|
|
@@ -264,7 +264,7 @@ export function formatSummarySection(
|
|
|
264
264
|
let spread = ''
|
|
265
265
|
if (stats.spread !== null && stats.spread.count > 0) {
|
|
266
266
|
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
267
|
-
spread = ` ${dim(`\u00b7 max ${
|
|
267
|
+
spread = ` ${dim(`\u00b7 max ${formatElapsed(maxMs)} \u00b7 avg ${formatElapsed(sumMs / count)} \u00b7 min ${formatElapsed(minMs)}`)}`
|
|
268
268
|
}
|
|
269
269
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
270
270
|
// grouped with time under a blank line below the meters.
|
|
@@ -280,15 +280,15 @@ export function formatSummarySection(
|
|
|
280
280
|
// (2026-09-15), so the line says it is a sum.
|
|
281
281
|
if (stats.held !== undefined && stats.held.count > 0) {
|
|
282
282
|
info.push(
|
|
283
|
-
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${
|
|
283
|
+
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
|
|
284
284
|
)
|
|
285
285
|
}
|
|
286
|
-
lines.push('', row('info', join(info)), row('time', `${
|
|
286
|
+
lines.push('', row('info', join(info)), row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
287
287
|
// The run in one line, last, where the eye lands: what a Turbo user
|
|
288
288
|
// reads first in its own summary (tasks, cached, time), in vx's words.
|
|
289
289
|
if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
|
|
290
290
|
} else {
|
|
291
|
-
lines.push('', row('time', `${
|
|
291
|
+
lines.push('', row('time', `${formatElapsed(totalMs)}${spread}`))
|
|
292
292
|
}
|
|
293
293
|
return lines
|
|
294
294
|
}
|
|
@@ -320,7 +320,7 @@ function resultLine(
|
|
|
320
320
|
: `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
|
|
321
321
|
)
|
|
322
322
|
if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
323
|
-
parts.push(
|
|
323
|
+
parts.push(formatElapsed(totalMs))
|
|
324
324
|
return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
|
|
325
325
|
}
|
|
326
326
|
|
|
@@ -426,7 +426,3 @@ export function neverStarted(
|
|
|
426
426
|
): boolean {
|
|
427
427
|
return o.status === 'aborted' && o.wallclockStartNs === undefined
|
|
428
428
|
}
|
|
429
|
-
|
|
430
|
-
export function formatDuration(ms: number): string {
|
|
431
|
-
return formatElapsed(ms)
|
|
432
|
-
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
WORKSPACE_OUTPUT_PREFIX,
|
|
17
17
|
} from '../cache/index.js'
|
|
18
18
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
19
|
-
import type
|
|
20
|
-
import {
|
|
19
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
20
|
+
import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../util/index.js'
|
|
21
21
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -296,6 +296,13 @@ async function resolveKeyInput(
|
|
|
296
296
|
projectFilesCache: args.hashCache.projectFiles,
|
|
297
297
|
}
|
|
298
298
|
: {}),
|
|
299
|
+
}).catch((err: unknown) => {
|
|
300
|
+
// A failed probe's message quotes its command and output, and the probe
|
|
301
|
+
// runs in vx's own environment: `echo $NPM_TOKEN` printed the token in
|
|
302
|
+
// the task's stream and `--dry`'s stderr.
|
|
303
|
+
if (!isUserError(err)) throw err
|
|
304
|
+
const secrets = secretMask([process.env, cfg.exec?.env?.define], cfg.exec?.env?.secret)
|
|
305
|
+
throw secrets === null ? err : new UserError(secrets.mask(err.message))
|
|
299
306
|
})
|
|
300
307
|
|
|
301
308
|
const upstreamPairs = filterUpstreamHashes(
|
|
@@ -338,7 +345,10 @@ async function resolveKeyInput(
|
|
|
338
345
|
fileHashes,
|
|
339
346
|
)
|
|
340
347
|
|
|
341
|
-
|
|
348
|
+
// Args after `--` reach a requested command; the default build (a keyed
|
|
349
|
+
// group) runs none, and folded, they moved every key that folds its own.
|
|
350
|
+
const effectiveForwardArgs =
|
|
351
|
+
args.node.requested && !isGroupTask(args.node) ? (args.forwardArgs ?? []) : []
|
|
342
352
|
|
|
343
353
|
const input: CacheKeyInput = {
|
|
344
354
|
taskId: args.node.id,
|
|
@@ -28,6 +28,17 @@ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcom
|
|
|
28
28
|
return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** A dependency as a key reads it: only `node` and `hash` matter. */
|
|
32
|
+
export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
33
|
+
return {
|
|
34
|
+
node,
|
|
35
|
+
status: 'success',
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
durationMs: 0,
|
|
38
|
+
...(hash !== undefined ? { hash } : {}),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
32
43
|
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
33
44
|
const order = node.orderOnly
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Did this process start with SIGHUP ignored? `nohup` and a supervisor
|
|
2
|
+
// that wants a job to outlive its terminal say so through the inherited
|
|
3
|
+
// disposition, and a `process.on('SIGHUP')` replaces it: vx under nohup
|
|
4
|
+
// stopped its run and exited 129 when the terminal closed. The POSIX idiom
|
|
5
|
+
// is to ask before installing; Node has no API for it, so Linux reads
|
|
6
|
+
// `/proc/self/status` (self is this process in any pid namespace's procfs)
|
|
7
|
+
// and macOS asks `sigaction` itself. Read once, before the first handler.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dlopen, FFIType, ptr } from 'bun:ffi'
|
|
11
|
+
|
|
12
|
+
let ignored: boolean | undefined
|
|
13
|
+
|
|
14
|
+
export function hangupIgnored(): boolean {
|
|
15
|
+
ignored ??= read()
|
|
16
|
+
return ignored
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function read(): boolean {
|
|
20
|
+
try {
|
|
21
|
+
if (process.platform === 'linux') {
|
|
22
|
+
const mask = /^SigIgn:\s*([0-9a-f]+)$/m.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
23
|
+
return mask !== null && (BigInt(`0x${mask[1]}`) & 1n) === 1n
|
|
24
|
+
}
|
|
25
|
+
if (process.platform === 'darwin') return darwinIgnored()
|
|
26
|
+
} catch {}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function darwinIgnored(): boolean {
|
|
31
|
+
// Opened only here: bun:ffi compiles a trampoline per call site, ~1 ms.
|
|
32
|
+
const lib = dlopen('/usr/lib/libSystem.B.dylib', {
|
|
33
|
+
sigaction: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
// `struct sigaction` opens with the handler; SIG_IGN is 1. SIGHUP is 1.
|
|
37
|
+
const old = new BigUint64Array(4)
|
|
38
|
+
return lib.symbols.sigaction(1, null, ptr(old)) === 0 && old[0] === 1n
|
|
39
|
+
} finally {
|
|
40
|
+
lib.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ export {
|
|
|
43
43
|
relPosix,
|
|
44
44
|
splitNegations,
|
|
45
45
|
staticPrefix,
|
|
46
|
+
stripTrailingSlash,
|
|
46
47
|
anyTaskGlob,
|
|
47
48
|
isInstalledPath,
|
|
48
49
|
taskGlob,
|
|
@@ -72,6 +73,7 @@ export { CORE_VERBS, MOVED_VERBS } from './verbs.js'
|
|
|
72
73
|
export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
|
|
73
74
|
export { executablePath, shellArgv, taskShell } from './which.js'
|
|
74
75
|
export { procfsIsOwn } from './procfs.js'
|
|
76
|
+
export { hangupIgnored } from './hangup.js'
|
|
75
77
|
export { realPath } from './real-path.js'
|
|
76
78
|
export {
|
|
77
79
|
maskedCommand,
|
package/src/util/paths.ts
CHANGED