Bringing a probe up on a new platform
A harness that works on one OS fails on another for reasons that are almost never the thing the
error names. Expect a CHAIN of faults, each hidden by the one in front of it.
⛔ Debug with the SMALLEST payload — the single most expensive mistake here
Measured, bringing the build-jail probe up on Windows: six sequential faults, found using
puppeteer as the probe, at ~25 minutes per attempt. Every one was answerable by a question as
small as "does the binary spawn?" — seconds with is-odd. About two hours bought six booleans.
Climb this ladder in order. Do not skip to the end.
| # |
Step |
Proves |
| 1 |
<binary> --version |
it exists, is executable, spawns |
| 2 |
same, with the config/override env set |
the feature is compiled in AND the override engages |
| 3 |
install a trivial no-scripts package (is-odd) |
fixture, store, linker |
| 4 |
one tiny package that DOES run a script |
a child process spawns under confinement |
| 5 |
the real canary (large, real download) |
end-to-end — a FINAL GATE, never a debugging instrument |
Each step is seconds. Each isolates one layer. When one passes, expect the next to fail — do not
read a green step as "the platform works."
The top fault usually accuses the wrong subsystem. On Windows the probe insisted the binary
lacked a cargo feature; the truth four layers down was an unspawnable path, with every per-cell log
at 0 bytes. When a diagnostic names a subsystem, verify that subsystem independently before acting
on the accusation — and when you fix a misleading diagnostic, it pays for itself immediately.
Windows — all measured
for /f runs its command through cmd /c, which STRIPS the outer quote pair when the string
both starts and ends with a quote. So the natural spelling of a capture,
for /f "usebackq delims=" %%i in (`"%EXE%" arg "%DIR%"`), degrades to
C:\...\x.exe" arg "C:\...\dir and dies with "The filename, directory name, or volume label
syntax is incorrect" — with or without spaces in the path. Fix: wrap the whole command in one
MORE quote pair (`""%EXE%" arg "%DIR%""`). Measured against a temp-file-redirect alternative,
which did not work.
An undefined %VAR% expands to its own literal text, so a missing variable is passed onward as
the string %VAR% rather than being empty — silently handing a bogus path downstream. Guard with
if not defined VAR set "VAR=%CD%", under setlocal so the default does not leak to the caller.
A .cmd on PATH is invisible to whole classes of CI. In this repo native-deps.yml is the
only workflow touching node-gyp and it is ubuntu-only, so the Windows .cmd shim shipped broken and
unnoticed for as long as another code path kept it from ever running. Green Windows jobs are not
evidence for a Windows file nothing executes — check that some job actually runs it.
Node aborts at startup inside nub's build jail on Windows: Assertion failed: ncrypto::CSPRNG(nullptr, 0). Any jailed scenario whose script is node therefore cannot report on
what it meant to test; skip it with the reason printed rather than recording a pass or a fail.
spawnSync cannot run npm/npx/pnpm. They are .cmd shims: the bare name gives
ENOENT, the .cmd spelling gives EINVAL (Node has refused to CreateProcess a batch file
since CVE-2024-27980). shell: true works but is DEP0190 — args are concatenated rather than
escaped, so cmd.exe re-parses a spec like @scope/pkg@1.0.0. Run the bundled JS instead:
node <path>/node_modules/npm/bin/npm-cli.js.
Git Bash paths are not spawnable by native tools. cd $(dirname) && pwd under Git Bash yields
/c/Users/…, handed to CreateProcess verbatim → ENOENT. Convert to C:\…; for any path passed to
a Windows binary use cygpath -w. /tmp/x is NOT where a Windows node will look.
A copied binary must keep its .exe. Windows decides executability from the SUFFIX, so a
content-addressed copy named by bare hash is ENOENT even at 1.2 GB and mode 0755.
Removing a node_modules tree fails with EPERM routinely — a lifecycle child still holds a
handle, or an indexer/AV opens files behind you. Use maxRetries/retryDelay, and never let a
cleanup failure discard a measurement that already succeeded (a non-zero exit from tidy-up reads
upstream as "no result").
Disk is a first-class hazard, and a full disk is SILENT AND EVIL. A debug Rust binary can be
~1.2 GB with a target/ of tens of GB; add leaked fixture trees and 80 GB vanishes. When it
fills, git fetch fails and the probe runs STALE CODE while you believe you are testing the
fix. Size 200 GB+, build --profile fast not debug, and check free space before trusting any
run. Growing the GCE disk is not enough — the in-OS partition must be extended too, and
Resize-Partition can simply fail.
Prefer a CI-built binary to provisioning MSVC. A windows-latest workflow that uploads
target/debug/nub.exe gives a real MSVC binary for one dispatch, versus installing the VS
VCTools workload, cmake, and switching the rustup host triple by hand. A windows-gnu
cross-compile is NOT a substitute when the thing under test is OS confinement behaviour.
If you do build on the VM: rustup may default to windows-gnu (nub needs -msvc), VS Build Tools
can be present with no C++ workload so there is no linker at all, and /STACK:8388608 is an
MSVC-only flag that breaks a GNU link — it is required under MSVC because Windows gives the
main thread 1 MB against Linux's 8 MB.
Windows filesystem speed — budget ~6x Linux, and beware the benchmark itself
MEASURED on matched GCE e2-standard-8 / pd-balanced VMs, 3,000 small file creates:
|
time |
| Linux (bash redirect) |
181 ms |
Windows ([System.IO.File]::WriteAllText) |
1,142 ms |
Windows (PowerShell Set-Content) |
3,156 ms |
~6x, not 17x. The first Windows figure was Set-Content, whose cmdlet/pipeline overhead is
roughly two-thirds of it — comparing that against a bash redirect is not varying one thing. Use a
raw write on both sides before quoting any cross-OS I/O ratio.
⛔ DO NOT EXTRAPOLATE THAT RATIO TO INSTALL TIME — I did, and it was wrong. A microbenchmark of
raw file creation is not a workload. Measured with the SAME nub binary on matched VMs:
| install |
Linux |
Windows |
ratio |
trivial (is-odd) |
493 ms |
744 ms |
1.5x |
file-heavier (typescript) |
1,675 ms |
3,218 ms |
1.9x |
So a real install is ~1.5–2x, not 6x: installs are dominated by network and archive work, and
the file-create penalty is a minority of the total. I first wrote "budget an order of magnitude" here
off the microbenchmark alone; that was a wrong planning number in a durable doc. Time the actual
workload before sizing anything.
(Note the file COUNTS in that test are not comparable — Get-ChildItem -Recurse reported 9 where
find -L reported 264, because neither traverses junctions the same way. The TIMES are the
comparable part; see the junction-traversal trap above.)
Linux — all measured
- An AUTHORED Landlock grant naming a path that does not exist makes the whole policy
uncompilable — refused, not degraded (
PolicyNotExpressible). Speculative grants skip a missing
path; authored ones abort. One bad entry breaks every confined run.
- Do not read "Landlock unavailable" as a kernel problem. That error covers both an old kernel
and our own policy failing to compile; it claimed a missing feature on a 6.17 kernel with ABI 4.
- A fresh cloud box has no system
node, and the Rust build needs cmake. Provision Nodes with
the tool under test where possible (dogfoods the real mechanism) — but its layout may be
<cache>/nub/node/22.23.1/bin, with no v prefix, where nvm uses v22.23.1. Anything parsing
a version out of a node path must accept both or it silently returns null.
- Absent tooling is environmental, not a defect.
pnpm missing → a script shelling out to it
exits 127; a -musl package on a glibc box cannot load. Neither is a bug in the thing under test.
- Old Node pins bring old npm, and npm 6 races on its own
_cacache under concurrency —
surfacing as rimraf: missing path + Callback called more than once. This manufactures FALSE
defect verdicts that a double-control cannot catch, because both attempts sit in the same busy
window. Re-verify any defect verdict serially once the batch drains.
Remote-shell mechanics that cost a loop every time
- SSH to Windows lands in PowerShell, not bash.
bash.exe -lc "…" must go through a .ps1 or
PowerShell parses -lc as an expression. Nested quoting through
ssh … powershell -Command "…" breaks constantly — prefer a command containing no double
quotes at all, or scp a script file (which fails on a full disk, so check that first).
- Keep every PowerShell script ASCII. One em-dash anywhere fails with
The string is missing the terminator, pointing at the LAST line of the file rather than the offending one.
pkill -f <pattern> matches YOUR OWN command line. Killing -f "search.mjs" from a shell
whose command text contains search.mjs kills that shell — the remote command dies mid-way and
returns no output, which reads like a hang. Split the kill into its own invocation, or break the
literal ("sea""rch.mjs").
- Detach long remote runs with
tmux new-session -d. A bare nohup … & over SSH hangs the
connection and can leave only the first job running.
- Prefer native tools for bulk filesystem work.
du -sh over Git Bash on a large Windows tree
is glacial and will time out; PowerShell equivalents finish.
1---2name: probe-platforms3description: Bring a test harness or probe up on a NEW operating system (Windows or Linux), or debug one that fails there. Invoke before the first run on a platform, and whenever a harness works on one OS but not another. Carries the bring-up ladder that avoids spending hours learning single booleans, the Windows spawn/path/disk faults that each present as something else entirely, the Linux Landlock and node-layout traps, and the remote-shell mechanics (PowerShell-over-SSH quoting, pkill matching your own command) that waste a loop each time they are rediscovered.4---56# Bringing a probe up on a new platform78A harness that works on one OS fails on another for reasons that are almost never the thing the9error names. Expect a CHAIN of faults, each hidden by the one in front of it.1011## ⛔ Debug with the SMALLEST payload — the single most expensive mistake here1213Measured, bringing the build-jail probe up on Windows: **six sequential faults, found using14`puppeteer` as the probe, at ~25 minutes per attempt.** Every one was answerable by a question as15small as *"does the binary spawn?"* — seconds with `is-odd`. About two hours bought six booleans.1617**Climb this ladder in order. Do not skip to the end.**1819| # | Step | Proves |20|---|---|---|21| 1 | `<binary> --version` | it exists, is executable, spawns |22| 2 | same, with the config/override env set | the feature is compiled in AND the override *engages* |23| 3 | install a trivial no-scripts package (`is-odd`) | fixture, store, linker |24| 4 | one tiny package that DOES run a script | a child process spawns under confinement |25| 5 | the real canary (large, real download) | end-to-end — a FINAL GATE, never a debugging instrument |2627Each step is seconds. Each isolates one layer. **When one passes, expect the next to fail** — do not28read a green step as "the platform works."2930**The top fault usually accuses the wrong subsystem.** On Windows the probe insisted the binary31lacked a cargo feature; the truth four layers down was an unspawnable path, with every per-cell log32at 0 bytes. When a diagnostic names a subsystem, verify that subsystem independently before acting33on the accusation — and when you fix a misleading diagnostic, it pays for itself immediately.3435## Windows — all measured3637- **`for /f` runs its command through `cmd /c`, which STRIPS the outer quote pair** when the string38 both starts and ends with a quote. So the natural spelling of a capture,39 `` for /f "usebackq delims=" %%i in (`"%EXE%" arg "%DIR%"`) ``, degrades to40 `C:\...\x.exe" arg "C:\...\dir` and dies with **"The filename, directory name, or volume label41 syntax is incorrect"** — with or without spaces in the path. Fix: wrap the whole command in one42 MORE quote pair (`` `""%EXE%" arg "%DIR%""` ``). Measured against a temp-file-redirect alternative,43 which did *not* work.44- **An undefined `%VAR%` expands to its own literal text**, so a missing variable is passed onward as45 the string `%VAR%` rather than being empty — silently handing a bogus path downstream. Guard with46 `if not defined VAR set "VAR=%CD%"`, under `setlocal` so the default does not leak to the caller.47- **A `.cmd` on `PATH` is invisible to whole classes of CI.** In this repo `native-deps.yml` is the48 only workflow touching node-gyp and it is ubuntu-only, so the Windows `.cmd` shim shipped broken and49 unnoticed for as long as another code path kept it from ever running. Green Windows jobs are not50 evidence for a Windows file nothing executes — check that some job actually runs it.51- **Node aborts at startup inside nub's build jail on Windows**: `Assertion failed:52 ncrypto::CSPRNG(nullptr, 0)`. Any jailed scenario whose script is `node` therefore cannot report on53 what it meant to test; skip it with the reason printed rather than recording a pass or a fail.5455- **`spawnSync` cannot run `npm`/`npx`/`pnpm`.** They are `.cmd` shims: the bare name gives56 **ENOENT**, the `.cmd` spelling gives **EINVAL** (Node has refused to `CreateProcess` a batch file57 since CVE-2024-27980). `shell: true` works but is DEP0190 — args are concatenated rather than58 escaped, so cmd.exe re-parses a spec like `@scope/pkg@1.0.0`. **Run the bundled JS instead:59 `node <path>/node_modules/npm/bin/npm-cli.js`.**60- **Git Bash paths are not spawnable by native tools.** `cd $(dirname) && pwd` under Git Bash yields61 `/c/Users/…`, handed to CreateProcess verbatim → ENOENT. Convert to `C:\…`; for any path passed to62 a Windows binary use `cygpath -w`. `/tmp/x` is NOT where a Windows `node` will look.63- **A copied binary must keep its `.exe`.** Windows decides executability from the SUFFIX, so a64 content-addressed copy named by bare hash is ENOENT even at 1.2 GB and mode 0755.65- **Removing a `node_modules` tree fails with EPERM routinely** — a lifecycle child still holds a66 handle, or an indexer/AV opens files behind you. Use `maxRetries`/`retryDelay`, and **never let a67 cleanup failure discard a measurement that already succeeded** (a non-zero exit from tidy-up reads68 upstream as "no result").69- **Disk is a first-class hazard, and a full disk is SILENT AND EVIL.** A debug Rust binary can be70 ~1.2 GB with a `target/` of tens of GB; add leaked fixture trees and 80 GB vanishes. When it71 fills, `git fetch` fails and the probe runs **STALE CODE while you believe you are testing the72 fix**. Size 200 GB+, build `--profile fast` not `debug`, and check free space before trusting any73 run. Growing the GCE disk is not enough — the in-OS partition must be extended too, and74 `Resize-Partition` can simply fail.75- **Prefer a CI-built binary to provisioning MSVC.** A `windows-latest` workflow that uploads76 `target/debug/nub.exe` gives a real MSVC binary for one dispatch, versus installing the VS77 `VCTools` workload, `cmake`, and switching the rustup host triple by hand. A `windows-gnu`78 cross-compile is NOT a substitute when the thing under test is OS confinement behaviour.79- If you do build on the VM: rustup may default to `windows-gnu` (nub needs `-msvc`), VS Build Tools80 can be present with **no C++ workload** so there is no linker at all, and `/STACK:8388608` is an81 **MSVC-only** flag that breaks a GNU link — it is required under MSVC because Windows gives the82 main thread 1 MB against Linux's 8 MB.8384### Windows filesystem speed — budget ~6x Linux, and beware the benchmark itself8586MEASURED on matched GCE `e2-standard-8` / `pd-balanced` VMs, 3,000 small file creates:8788| | time |89|---|---|90| Linux (bash redirect) | **181 ms** |91| Windows (`[System.IO.File]::WriteAllText`) | **1,142 ms** |92| Windows (PowerShell `Set-Content`) | 3,156 ms |9394**~6x, not 17x.** The first Windows figure was `Set-Content`, whose cmdlet/pipeline overhead is95roughly two-thirds of it — comparing that against a bash redirect is not varying one thing. Use a96raw write on both sides before quoting any cross-OS I/O ratio.9798⛔ **DO NOT EXTRAPOLATE THAT RATIO TO INSTALL TIME — I did, and it was wrong.** A microbenchmark of99raw file creation is not a workload. Measured with the SAME nub binary on matched VMs:100101| install | Linux | Windows | ratio |102|---|---|---|---|103| trivial (`is-odd`) | 493 ms | 744 ms | **1.5x** |104| file-heavier (`typescript`) | 1,675 ms | 3,218 ms | **1.9x** |105106So a real install is **~1.5–2x**, not 6x: installs are dominated by network and archive work, and107the file-create penalty is a minority of the total. I first wrote "budget an order of magnitude" here108off the microbenchmark alone; that was a wrong planning number in a durable doc. **Time the actual109workload before sizing anything.**110111(Note the file COUNTS in that test are not comparable — `Get-ChildItem -Recurse` reported 9 where112`find -L` reported 264, because neither traverses junctions the same way. The TIMES are the113comparable part; see the junction-traversal trap above.)114115## Linux — all measured116117- **An AUTHORED Landlock grant naming a path that does not exist makes the whole policy118 uncompilable** — refused, not degraded (`PolicyNotExpressible`). Speculative grants skip a missing119 path; authored ones abort. One bad entry breaks every confined run.120- **Do not read "Landlock unavailable" as a kernel problem.** That error covers both an old kernel121 and *our own policy failing to compile*; it claimed a missing feature on a 6.17 kernel with ABI 4.122- **A fresh cloud box has no system `node`**, and the Rust build needs `cmake`. Provision Nodes with123 the tool under test where possible (dogfoods the real mechanism) — but its layout may be124 `<cache>/nub/node/22.23.1/bin`, with **no `v` prefix**, where nvm uses `v22.23.1`. Anything parsing125 a version out of a node path must accept both or it silently returns null.126- **Absent tooling is environmental, not a defect.** `pnpm` missing → a script shelling out to it127 exits 127; a `-musl` package on a glibc box cannot load. Neither is a bug in the thing under test.128- **Old Node pins bring old npm, and npm 6 races on its own `_cacache` under concurrency** —129 surfacing as `rimraf: missing path` + `Callback called more than once`. This manufactures FALSE130 defect verdicts that a double-control cannot catch, because both attempts sit in the same busy131 window. Re-verify any defect verdict serially once the batch drains.132133## Remote-shell mechanics that cost a loop every time134135- **SSH to Windows lands in PowerShell, not bash.** `bash.exe -lc "…"` must go through a `.ps1` or136 PowerShell parses `-lc` as an expression. Nested quoting through137 `ssh … powershell -Command "…"` breaks constantly — prefer a command containing **no double138 quotes at all**, or `scp` a script file (which fails on a full disk, so check that first).139- **Keep every PowerShell script ASCII.** One em-dash anywhere fails with `The string is missing the140 terminator`, pointing at the LAST line of the file rather than the offending one.141- **`pkill -f <pattern>` matches YOUR OWN command line.** Killing `-f "search.mjs"` from a shell142 whose command text contains `search.mjs` kills that shell — the remote command dies mid-way and143 returns no output, which reads like a hang. Split the kill into its own invocation, or break the144 literal (`"sea""rch.mjs"`).145- **Detach long remote runs with `tmux new-session -d`.** A bare `nohup … &` over SSH hangs the146 connection and can leave only the first job running.147- **Prefer native tools for bulk filesystem work.** `du -sh` over Git Bash on a large Windows tree148 is glacial and will time out; PowerShell equivalents finish.