Repository navigation
An interrupted vendor eject (Ctrl-C, SIGTERM or SIGKILL) can leave the npm lock back on the registry, unpatched, with no journal, and a re-run then says "No manifest found, nothing to vendor" (exit 0) #1782
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
The hosted → vendored eject (plain socket-patch vendor over hosted pins) writes the upstream restore straight to disk. It rewrites package-lock.json back to the registry entry and deletes a hosted-created .npmrc (or leaves the allow-remote=all line in a pre-existing one). Only afterwards does it run the vendored apply, whose group commit writes .socket/vendor/.commit-journal.json. The only safety net in between is the in-memory EjectSnapshot.
If the process dies in that window, nothing is left on disk to recover from. That covers SIGKILL, Ctrl-C (SIGINT), a CI job cancel or timeout (SIGTERM), an OOM kill and a power loss. The project ends up neither hosted nor vendored:
- the lock points at the unpatched registry tarball;
.npmrcis gone;.socket/vendor/holds an orphaned, partly written uuid dir;- there is no journal for the next locked command to replay.
The next npm ci installs the vulnerable bytes. Every tool says nothing is wrong:
- re-running
vendorprintsNo manifest found, nothing to vendor.(--json:status: "noManifest"), exit 0; vendor --checkprints nothing, exit 0;vexsays there is nothing to attest.
The scan --mode vendored takeover of the same hosted pin is crash-safe. Killed at each of its renames, it stays hosted until the journal exists, then rolls forward. So this gap is specific to the eject.
Impact
- A Ctrl-C or CI cancel during
vendorsilently unpatches the project. Onlygit diffshows it. - The documented recovery path ("re-run the command") doesn't help.
vendorno longer sees any hosted pin, and there's no manifest, so it exits 0 having done nothing. The user has to know togit checkout -- package-lock.json .npmrcor runscan --mode vendored. - The window lasts about 115–140 ms per run with one package, on a local disk (measured with
strace -tt). With a single package it contains sixgitsubprocess spawns. Per-packagegitprobes plausibly widen it with more packages.
Repro (Linux, npm 10.9.4 / 12.2.1, Node 22.22.0)
A small LD_PRELOAD shim delivers a signal on the Nth rename(2). That stands in for a Ctrl-C landing at that moment. A local mock of the patch API and patch server serves one free patch for ms@2.0.0, the transitive copy under debug@2.6.9.
# killren.c: kill(getpid(), $KILL_SIG or SIGKILL) on the $KILL_AT-th rename()/renameat()
gcc -shared -fPIC -o killren.so killren.c -ldl
mkdir p && cd p && echo '{"name":"p","version":"1.0.0","private":true}' > package.json
npm i ms@2.1.3 debug@2.6.9
A="--api-url http://127.0.0.1:18080 --org o --api-token fake --patch-server-url http://127.0.0.1:18080"
socket-patch scan --mode hosted --yes $A # pins debug/node_modules/ms to the hosted tarball, writes .npmrc
git init -q && git add -A && git commit -qm hosted
LD_PRELOAD=$PWD/../killren.so KILL_AT=3 socket-patch vendor --yes $A # killed, exit 137
git status --short # D .npmrc M package-lock.json ?? .socket/
grep '"resolved".*ms-2.0.0' package-lock.json # https://registry.npmjs.org/ms/-/ms-2.0.0.tgz
ls .socket/vendor/.commit-journal.json # No such file
rm -rf node_modules && npm ci && head -c 40 node_modules/debug/node_modules/ms/index.js # unpatched
socket-patch vendor --yes $A # "No manifest found, nothing to vendor." exit 0
socket-patch vendor --check $A # (no output) exit 0
socket-patch vex --output v.json $A # "...nothing to attest"Order of writes in a successful eject (strace -f -e rename,unlink):
unlink("./.npmrc") <- upstream restore, direct
rename(stage, "./package-lock.json") <- upstream restore, direct (now registry/unpatched)
rename(stage, ".socket/vendor/npm/<uuid>/ms-2.0.0.tgz")
rename(stage, ".socket/vendor/npm/<uuid>/.gitignore")
rename(stage, ".socket/vendor/npm/<uuid>/.gitattributes")
rename(stage, ".socket/vendor/npm/<uuid>/socket-patch.vendor.json")
rename(stage, ".socket/vendor/.commit-journal.json") <- first durable recovery point
rename(stage, "./package-lock.json") <- vendored wiring
rename(stage, ".socket/vendor/state.json")
unlink(".socket/vendor/.commit-journal.json")
Expected vs actual
Expected. CLI_CONTRACT.md, vendor eject: "The eject is ONE planned transition, all-or-nothing … If any package then fails, exactly the files the eject wrote are put back … the project stays hosted exactly as before".
The vendored crash semantics: "before the journal is durable, nothing is committed — the lockfiles and ledgers are the pre-run ones and the run's artifacts are unreferenced orphans; after it, the next command that takes the apply lock replays the journal".
An interrupted eject should leave the project hosted (pre-journal) or finish vendored (post-journal), as the scan --mode vendored takeover does.
Actual. An interrupt at any of renames 2–6 (main) or 2–6 (v5.0.1) leaves the lock un-hosted, with no journal and no recovery. The same happens on SIGINT and SIGTERM: the CLI installs no handler that runs the EjectSnapshot restore.
Matrix (each cell run at least twice)
| Build | npm | Interrupt | Kill point | Result |
|---|---|---|---|---|
main b787454 |
10.9.4 (v3 lock) | SIGKILL | renames 2,3,4,5,6 | fail: lock on registry, .npmrc deleted, no journal, re-run noManifest exit 0 |
main b787454 |
10.9.4 | SIGKILL | rename 1 | .npmrc already deleted, lock still hosted, stray .socket-stage-package-lock.json-* at the root; npm 12 npm ci would then fail EALLOWREMOTE (fails closed) |
main b787454 |
10.9.4 | SIGKILL | renames 7, 8 | pass (journal replayed by the next locked command) |
main b787454 |
10.9.4 | SIGINT / SIGTERM | rename 3 | fail (same state as SIGKILL) |
main b787454 |
12.2.1, install-strategy=linked, pre-existing .npmrc |
SIGKILL | rename 3 | fail: lock on registry, npm ci installs unpatched .store copy, re-run noManifest exit 0 |
| v5.0.1 (npm package) | 10.9.4 | SIGKILL | renames 2–6 | fail (same) |
main b787454 |
10.9.4 | SIGKILL during the artifact download (mock delays the tarball 8 s) | — | pass: the download happens before the restore, so the lock is still hosted |
main b787454 |
10.9.4 | scan --mode vendored takeover, SIGKILL |
renames 1–8 | pass: hosted until the journal, then rolls forward |
Not OS-specific: the ordering is in shared code. macOS / Windows were not run (this routine's probe branches are currently blocked).
First bad version
Present in v5.0.1 and on main b787454 (#1648 kept the ordering).
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:2029:run_ejectcallsrestore_upstream(.., dry_run: false, ..)with noGroupCommit, so the restore's lock rewrite and.npmrcremoval reach the disk directly. Recovery relies onEjectSnapshot(vendor.rs:1992), which lives only in memory.- Compare
vendor.rs:3405. The takeover takesgroup.savepoint()and runsrestore_upstreaminside the group-commit overlay, so the restore lands in the same journaled commit as the vendored wiring. vendor.rs:2239: a re-run reportsNo manifest found, nothing to vendor.with exit 0 even though.socket/vendor/npm/<uuid>/holds an orphan for a package the lock no longer pins. A hint towardgit checkout/scan --mode vendoredwould at least make the state visible.
Probe runs: none (probe branches are blocked for this routine; Linux only).
Activity
npm bughunt run 51: file #1782
mikolalysenko commentedon Oct 28, 2026
[agent] Triage: priority:p1 (npm). Confirmed on main b787454:
run_eject(crates/socket-patch-cli/src/commands/vendor.rs) runs the wetrestore_upstreambefore the vendored backend opens its group commit, so the lock rewrite and.npmrcremoval hit the disk before.socket/vendor/.commit-journal.jsonexists. No duplicate found among open issues.[agent] Claiming this issue (shared root cause: eject's upstream restore is written outside the vendored group commit). Branch: agent/fix-eject-restore-journal. Claim-ID: 2026-10-28T13:27:00Z-06b3a0
Generated by Claude Code
mikolalysenko commentedon Oct 28, 2026
[agent] Draft PR: #1783
Generated by Claude Code
Keep an interrupted vendor eject hosted
Harden the journaled eject restore
Never commit a partly failed vendor eject