@vzn/vx 0.0.639 → 0.0.641
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/caching.md +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/src/workspace/project-loader.ts +5 -4
package/docs/security.md
CHANGED
|
@@ -45,9 +45,10 @@ vulnerability), never in a public issue.
|
|
|
45
45
|
choosing is trusted as a writer, as it is in every action cache. Use a
|
|
46
46
|
store only your builds write to, or `turboCache()`'s signature key,
|
|
47
47
|
which refuses an artifact a keyholder did not sign. A job you do not
|
|
48
|
-
trust (a fork's pull request) reads at most: `--cache-policy=local:rw,remote:r
|
|
49
|
-
or
|
|
50
|
-
|
|
48
|
+
trust (a fork's pull request) reads at most: `--cache-policy=local:rw,remote:r`
|
|
49
|
+
or `cache.policy`. Core's policy alone decides; a cache plugin reads no
|
|
50
|
+
tool's own switch (`TURBO_CACHE`, `NX_SKIP_REMOTE_CACHE`) and cannot
|
|
51
|
+
narrow it.
|
|
51
52
|
|
|
52
53
|
## Cache poisoning
|
|
53
54
|
|
|
@@ -217,7 +218,7 @@ executor's error, telemetry,
|
|
|
217
218
|
`vx show` (L-26). Values in the run history are digests under a per-store
|
|
218
219
|
salt, and what follows `--` on the command line reaches telemetry as a
|
|
219
220
|
count, not a quote, a secret before it masked (L-44). A remote executor (`@vzn/vx-reapi`) receives a
|
|
220
|
-
task's `exec.env.define` values and the `
|
|
221
|
+
task's `exec.env.define` values and the `inputs.env` values its
|
|
221
222
|
local child gets, never `passThrough`; those values sit unmasked in the
|
|
222
223
|
action's Command, which the remote stores in its CAS, as Bazel's
|
|
223
224
|
`--action_env` does, so a secret a remote task needs is trusted to that
|
package/launcher.sh
CHANGED
|
@@ -35,8 +35,15 @@ scope=${scope##*/}
|
|
|
35
35
|
# directory up to the git top-level (handoff.ts workspaceFile, the same
|
|
36
36
|
# rule). One hop (VX_HANDED_OFF, which also names this launcher), never to
|
|
37
37
|
# this package itself (`-ef`: same inode, so a pnpm symlink counts as
|
|
38
|
-
# itself). Shell tests and builtins only, no fork per level.
|
|
39
|
-
|
|
38
|
+
# itself). Shell tests and builtins only, no fork per level. `vx upgrade -g`
|
|
39
|
+
# moves only this global vx, so it never hands off (owner, 2026-10-16).
|
|
40
|
+
stay=
|
|
41
|
+
if [ "${1-}" = upgrade ]; then
|
|
42
|
+
for a in "$@"; do
|
|
43
|
+
case $a in -g | --global) stay=1 ;; esac
|
|
44
|
+
done
|
|
45
|
+
fi
|
|
46
|
+
if [ -z "${VX_HANDED_OFF-}" ] && [ -z "$stay" ]; then
|
|
40
47
|
d=$PWD
|
|
41
48
|
while :; do
|
|
42
49
|
root=
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.641",
|
|
4
4
|
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.641",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.641",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.641",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.641"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/schemas/show.json
CHANGED
|
@@ -90,24 +90,24 @@
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
|
+
"inputs": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"additionalProperties": false,
|
|
96
|
+
"required": ["files"],
|
|
97
|
+
"properties": {
|
|
98
|
+
"files": { "$ref": "#/$defs/stringList" },
|
|
99
|
+
"workspaceFiles": { "$ref": "#/$defs/stringList" },
|
|
100
|
+
"env": { "$ref": "#/$defs/stringList" },
|
|
101
|
+
"tasks": { "$ref": "#/$defs/stringList" },
|
|
102
|
+
"runtime": { "$ref": "#/$defs/stringList" },
|
|
103
|
+
"workspaceRuntime": { "$ref": "#/$defs/stringList" }
|
|
104
|
+
}
|
|
105
|
+
},
|
|
93
106
|
"cache": {
|
|
94
107
|
"type": "object",
|
|
95
108
|
"additionalProperties": false,
|
|
96
|
-
"required": ["
|
|
109
|
+
"required": ["outputs"],
|
|
97
110
|
"properties": {
|
|
98
|
-
"inputs": {
|
|
99
|
-
"type": "object",
|
|
100
|
-
"additionalProperties": false,
|
|
101
|
-
"required": ["files"],
|
|
102
|
-
"properties": {
|
|
103
|
-
"files": { "$ref": "#/$defs/stringList" },
|
|
104
|
-
"workspaceFiles": { "$ref": "#/$defs/stringList" },
|
|
105
|
-
"env": { "$ref": "#/$defs/stringList" },
|
|
106
|
-
"tasks": { "$ref": "#/$defs/stringList" },
|
|
107
|
-
"runtime": { "$ref": "#/$defs/stringList" },
|
|
108
|
-
"workspaceRuntime": { "$ref": "#/$defs/stringList" }
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
111
|
"outputs": {
|
|
112
112
|
"type": "object",
|
|
113
113
|
"additionalProperties": false,
|
package/skills/vx/SKILL.md
CHANGED
|
@@ -90,9 +90,10 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
|
|
|
90
90
|
- Never delete or edit the cache by hand; vx repairs it itself.
|
|
91
91
|
- A sandboxed task that fails on a denied path: grant that one path in
|
|
92
92
|
`exec.sandbox.allow`. Never remove `sandbox` or widen it further.
|
|
93
|
-
- A
|
|
94
|
-
does not re-run a task, the file is not one of its inputs. Fix
|
|
95
|
-
config, do not force.
|
|
93
|
+
- A task declares its inputs (`inputs.files`), cached or not; if a
|
|
94
|
+
change does not re-run a task, the file is not one of its inputs. Fix
|
|
95
|
+
the config, do not force. A task with no `inputs` has no key, so every
|
|
96
|
+
cached task behind it misses: give it `inputs`.
|
|
96
97
|
- Every JSON shape is a JSON Schema in `node_modules/@vzn/vx/schemas/`.
|
|
97
98
|
- Offline reference: `vx docs <words> --format json` searches the CLI, config
|
|
98
99
|
and cache pages that ship with vx; the sections holding every word print
|
package/src/bin.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun --no-env-file --no-install
|
|
2
2
|
// Wrapped in an explicit async main so `bun build --compile` accepts
|
|
3
3
|
// the file. The compile target doesn't allow top-level await.
|
|
4
|
-
import { handOff, localVx, vxPath } from './handoff.js'
|
|
4
|
+
import { handOff, localVx, upgradesOnlyItself, vxPath } from './handoff.js'
|
|
5
5
|
|
|
6
6
|
let settled = false
|
|
7
7
|
|
|
@@ -10,11 +10,13 @@ async function main(): Promise<void> {
|
|
|
10
10
|
try {
|
|
11
11
|
// Inside the try: `process.cwd()` throws EMFILE when out of descriptors,
|
|
12
12
|
// and that is a one-line UserError below, not a stack.
|
|
13
|
-
const local =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const local = upgradesOnlyItself(argv)
|
|
14
|
+
? null
|
|
15
|
+
: await localVx(
|
|
16
|
+
process.cwd(),
|
|
17
|
+
process.execPath,
|
|
18
|
+
Bun.main.startsWith('/$bunfs') || (process.argv[1] ?? '').startsWith('/$bunfs'),
|
|
19
|
+
)
|
|
18
20
|
if (local !== null) await handOff(local, argv)
|
|
19
21
|
// `vx --version` needs one leaf module; the dispatcher and the util
|
|
20
22
|
// barrel it pulled in were 20 ms of its 28 (2026-10-01).
|
package/src/cache/archive.ts
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
// holding more than a chunk. `Bun.Archive` used to do all three and
|
|
21
21
|
// needed the whole tar in memory on every side — restoring a 150 MiB
|
|
22
22
|
// artifact peaked at 3.2× its size, saving one at 4.7× (measured
|
|
23
|
-
// 2026-09-03). Every regular entry is written
|
|
24
|
-
// `.vx
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// needed, which
|
|
23
|
+
// 2026-09-03). Every regular entry is written to the workspace's staging
|
|
24
|
+
// directory (`.vx/tmp/<pid>-0/`, vx's own, never an output folder) and
|
|
25
|
+
// renamed over its target only once the WHOLE archive has been read and
|
|
26
|
+
// every name proven safe, so a poisoned entry anywhere — even the last
|
|
27
|
+
// one — leaves nothing behind but the empty directories it needed, which
|
|
28
|
+
// are pruned too, and a killed restore leaves its temps where the next
|
|
29
|
+
// run sweeps them (`sweepStaging`).
|
|
28
30
|
//
|
|
29
31
|
// What stays here is the part no tar reader can decide for us: WHERE an
|
|
30
32
|
// entry may land on disk. Every name is validated and every destination
|
|
@@ -39,14 +41,33 @@
|
|
|
39
41
|
|
|
40
42
|
import {
|
|
41
43
|
chmodSync,
|
|
44
|
+
chownSync,
|
|
45
|
+
closeSync,
|
|
46
|
+
constants,
|
|
47
|
+
existsSync,
|
|
48
|
+
fchmodSync,
|
|
49
|
+
fstatSync,
|
|
50
|
+
futimesSync,
|
|
42
51
|
lstatSync,
|
|
52
|
+
openSync,
|
|
43
53
|
readFileSync,
|
|
44
54
|
realpathSync,
|
|
45
|
-
renameSync,
|
|
46
55
|
statSync,
|
|
47
56
|
utimesSync,
|
|
57
|
+
writeFileSync,
|
|
48
58
|
} from 'node:fs'
|
|
49
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
copyFile,
|
|
61
|
+
lstat,
|
|
62
|
+
mkdir,
|
|
63
|
+
readlink,
|
|
64
|
+
realpath,
|
|
65
|
+
rename,
|
|
66
|
+
rmdir,
|
|
67
|
+
stat,
|
|
68
|
+
unlink,
|
|
69
|
+
writeFile,
|
|
70
|
+
} from 'node:fs/promises'
|
|
50
71
|
import path from 'node:path'
|
|
51
72
|
import { UserError } from '../util/index.js'
|
|
52
73
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
@@ -595,12 +616,15 @@ export async function extractArtifactStream(
|
|
|
595
616
|
destDir: string,
|
|
596
617
|
workspaceDest: string | undefined,
|
|
597
618
|
verify?: (provided: ReadonlySet<string>) => void,
|
|
619
|
+
accept?: (name: string) => boolean,
|
|
620
|
+
/** The workspace root whose `.vx/tmp/` stages the entries; absent, each stages beside its target. */
|
|
621
|
+
stageRoot?: string,
|
|
598
622
|
): Promise<Set<string>> {
|
|
599
623
|
// No `mkdir -p` of the destination here: `stage` creates each entry's
|
|
600
624
|
// directory chain, the destination included, and the cache restores
|
|
601
625
|
// into a project directory that exists. The eager one cost every
|
|
602
626
|
// restore a thread-pool round trip for an EEXIST and a stat (item 627).
|
|
603
|
-
const x = new Extractor(destDir, workspaceDest)
|
|
627
|
+
const x = new Extractor(destDir, workspaceDest, stageRoot)
|
|
604
628
|
const provided = new Set<string>()
|
|
605
629
|
const headerMtime = new Map<string, number>()
|
|
606
630
|
let meta: MetaFile['files'] = {}
|
|
@@ -615,6 +639,13 @@ export async function extractArtifactStream(
|
|
|
615
639
|
continue
|
|
616
640
|
}
|
|
617
641
|
assertSafeName(e.name)
|
|
642
|
+
// Before anything is staged: an entry the task never declared is not
|
|
643
|
+
// this task's save, and none of the archive lands.
|
|
644
|
+
if (accept !== undefined && !accept(e.name)) {
|
|
645
|
+
throw new ArchiveSecurityError(
|
|
646
|
+
`archive entry is not one of the task's declared outputs: ${e.name}`,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
618
649
|
const dest = x.destFor(e.name)
|
|
619
650
|
if (dest === null || dest.rel.length === 0) {
|
|
620
651
|
await drain(body)
|
|
@@ -644,6 +675,9 @@ export async function extractArtifactStream(
|
|
|
644
675
|
return provided
|
|
645
676
|
}
|
|
646
677
|
|
|
678
|
+
/** BSD gives every new file its directory's group, setgid or not. */
|
|
679
|
+
const INHERITS_GROUP = process.platform === 'darwin'
|
|
680
|
+
|
|
647
681
|
/**
|
|
648
682
|
* Entries up to this size are buffered and written without waiting, so a
|
|
649
683
|
* thousand tiny outputs restore in parallel; larger ones stream chunk by
|
|
@@ -674,8 +708,32 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
|
674
708
|
const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
|
|
675
709
|
utf8.decode(await bytesOf(body))
|
|
676
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Bring a target that already held the entry's bytes to its mode and
|
|
713
|
+
* mtime, through a descriptor opened without following a link: one
|
|
714
|
+
* planted since the compare is refused, never written through.
|
|
715
|
+
*/
|
|
716
|
+
function keep(s: Staged, ino: number, mode: number, mtimeMs: number | undefined): void {
|
|
717
|
+
const fd = openSync(s.target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
718
|
+
try {
|
|
719
|
+
const st = fstatSync(fd)
|
|
720
|
+
if (st.ino !== ino || st.nlink !== 1) {
|
|
721
|
+
throw new Error(`${s.target} changed while it was restored`)
|
|
722
|
+
}
|
|
723
|
+
if ((st.mode & 0o7777) !== (mode & 0o777)) fchmodSync(fd, mode & 0o777)
|
|
724
|
+
if (mtimeMs !== undefined && Math.floor(st.mtimeMs) !== mtimeMs) {
|
|
725
|
+
const t = new Date(mtimeMs)
|
|
726
|
+
futimesSync(fd, t, t)
|
|
727
|
+
}
|
|
728
|
+
} finally {
|
|
729
|
+
closeSync(fd)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
677
733
|
/** One staged entry: written at `tmp`, renamed to `target` on commit. */
|
|
678
734
|
interface Staged {
|
|
735
|
+
/** `tmp` is in the staging directory, not beside `target`. */
|
|
736
|
+
staged: boolean
|
|
679
737
|
name: string
|
|
680
738
|
tmp: string
|
|
681
739
|
target: string
|
|
@@ -683,6 +741,10 @@ interface Staged {
|
|
|
683
741
|
created: string | undefined
|
|
684
742
|
/** Written by Bun's file writer, which creates with 0664 where `writeFile` uses 0666 (before the umask). */
|
|
685
743
|
streamed: boolean
|
|
744
|
+
/** The destination it is contained in. */
|
|
745
|
+
base: string
|
|
746
|
+
/** The target already holds the entry's bytes: nothing was written, and only its mode and mtime may change. */
|
|
747
|
+
same?: number
|
|
686
748
|
}
|
|
687
749
|
|
|
688
750
|
/**
|
|
@@ -709,6 +771,7 @@ class Extractor {
|
|
|
709
771
|
constructor(
|
|
710
772
|
private readonly destDir: string,
|
|
711
773
|
private readonly workspaceDest: string | undefined,
|
|
774
|
+
private readonly stageRoot: string | undefined,
|
|
712
775
|
) {}
|
|
713
776
|
|
|
714
777
|
/** Each namespace anchors at its own destination dir. */
|
|
@@ -865,7 +928,33 @@ class Extractor {
|
|
|
865
928
|
}
|
|
866
929
|
this.ensuredDirs.add(dir)
|
|
867
930
|
}
|
|
868
|
-
//
|
|
931
|
+
// A target that already holds these bytes stays: renaming over a live
|
|
932
|
+
// file costs ~0.4 ms on ext4 (auto_da_alloc), and a hit that changed
|
|
933
|
+
// one of 2,000 outputs paid it 2,000 times. Bytes, not a stat stamp: a
|
|
934
|
+
// reproducible build gives every output one mtime (item 886).
|
|
935
|
+
if (body instanceof Uint8Array && created === undefined) {
|
|
936
|
+
const st = lstatSync(target, { throwIfNoEntry: false })
|
|
937
|
+
if (
|
|
938
|
+
st !== undefined &&
|
|
939
|
+
st.isFile() &&
|
|
940
|
+
st.nlink === 1 &&
|
|
941
|
+
st.size === body.byteLength &&
|
|
942
|
+
readFileSync(target).equals(body)
|
|
943
|
+
) {
|
|
944
|
+
this.staged.push({
|
|
945
|
+
staged: false,
|
|
946
|
+
name,
|
|
947
|
+
tmp: '',
|
|
948
|
+
target,
|
|
949
|
+
created,
|
|
950
|
+
streamed: false,
|
|
951
|
+
base,
|
|
952
|
+
same: st.ino,
|
|
953
|
+
})
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// Write apart from the target and RENAME into place. rename(2) replaces
|
|
869
958
|
// the destination's directory ENTRY without following it, which is
|
|
870
959
|
// what makes this both link-safe and concurrency-safe:
|
|
871
960
|
//
|
|
@@ -882,15 +971,15 @@ class Extractor {
|
|
|
882
971
|
//
|
|
883
972
|
// A directory at the target makes the rename fail — the same
|
|
884
973
|
// fail-closed outcome the plain write had.
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
974
|
+
// Staged under `.vx/tmp/`, so no output folder ever holds a temp: a
|
|
975
|
+
// reader or a key taken meanwhile saw `.vx-tmp-*` files beside the
|
|
976
|
+
// outputs, and a killed restore left them there (M, 2026-10-16).
|
|
977
|
+
const staged = this.stageRoot !== undefined
|
|
978
|
+
const tmp = staged
|
|
979
|
+
? path.join(await stagingDir(this.stageRoot!), (tmpSeq++).toString(36))
|
|
980
|
+
: siblingTemp(target)
|
|
892
981
|
const streamed = !(body instanceof Uint8Array)
|
|
893
|
-
this.staged.push({ name, tmp, target, created, streamed })
|
|
982
|
+
this.staged.push({ staged, name, tmp, target, created, streamed, base })
|
|
894
983
|
if (body instanceof Uint8Array) {
|
|
895
984
|
this.inflightBytes += body.byteLength
|
|
896
985
|
// `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
|
|
@@ -926,12 +1015,11 @@ class Extractor {
|
|
|
926
1015
|
|
|
927
1016
|
/**
|
|
928
1017
|
* Apply each entry's mode and mtime, then rename everything into place.
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* restores' round trips flowing. The chmod is skipped when the mode is
|
|
1018
|
+
* The metadata calls are synchronous: a few microseconds each on the
|
|
1019
|
+
* calling thread and ~40 µs each as thread-pool round trips, and a large
|
|
1020
|
+
* artifact has thousands of them (measured 2026-09-11 on payload's ui,
|
|
1021
|
+
* 4,069 files: 892 ms → see the archive bench). The renames go to the
|
|
1022
|
+
* pool, `RENAME_BATCH` at a time: each replaces a live file. The chmod is skipped when the mode is
|
|
935
1023
|
* the one the temp file was created with — the common case. The two
|
|
936
1024
|
* writers create with different modes (under umask 000: 0666 and 0664),
|
|
937
1025
|
* so each is stat'ed once.
|
|
@@ -940,38 +1028,107 @@ class Extractor {
|
|
|
940
1028
|
metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
|
|
941
1029
|
): Promise<void> {
|
|
942
1030
|
await this.drain()
|
|
943
|
-
const
|
|
1031
|
+
const created = new Map<boolean, { mode: number; uid: number; gid: number }>()
|
|
1032
|
+
// The group a file written in place would take, by directory: a setgid
|
|
1033
|
+
// directory's on Linux, every directory's on BSD/macOS. A file staged
|
|
1034
|
+
// elsewhere took the staging directory's.
|
|
1035
|
+
const setgid = new Map<string, number | null>()
|
|
944
1036
|
let n = 0
|
|
1037
|
+
let renames: Promise<void>[] = []
|
|
1038
|
+
const checked = new Set<string>()
|
|
945
1039
|
for (const s of this.staged) {
|
|
1040
|
+
await this.assertStillContained(s, checked)
|
|
946
1041
|
const [mode, mtimeMs] = metaFor(s.name)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1042
|
+
if (s.same !== undefined) {
|
|
1043
|
+
keep(s, s.same, mode, mtimeMs)
|
|
1044
|
+
continue
|
|
1045
|
+
}
|
|
1046
|
+
let made = created.get(s.streamed)
|
|
1047
|
+
if (made === undefined) {
|
|
1048
|
+
const st = statSync(s.tmp)
|
|
1049
|
+
made = { mode: st.mode & 0o777, uid: st.uid, gid: st.gid }
|
|
1050
|
+
created.set(s.streamed, made)
|
|
1051
|
+
}
|
|
1052
|
+
if ((mode & 0o777) !== made.mode) chmodSync(s.tmp, mode & 0o777)
|
|
1053
|
+
// A Date, not seconds: Bun reads a negative number of seconds as
|
|
1054
|
+
// "now", and a Date before 1970 as itself (Bun 1.4.2).
|
|
1055
|
+
const t = mtimeMs === undefined ? undefined : new Date(mtimeMs)
|
|
1056
|
+
if (t !== undefined) utimesSync(s.tmp, t, t)
|
|
1057
|
+
if (s.staged) {
|
|
1058
|
+
const dir = path.dirname(s.target)
|
|
1059
|
+
let gid = setgid.get(dir)
|
|
1060
|
+
if (gid === undefined) {
|
|
1061
|
+
const st = statSync(dir)
|
|
1062
|
+
gid = INHERITS_GROUP || (st.mode & 0o2000) !== 0 ? st.gid : null
|
|
1063
|
+
setgid.set(dir, gid)
|
|
1064
|
+
}
|
|
1065
|
+
if (gid !== null && gid !== made.gid) chownSync(s.tmp, made.uid, gid)
|
|
951
1066
|
}
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const t = new Date(mtimeMs)
|
|
957
|
-
utimesSync(s.tmp, t, t)
|
|
1067
|
+
renames.push(this.place(s, mode, t))
|
|
1068
|
+
if (++n % RENAME_BATCH === 0) {
|
|
1069
|
+
await settle(renames)
|
|
1070
|
+
renames = []
|
|
958
1071
|
}
|
|
959
|
-
renameSync(s.tmp, s.target)
|
|
960
|
-
if (++n % COMMIT_BATCH === 0) await new Promise<void>((r) => setImmediate(r))
|
|
961
1072
|
}
|
|
1073
|
+
await settle(renames)
|
|
962
1074
|
this.staged.length = 0
|
|
963
1075
|
}
|
|
964
1076
|
|
|
965
1077
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
1078
|
+
* Rename `s` over its target. A target on another filesystem than
|
|
1079
|
+
* `.vx/tmp/` (EXDEV) is reached through a temp beside it, for that file
|
|
1080
|
+
* only: the rename that replaces the live file stays atomic.
|
|
1081
|
+
*/
|
|
1082
|
+
private async place(s: Staged, mode: number, t: Date | undefined): Promise<void> {
|
|
1083
|
+
try {
|
|
1084
|
+
await rename(s.tmp, s.target)
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
if (!s.staged || (err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
|
|
1087
|
+
const side = siblingTemp(s.target)
|
|
1088
|
+
try {
|
|
1089
|
+
await copyFile(s.tmp, side)
|
|
1090
|
+
chmodSync(side, mode & 0o777)
|
|
1091
|
+
if (t !== undefined) utimesSync(side, t, t)
|
|
1092
|
+
await rename(side, s.target)
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
await unlink(side).catch(() => undefined)
|
|
1095
|
+
throw e
|
|
1096
|
+
}
|
|
1097
|
+
await unlink(s.tmp).catch(() => undefined)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The staging check, again at the rename: a directory on the way swapped
|
|
1103
|
+
* for a link out of the base since then would carry the rename with it.
|
|
1104
|
+
* Bun has no `renameat` against a held directory fd, so this is by path,
|
|
1105
|
+
* once per directory per commit.
|
|
1106
|
+
*/
|
|
1107
|
+
private async assertStillContained(s: Staged, checked: Set<string>): Promise<void> {
|
|
1108
|
+
const dir = path.dirname(s.target)
|
|
1109
|
+
if (checked.has(dir)) return
|
|
1110
|
+
checked.add(dir)
|
|
1111
|
+
const realBase = await this.realBaseOf(s.base)
|
|
1112
|
+
const real = await realOf(dir, true)
|
|
1113
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) {
|
|
1114
|
+
throw await linkOutError(path.resolve(s.base), dir, realBase)
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Never leave a temp behind: beside a target, the declared output globs
|
|
1120
|
+
* would sweep it into the next artifact. Directories this extraction created
|
|
968
1121
|
* are removed bottom-up while empty; one a concurrent writer has since
|
|
969
1122
|
* filled is left alone.
|
|
970
1123
|
*/
|
|
971
1124
|
async abort(): Promise<void> {
|
|
972
1125
|
await Promise.allSettled(this.inflight)
|
|
973
1126
|
this.inflight = []
|
|
974
|
-
await Promise.all(
|
|
1127
|
+
await Promise.all(
|
|
1128
|
+
this.staged.map((s) =>
|
|
1129
|
+
s.same === undefined ? unlink(s.tmp).catch(() => undefined) : undefined,
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
975
1132
|
// Newest first: a later entry's chain can sit inside an earlier one's
|
|
976
1133
|
// (`dist/` then `dist/sub/`), never the reverse.
|
|
977
1134
|
for (const s of this.staged.toReversed()) {
|
|
@@ -997,12 +1154,62 @@ class Extractor {
|
|
|
997
1154
|
}
|
|
998
1155
|
}
|
|
999
1156
|
|
|
1000
|
-
/**
|
|
1001
|
-
|
|
1157
|
+
/**
|
|
1158
|
+
* Renames in flight at once. A rename over a live file is not the few
|
|
1159
|
+
* microseconds of one into an empty directory: on ext4 replacing a file
|
|
1160
|
+
* starts writeback of the new one's data (`auto_da_alloc`), ~250 µs each,
|
|
1161
|
+
* and synchronous ones stalled the main thread. 300 one-file restores
|
|
1162
|
+
* took 25% longer than delete-then-restore (2026-10-16); on the thread
|
|
1163
|
+
* pool they tie it, and 2,000 files in one restore are faster.
|
|
1164
|
+
*/
|
|
1165
|
+
const RENAME_BATCH = 64
|
|
1166
|
+
|
|
1167
|
+
/** Wait for every rename, then throw the first failure: none is left in flight for `abort`. */
|
|
1168
|
+
async function settle(renames: readonly Promise<void>[]): Promise<void> {
|
|
1169
|
+
for (const r of await Promise.allSettled(renames)) if (r.status === 'rejected') throw r.reason
|
|
1170
|
+
}
|
|
1002
1171
|
|
|
1003
1172
|
/** Per-process counter for extract temp names; uniqueness only. */
|
|
1004
1173
|
let tmpSeq = 0
|
|
1005
1174
|
|
|
1175
|
+
/** `.vx/<STAGING>/`: where restores stage, each process in its own `<pid>-0` directory. */
|
|
1176
|
+
export const STAGING = 'tmp'
|
|
1177
|
+
|
|
1178
|
+
const stagingMade = new Set<string>()
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* This process's staging directory under `root`'s `.vx/tmp/`, made on its
|
|
1182
|
+
* first restore and again only if something removed it: one per restore
|
|
1183
|
+
* cost 300 restores 12% (2026-10-16). Every restore's renames empty it;
|
|
1184
|
+
* the next run's `sweepStaging` removes it once this process is gone.
|
|
1185
|
+
*/
|
|
1186
|
+
async function stagingDir(root: string): Promise<string> {
|
|
1187
|
+
const top = path.join(root, '.vx', STAGING)
|
|
1188
|
+
const own = path.join(top, `${process.pid.toString(36)}-0`)
|
|
1189
|
+
if (stagingMade.has(own) && existsSync(own)) return own
|
|
1190
|
+
await mkdir(own, { recursive: true })
|
|
1191
|
+
// Ignored by git whatever `.vx/.gitignore` an older init committed.
|
|
1192
|
+
try {
|
|
1193
|
+
writeFileSync(path.join(top, '.gitignore'), '*\n', { flag: 'wx' })
|
|
1194
|
+
} catch {
|
|
1195
|
+
// there already, or the restore fails on its first write anyway
|
|
1196
|
+
}
|
|
1197
|
+
stagingMade.add(own)
|
|
1198
|
+
return own
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* A temp beside `target`, with its own short name, never `<target>.suffix`:
|
|
1203
|
+
* a suffix pushed a legal name of 242–255 bytes past NAME_MAX, and the
|
|
1204
|
+
* restore of a valid artifact failed with ENAMETOOLONG (item 666).
|
|
1205
|
+
*/
|
|
1206
|
+
function siblingTemp(target: string): string {
|
|
1207
|
+
return path.join(
|
|
1208
|
+
path.dirname(target),
|
|
1209
|
+
`.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
|
|
1210
|
+
)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1006
1213
|
const WORKSPACE_PREFIX = 'workspace-outputs/'
|
|
1007
1214
|
|
|
1008
1215
|
/**
|