@vzn/vx 0.0.354 → 0.0.356
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 -77
- package/package.json +5 -5
- package/src/cache/archive.ts +3 -11
- package/src/cache/cache.ts +2 -2
- package/src/cache/git-inputs.ts +2 -1
- package/src/cache/inputs.ts +3 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +1 -1
- package/src/cli/upgrade.ts +2 -3
- package/src/config.ts +3 -2
- package/src/exec/env.ts +0 -16
- package/src/exec/kill-tree.ts +0 -24
- package/src/exec/local-executor.ts +2 -3
- package/src/exec/runner.ts +24 -22
- package/src/exec/sandbox-binds.ts +39 -15
- package/src/exec/sandbox-runtime.ts +43 -4
- package/src/exec/sandbox-violations.ts +92 -15
- package/src/orchestrator/events.ts +8 -3
- package/src/orchestrator/execute-task.ts +14 -1
- package/src/orchestrator/run-lock.ts +1 -31
- package/src/orchestrator/sandbox-request.ts +1 -1
- package/src/util/real-path.ts +2 -5
- package/src/util/which.ts +2 -7
- package/src/workspace/config-cache.ts +15 -0
- package/src/workspace/config-schema.ts +8 -1
- package/src/workspace/migrate-scripts.ts +24 -0
- package/src/workspace/migration.ts +20 -4
- package/src/workspace/package-graph.ts +21 -13
- package/src/workspace/project-loader.ts +61 -38
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<h1 align="center">vx</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
9
|
-
|
|
8
|
+
<strong>The fastest task runner for JS monorepos.</strong><br>
|
|
9
|
+
Measured against Turborepo and Nx, each in its own native config.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
@@ -18,50 +18,20 @@
|
|
|
18
18
|
|
|
19
19
|
<!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
|
|
20
20
|
|
|
21
|
-
| 1,090 packages, 3,270 tasks | vx | Turborepo
|
|
22
|
-
| -------------------------------- | ---------- |
|
|
23
|
-
| Cold build: time the runner adds | **9 s** | 1 min 35 s | 31 min |
|
|
24
|
-
| Cold build: CPU burned | **34 s** | 1 min 13 s | 114 min |
|
|
25
|
-
| Fully cached run | **476 ms** | 760 ms | 3.59 s |
|
|
26
|
-
| Overhead per package | **8 ms** | 88 ms | 1,712 ms |
|
|
21
|
+
| 1,090 packages, 3,270 tasks | vx | Turborepo | Nx |
|
|
22
|
+
| -------------------------------- | ---------- | -------------------------- | ------------------------ |
|
|
23
|
+
| Cold build: time the runner adds | **9 s** | 1 min 35 s (vx 91% faster) | 31 min (vx 99% faster) |
|
|
24
|
+
| Cold build: CPU burned | **34 s** | 1 min 13 s (vx 53% faster) | 114 min (vx 99% faster) |
|
|
25
|
+
| Fully cached run | **476 ms** | 760 ms (vx 37% faster) | 3.59 s (vx 86% faster) |
|
|
26
|
+
| Overhead per package | **8 ms** | 88 ms (vx 91% faster) | 1,712 ms (vx 99% faster) |
|
|
27
|
+
|
|
28
|
+
vx N% faster: vx takes N% less time than that tool (1 − vx ÷ theirs); N% slower: N% more (vx ÷ theirs − 1).
|
|
27
29
|
|
|
28
30
|
Time added is the wall time over the tasks' own ideal schedule (3 min 38 s).
|
|
29
31
|
Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
|
|
30
32
|
|
|
31
33
|
<!-- bench:end -->
|
|
32
34
|
|
|
33
|
-
## Tried on real repos
|
|
34
|
-
|
|
35
|
-
Public monorepos, each run with its own `turbo.json` or Nx graph against
|
|
36
|
-
the tool it ships with, same machine and tasks per row, medians.
|
|
37
|
-
|
|
38
|
-
<!-- repos:start — generated by packages/vx-bench/update-site.ts from real-repos.json; do not hand-edit -->
|
|
39
|
-
|
|
40
|
-
| Repo | Against | Cold build | Restore outputs | Nothing to do |
|
|
41
|
-
| ------------------------------------------------------------------- | ------- | --------------------- | ------------------- | ---------------------- |
|
|
42
|
-
| [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
|
|
43
|
-
| [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
|
|
44
|
-
| [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
|
|
45
|
-
| [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
|
|
46
|
-
| [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
|
|
47
|
-
| [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
|
|
48
|
-
| [unocss/unocss](https://github.com/unocss/unocss) | Turbo | 45.1 s / 47.3 s | 859 ms / **185 ms** | 770 ms / **126 ms** |
|
|
49
|
-
| [vueuse/vueuse](https://github.com/vueuse/vueuse) | Turbo | 21.4 s / 29.2 s | 263 ms / **128 ms** | 246 ms / **51–161 ms** |
|
|
50
|
-
| [t3-oss/create-t3-turbo](https://github.com/t3-oss/create-t3-turbo) | Turbo | 27.2 s / 29.6 s | 199 ms / 340 ms | 174 ms / 342 ms |
|
|
51
|
-
| [TanStack/query](https://github.com/TanStack/query) | Nx | 34.6 s / 37.1 s | 910 ms / 1.62 s | 291 ms / 1.60 s |
|
|
52
|
-
| [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
|
|
53
|
-
| [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
|
|
54
|
-
| [TanStack/router](https://github.com/TanStack/router) | Nx | 123.0 s / 138.0 s | 1.27 s / 3.82 s | 626 ms / 3.91 s |
|
|
55
|
-
| [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
|
|
56
|
-
|
|
57
|
-
vx first, theirs second; **bold** marks the other tool winning. These
|
|
58
|
-
rows ran vx without a lock (every config evaluated per run); the harness
|
|
59
|
-
now runs it from a `vx lock` snapshot (`--frozen`). How each
|
|
60
|
-
repo was run, versions, dates and why a tool wins where it does:
|
|
61
|
-
[vx on real Turbo and Nx repos](https://vznjs.github.io/vx/benchmarks/real-repos/).
|
|
62
|
-
|
|
63
|
-
<!-- repos:end -->
|
|
64
|
-
|
|
65
35
|
## Why it is faster
|
|
66
36
|
|
|
67
37
|
- **Nothing boots per task.** No daemon, no Node process per task: vx
|
|
@@ -71,46 +41,20 @@ repo was run, versions, dates and why a tool wins where it does:
|
|
|
71
41
|
- **One pass over the repo.** One git walk per run; each stat and
|
|
72
42
|
lookup happens once.
|
|
73
43
|
|
|
74
|
-
## Try it on your repo
|
|
75
|
-
|
|
76
|
-
In a Turborepo or Nx workspace:
|
|
77
|
-
|
|
78
|
-
```sh
|
|
79
|
-
npm install -D @vzn/vx @vzn/vx-migrate
|
|
80
|
-
npx vx init # writes vx.workspace.ts, below; nothing else
|
|
81
|
-
npx vx run build --all # your turbo.json or Nx targets, run by vx
|
|
82
|
-
npx vx run build --all # again: up-to-date in milliseconds
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
The one file `vx init` writes (in an Nx repo, `nx` for `turbo`):
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
import type { WorkspaceConfig } from '@vzn/vx/config'
|
|
89
|
-
import { turbo } from '@vzn/vx-migrate'
|
|
90
|
-
|
|
91
|
-
export default { plugins: [turbo()] } satisfies WorkspaceConfig
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
Nothing is rewritten. `@vzn/vx-migrate` is not on npm yet (first publish
|
|
95
|
-
pending); [`examples/turbo`](examples/turbo) runs this setup, and
|
|
96
|
-
`packages/vx/tests/try-it.unsafe.test.ts` runs these exact lines on a Turbo
|
|
97
|
-
and an Nx repo, on every commit. Which `turbo.json`, `nx.json` and `project.json` keys vx honours,
|
|
98
|
-
key by key: [Turbo and Nx config support](https://vznjs.github.io/vx/compare/turbo-nx-support/).
|
|
99
|
-
|
|
100
44
|
## Install
|
|
101
45
|
|
|
102
46
|
```sh
|
|
103
47
|
npm install -D @vzn/vx # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
|
|
104
48
|
```
|
|
105
49
|
|
|
106
|
-
The package ships a prebuilt binary for Linux and macOS, x64 and arm64
|
|
107
|
-
|
|
50
|
+
The package ships a prebuilt binary for Linux and macOS, x64 and arm64;
|
|
51
|
+
no Bun needed. The binary alone, from a
|
|
108
52
|
[release](https://github.com/vznjs/vx/releases), needs no Node either.
|
|
53
|
+
Windows: use WSL.
|
|
109
54
|
|
|
110
55
|
## Quick start
|
|
111
56
|
|
|
112
|
-
In a git repo with a `package.json` workspace
|
|
113
|
-
`nx.json` (those get the one file above):
|
|
57
|
+
In a git repo with a `package.json` workspace:
|
|
114
58
|
|
|
115
59
|
```sh
|
|
116
60
|
npx vx init # a vx.config.ts per package, from its scripts
|
|
@@ -151,13 +95,13 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
|
|
|
151
95
|
[CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
|
|
152
96
|
[playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
|
|
153
97
|
|
|
154
|
-
##
|
|
98
|
+
## Coming from Turbo or Nx
|
|
155
99
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
[
|
|
100
|
+
`bunx @vzn/vx-migrate` writes a `vx.config.ts` per package from
|
|
101
|
+
`turbo.json` or the Nx graph; `vx init` in such a repo writes a
|
|
102
|
+
`vx.workspace.ts` that reads them in place. Either is a temporary start:
|
|
103
|
+
move to native vx config, which is what the numbers above measure.
|
|
104
|
+
[Migration guide](https://vznjs.github.io/vx/guides/migrate/).
|
|
161
105
|
|
|
162
106
|
## More than speed
|
|
163
107
|
|
|
@@ -197,7 +141,7 @@ machine.
|
|
|
197
141
|
|
|
198
142
|
| Package | What it adds |
|
|
199
143
|
| ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
200
|
-
| [`@vzn/vx-migrate`](packages/vx-migrate) |
|
|
144
|
+
| [`@vzn/vx-migrate`](packages/vx-migrate) | Migrate from Turbo or Nx: write native configs, keep its remote cache |
|
|
201
145
|
| [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
|
|
202
146
|
| [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
|
|
203
147
|
| [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.356",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
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.356",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.356",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.356",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.356"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/archive.ts
CHANGED
|
@@ -122,10 +122,6 @@ const drain = async (body: AsyncIterable<Uint8Array>): Promise<void> => {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
const WIN32 = process.platform === 'win32'
|
|
126
|
-
// A Windows link target may be spelled with either separator.
|
|
127
|
-
const LINK_SEP = WIN32 ? /[\\/]/ : '/'
|
|
128
|
-
|
|
129
125
|
/**
|
|
130
126
|
* Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
|
|
131
127
|
* pair, not an object, because the sidecar is real entropy inside an
|
|
@@ -271,9 +267,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
271
267
|
`output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
|
|
272
268
|
)
|
|
273
269
|
}
|
|
274
|
-
|
|
275
|
-
// Linux restore of that would be world-writable.
|
|
276
|
-
const mode = WIN32 ? 0o644 : st.mode & 0o777
|
|
270
|
+
const mode = st.mode & 0o777
|
|
277
271
|
meta.files[name] = [mode, Math.floor(st.mtimeMs)]
|
|
278
272
|
return {
|
|
279
273
|
name,
|
|
@@ -788,9 +782,7 @@ class Extractor {
|
|
|
788
782
|
let n = 0
|
|
789
783
|
for (const s of this.staged) {
|
|
790
784
|
const [mode, mtimeMs] = metaFor(s.name)
|
|
791
|
-
|
|
792
|
-
// attribute, and a read-only restore refuses the next rename over it.
|
|
793
|
-
if ((mode & 0o777) !== createdMode && !WIN32) chmodSync(s.tmp, mode & 0o777)
|
|
785
|
+
if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
|
|
794
786
|
if (mtimeMs !== undefined) {
|
|
795
787
|
// A Date, not seconds: Bun reads a negative number of seconds as
|
|
796
788
|
// "now", and a Date before 1970 as itself (Bun 1.4.2).
|
|
@@ -904,7 +896,7 @@ async function resolveThrough(p: string): Promise<string> {
|
|
|
904
896
|
at = path.parse(link).root
|
|
905
897
|
rest = link.slice(at.length)
|
|
906
898
|
}
|
|
907
|
-
pending.push(...rest.split(
|
|
899
|
+
pending.push(...rest.split('/').filter(Boolean).reverse())
|
|
908
900
|
}
|
|
909
901
|
return at
|
|
910
902
|
}
|
package/src/cache/cache.ts
CHANGED
|
@@ -2026,8 +2026,8 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
|
2026
2026
|
* Close for real. A plain `close()` leaves the connection open while any
|
|
2027
2027
|
* statement from `db.prepare()` lives (bun:sqlite 1.4.2 defers it, as
|
|
2028
2028
|
* `sqlite3_close_v2` does), so `cache.db` and its `-wal` and `-shm` stayed
|
|
2029
|
-
* open after `Cache.close()`: a leaked descriptor per run for an embedder
|
|
2030
|
-
*
|
|
2029
|
+
* open after `Cache.close()`: a leaked descriptor per run for an embedder
|
|
2030
|
+
* (O-10).
|
|
2031
2031
|
* `close(true)` finalizes them and closes.
|
|
2032
2032
|
*/
|
|
2033
2033
|
function closeDb(db: Database): void {
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -556,7 +556,8 @@ export interface BlobSizeMemo {
|
|
|
556
556
|
* the ones it lacks from one `cat-file` (65 ms over 3,000 loose objects,
|
|
557
557
|
* the whole of the cost, measured 2026-10-02). A smudged (racy) entry
|
|
558
558
|
* records 0 and is hashed from disk. An answer that cannot be read trusts
|
|
559
|
-
* nothing.
|
|
559
|
+
* nothing. A filter that keeps the size (`tr a-z A-Z`) is not caught: only
|
|
560
|
+
* a read of every trusted file would be (caching.md, step 12).
|
|
560
561
|
*/
|
|
561
562
|
async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
|
|
562
563
|
const { trusted, indexed } = enumeration
|
package/src/cache/inputs.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { rm, rmdir } from 'node:fs/promises'
|
|
|
22
22
|
import type { CacheInputs } from '../config.js'
|
|
23
23
|
import {
|
|
24
24
|
asTrees,
|
|
25
|
-
executablePath,
|
|
26
25
|
isExecutableMissing,
|
|
27
26
|
isLiteralPattern,
|
|
28
27
|
normalizeGlob,
|
|
@@ -394,17 +393,15 @@ async function runRuntimeCommand(
|
|
|
394
393
|
// vx's own `sh`, resolved on its PATH before the probe's: Bun.spawn looks
|
|
395
394
|
// a bare name up on the child's PATH, which leads with the project's
|
|
396
395
|
// `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
|
|
397
|
-
|
|
398
|
-
process.platform === 'win32' ? shellArgv(command) : [executablePath('sh'), '-c', command]
|
|
399
|
-
proc = Bun.spawn(argv, {
|
|
396
|
+
proc = Bun.spawn(shellArgv(command), {
|
|
400
397
|
cwd,
|
|
401
398
|
env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
|
|
402
399
|
stdin: 'ignore',
|
|
403
400
|
stdout: 'pipe',
|
|
404
401
|
stderr: 'pipe',
|
|
405
402
|
// Its own group, so the probe's whole tree can be taken down with vx
|
|
406
|
-
// (`killProbesOnExit`).
|
|
407
|
-
detached:
|
|
403
|
+
// (`killProbesOnExit`).
|
|
404
|
+
detached: true,
|
|
408
405
|
})
|
|
409
406
|
} catch (err) {
|
|
410
407
|
// The probe runs through `sh -c` like a task: a box without sh names
|
package/src/cli/help.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
61
61
|
' foo, @scope/*, ./packages/foo, foo..., ...foo,',
|
|
62
62
|
' foo^..., !foo, [<since>]',
|
|
63
63
|
' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
|
|
64
|
-
' affectedBase, else origin/HEAD, else
|
|
64
|
+
' affectedBase, else origin/HEAD, else main or',
|
|
65
|
+
' master, else HEAD~1.',
|
|
65
66
|
" pkg#task Run a specific project's task directly.",
|
|
66
67
|
'',
|
|
67
68
|
'Execution (for run):',
|
package/src/cli/init.ts
CHANGED
|
@@ -193,7 +193,7 @@ async function adopt(
|
|
|
193
193
|
? ''
|
|
194
194
|
: `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
|
|
195
195
|
process.stdout.write(
|
|
196
|
-
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate
|
|
196
|
+
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until bunx @vzn/vx-migrate writes native config; nothing else written.\n` +
|
|
197
197
|
`${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
|
|
198
198
|
)
|
|
199
199
|
return 0
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -21,11 +21,10 @@ const REPO = 'vznjs/vx'
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* A Bun standalone-binary path lives under the bunfs virtual root.
|
|
24
|
-
* Marker
|
|
25
|
-
* `B:\~BUN\...` or `B:/~BUN/...` (windows).
|
|
24
|
+
* Marker: `/$bunfs/...`.
|
|
26
25
|
*/
|
|
27
26
|
export function isBunfsPath(p: string): boolean {
|
|
28
|
-
return p.startsWith('/$bunfs')
|
|
27
|
+
return p.startsWith('/$bunfs')
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/**
|
package/src/config.ts
CHANGED
|
@@ -24,8 +24,9 @@ export interface WorkspaceConfig {
|
|
|
24
24
|
cacheRetention?: { olderThan?: string; maxSize?: string }
|
|
25
25
|
/**
|
|
26
26
|
* The git ref a bare `--affected` compares with (`origin/develop`).
|
|
27
|
-
* `--affected=<base>` overrides it. Omitted → `origin/HEAD`,
|
|
28
|
-
* `
|
|
27
|
+
* `--affected=<base>` overrides it. Omitted → `origin/HEAD`, then the
|
|
28
|
+
* first of `origin/main`, `origin/master`, `main`, `master` that is not
|
|
29
|
+
* HEAD, else `HEAD~1`. Never folded into a cache key.
|
|
29
30
|
*/
|
|
30
31
|
affectedBase?: string
|
|
31
32
|
/**
|
package/src/exec/env.ts
CHANGED
|
@@ -32,22 +32,6 @@ export const ESSENTIAL_ENV: readonly string[] = [
|
|
|
32
32
|
'NO_COLOR',
|
|
33
33
|
'CI',
|
|
34
34
|
'NODE_OPTIONS',
|
|
35
|
-
'SYSTEMROOT',
|
|
36
|
-
'APPDATA',
|
|
37
|
-
'LOCALAPPDATA',
|
|
38
|
-
'PROGRAMDATA',
|
|
39
|
-
'PROGRAMFILES',
|
|
40
|
-
'PROGRAMFILES(X86)',
|
|
41
|
-
'COMSPEC',
|
|
42
|
-
'PATHEXT',
|
|
43
|
-
// Read on Windows by os.homedir(), npm and git, which break without them.
|
|
44
|
-
'SYSTEMDRIVE',
|
|
45
|
-
'WINDIR',
|
|
46
|
-
'USERPROFILE',
|
|
47
|
-
'HOMEDRIVE',
|
|
48
|
-
'HOMEPATH',
|
|
49
|
-
'NUMBER_OF_PROCESSORS',
|
|
50
|
-
'PROCESSOR_ARCHITECTURE',
|
|
51
35
|
]
|
|
52
36
|
|
|
53
37
|
/**
|
package/src/exec/kill-tree.ts
CHANGED
|
@@ -15,25 +15,6 @@ import { executablePath, procfsIsOwn } from '../util/index.js'
|
|
|
15
15
|
|
|
16
16
|
export type Child = ReturnType<typeof Bun.spawn>
|
|
17
17
|
|
|
18
|
-
const WIN32 = process.platform === 'win32'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Windows has no process groups (`kill(-pid)` is ESRCH, which read as
|
|
22
|
-
* "gone" and killed nothing) and no polite signal a console task hears
|
|
23
|
-
* from another process, so every stop is `taskkill /T /F`: the child and
|
|
24
|
-
* every descendant it still parents. A grandchild whose parent already
|
|
25
|
-
* exited is out of the tree's reach (docs/design/windows-2026-09.md).
|
|
26
|
-
*/
|
|
27
|
-
function killTreeWin32(child: Child): void {
|
|
28
|
-
try {
|
|
29
|
-
Bun.spawn(['taskkill', '/T', '/F', '/PID', String(child.pid)], {
|
|
30
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
31
|
-
})
|
|
32
|
-
} catch {
|
|
33
|
-
child.kill()
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
18
|
/**
|
|
38
19
|
* A child whose polite signals go down a pipe instead of to its group: a
|
|
39
20
|
* Linux sandboxed task. The group there is bwrap's, whose monitor dies of
|
|
@@ -97,8 +78,6 @@ const GUARD_SCRIPT = [
|
|
|
97
78
|
function startGuard(): void {
|
|
98
79
|
if (guardFd !== undefined) return
|
|
99
80
|
guardFd = null
|
|
100
|
-
// The guard is an `sh` script over process groups; Windows has neither.
|
|
101
|
-
if (process.platform === 'win32') return
|
|
102
81
|
try {
|
|
103
82
|
const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
|
|
104
83
|
argv0: 'vx-group-guard',
|
|
@@ -227,7 +206,6 @@ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL')
|
|
|
227
206
|
// A pid of 0 would name OUR group (kill(0)): a child that never
|
|
228
207
|
// spawned has nothing to kill.
|
|
229
208
|
if (!(child.pid > 0) || goneGroups.has(child)) return
|
|
230
|
-
if (WIN32) return killTreeWin32(child)
|
|
231
209
|
const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
|
|
232
210
|
if (fd !== undefined) {
|
|
233
211
|
try {
|
|
@@ -297,8 +275,6 @@ export async function untilGroupsGone(
|
|
|
297
275
|
*/
|
|
298
276
|
function groupAlive(child: Child): boolean {
|
|
299
277
|
if (!(child.pid > 0) || goneGroups.has(child)) return false
|
|
300
|
-
// No groups: the tree kill is `taskkill /T`, and the leader is what is left to ask.
|
|
301
|
-
if (WIN32) return child.exitCode === null && child.signalCode === null
|
|
302
278
|
try {
|
|
303
279
|
process.kill(-child.pid, 0)
|
|
304
280
|
} catch (err) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// plugin claims the task, so it sits at the TAIL of every executor list
|
|
5
5
|
// (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
|
|
6
6
|
// absence is "here".
|
|
7
|
-
import { runCommand, signalExitCode } from './runner.js'
|
|
7
|
+
import { runCommand, signalExitCode, stopSignal } from './runner.js'
|
|
8
8
|
import { runSandboxed } from './sandbox-runtime.js'
|
|
9
9
|
import type { TaskExecutor } from './executor.js'
|
|
10
10
|
|
|
@@ -29,8 +29,7 @@ export function localExecutor(): TaskExecutor {
|
|
|
29
29
|
// runs after the teardown swept the run's children, and held the run
|
|
30
30
|
// to the signal's bound.
|
|
31
31
|
if (req.signal?.aborted === true) {
|
|
32
|
-
const
|
|
33
|
-
const signal = reason === 'SIGINT' || reason === 'SIGHUP' ? reason : 'SIGTERM'
|
|
32
|
+
const signal = stopSignal(req.signal.reason)
|
|
34
33
|
const exitCode = signalExitCode(signal)
|
|
35
34
|
return { exitCode, durationMs: 0, stdout: '', stderr: '', signal, violations: [] }
|
|
36
35
|
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
-
import { existsSync
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
8
|
import { constants as osConstants } from 'node:os'
|
|
9
9
|
import {
|
|
10
10
|
shellArgv,
|
|
@@ -25,9 +25,8 @@ import {
|
|
|
25
25
|
untilGroupsGone,
|
|
26
26
|
} from './kill-tree.js'
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
|
|
28
|
+
/** `sh` names itself in its errors. */
|
|
29
|
+
const SH_ARGV0 = { argv0: 'sh' }
|
|
31
30
|
|
|
32
31
|
export interface RunResult {
|
|
33
32
|
exitCode: number
|
|
@@ -224,8 +223,6 @@ const SHELL_BUILTINS = new Set([
|
|
|
224
223
|
* the shell.
|
|
225
224
|
*/
|
|
226
225
|
export function execWrap(command: string): string {
|
|
227
|
-
// Bun's shell, the task shell on Windows, has no `exec`.
|
|
228
|
-
if (WIN32) return command
|
|
229
226
|
const first = execWord(command)
|
|
230
227
|
return first === undefined ? command : `exec ${command}`
|
|
231
228
|
}
|
|
@@ -254,6 +251,16 @@ export function signalExitCode(signal: string): number {
|
|
|
254
251
|
return num === undefined ? 130 : 128 + num
|
|
255
252
|
}
|
|
256
253
|
|
|
254
|
+
/**
|
|
255
|
+
* The signal a run's stop sends a task, from its abort reason: a task
|
|
256
|
+
* stopped before its spawn exits as if that signal had killed it. As
|
|
257
|
+
* `forwardedSignal` (orchestrator/signals.ts): a hang-up forwards SIGTERM
|
|
258
|
+
* (to many servers SIGHUP means "reload"), so it reads as SIGTERM here.
|
|
259
|
+
*/
|
|
260
|
+
export function stopSignal(reason: unknown): 'SIGINT' | 'SIGTERM' {
|
|
261
|
+
return reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
|
|
262
|
+
}
|
|
263
|
+
|
|
257
264
|
/**
|
|
258
265
|
* The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
|
|
259
266
|
* by the platform's numbering; undefined for a plain exit. The shell
|
|
@@ -484,9 +491,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
484
491
|
],
|
|
485
492
|
// Its own session and process group, so a kill reaches what it
|
|
486
493
|
// forked (kill-tree.ts). stdin is a pipe, so a background group
|
|
487
|
-
// never stops on a terminal read.
|
|
488
|
-
|
|
489
|
-
detached: !WIN32,
|
|
494
|
+
// never stops on a terminal read.
|
|
495
|
+
detached: true,
|
|
490
496
|
},
|
|
491
497
|
),
|
|
492
498
|
)
|
|
@@ -695,9 +701,8 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
695
701
|
stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
|
|
696
702
|
// Its own session and process group, so a kill reaches what it
|
|
697
703
|
// forked (kill-tree.ts). stdin is ignored, so a background group
|
|
698
|
-
// never stops on a terminal read.
|
|
699
|
-
|
|
700
|
-
detached: !WIN32,
|
|
704
|
+
// never stops on a terminal read.
|
|
705
|
+
detached: true,
|
|
701
706
|
}),
|
|
702
707
|
)
|
|
703
708
|
} catch (err) {
|
|
@@ -750,18 +755,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
750
755
|
* `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
|
|
751
756
|
* 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
|
|
752
757
|
* Read after the child exits so it covers the whole task's span (the mark
|
|
753
|
-
* is monotonic). Linux reads
|
|
754
|
-
* bound in hand.
|
|
758
|
+
* is monotonic). Linux reads its own peak (`getrusage`); elsewhere the
|
|
759
|
+
* current RSS is the bound in hand.
|
|
755
760
|
*/
|
|
756
761
|
export function ownRssHighWater(): number {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// /proc unreadable: fall through to the current RSS.
|
|
763
|
-
}
|
|
764
|
-
}
|
|
762
|
+
// getrusage's own peak, not `/proc/self/status`'s `VmHWM`: one syscall
|
|
763
|
+
// where generating and parsing the status file was ~20 µs a task alone
|
|
764
|
+
// and ~70 ms of a 1,000-task cold run. It is the same mark, raised only
|
|
765
|
+
// by the image this one exec'd from (a shell), within the floor's slack.
|
|
766
|
+
if (process.platform === 'linux') return peakRssBytes(process.resourceUsage().maxRSS)
|
|
765
767
|
return process.memoryUsage.rss()
|
|
766
768
|
}
|
|
767
769
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isMountableLiteral,
|
|
11
11
|
localBindingOn,
|
|
12
12
|
MOUNT_WILDCARDS,
|
|
13
|
+
toRealPath,
|
|
13
14
|
unique,
|
|
14
15
|
} from './sandbox-paths.js'
|
|
15
16
|
import type { SandboxedRunArgs } from './sandbox-runtime.js'
|
|
@@ -27,27 +28,50 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
|
|
|
27
28
|
* is "Device or resource busy"; binding `/w/dist` instead succeeds.
|
|
28
29
|
*
|
|
29
30
|
* So on Linux a file-shaped grant is widened to its directory. That IS a
|
|
30
|
-
* widening — the task may write its siblings
|
|
31
|
+
* widening — the task may write its siblings, and read them unrefused
|
|
32
|
+
* (`widenedEntries` has those reads reported) — and it is the narrowest
|
|
31
33
|
* grant the mechanism can express: the alternative is a declared output
|
|
32
34
|
* the task cannot produce. macOS needs none of this (seatbelt matches
|
|
33
35
|
* paths, it does not mount), so the grant stays exact there.
|
|
34
36
|
*/
|
|
35
37
|
export function bindableWrites(paths: readonly string[]): string[] {
|
|
36
38
|
if (process.platform !== 'linux') return [...paths]
|
|
37
|
-
return unique(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bindOf(p: string): string {
|
|
43
|
+
if (!isMountableLiteral(p)) return p
|
|
44
|
+
try {
|
|
45
|
+
if (statSync(p).isDirectory()) return p
|
|
46
|
+
} catch {
|
|
47
|
+
// Does not exist yet: `prepareOutputsForBind` creates a file for a
|
|
48
|
+
// file-shaped grant, so treat it as one.
|
|
49
|
+
}
|
|
50
|
+
return path.dirname(p)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What each directory `bindableWrites` widened a file grant to held when
|
|
55
|
+
* the task started, by real path. A read there is never refused, so the
|
|
56
|
+
* strace pass reports one of these no grant covers: a task granted
|
|
57
|
+
* `write: ['out.txt']` read the whole project unreported, and a cached run
|
|
58
|
+
* replayed an undeclared file's old bytes. What the task creates there is
|
|
59
|
+
* its own, not an input, so only these entries count. Empty off Linux.
|
|
60
|
+
*/
|
|
61
|
+
export function widenedEntries(paths: readonly string[]): Map<string, Set<string>> {
|
|
62
|
+
const out = new Map<string, Set<string>>()
|
|
63
|
+
if (process.platform !== 'linux') return out
|
|
64
|
+
for (const p of paths) {
|
|
65
|
+
if (holdsGlobChar(p) || bindOf(p) === p) continue
|
|
66
|
+
const dir = toRealPath(path.dirname(p))
|
|
67
|
+
if (out.has(dir)) continue
|
|
68
|
+
try {
|
|
69
|
+
out.set(dir, new Set(readdirSync(dir).map((e) => path.join(dir, e))))
|
|
70
|
+
} catch {
|
|
71
|
+
// Gone or unreadable: nothing there to read.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
/**
|