[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
When a hosted→vendored eject (socket-patch vendor in a hosted project) fails after it has started writing, it restores its pre-eject snapshot. That snapshot isn't limited to the files the eject touches. EjectSnapshot::take reads every regular file directly in the project root, and restore atomically rewrites all of them (atomic_write_bytes_preserving_mode, a rename over the path). Every root file therefore gets replaced by a new inode holding its bytes from the start of the run. As a result:
- The run's own output is lost when stdout/stderr is redirected to a file in the project root, which is the usual CI pattern. With
vendor --json > vendor-report.json, the file is empty after the run (exit 1). The envelope, including the eject_rolled_back warning, went to the unlinked inode. With vendor > vendor.log 2>&1, the log stops at Ejecting 1 hosted package into .socket/vendor/...; the Error: Cannot vendor … line and the Vendored 0 packages; 1 failed. summary are gone.
- Writes from other processes to root files during the run are rolled back. A concurrent appender to
build.log lost 7 of its 400 lines.
- Files the eject never touched are rewritten anyway (README.md got a new inode), which breaks hard links and bumps mtimes.
The pre-flight refusals (eject_refused, before the snapshot) are unaffected: their envelope reaches a redirected file normally.
Impact
A CI job that runs socket-patch vendor --json > report.json and then reads the report gets an empty file and no reason for the exit 1. Logs captured in the repo root lose the error message. Any other tool writing to a root file during the eject, such as a build log or an editor save, silently loses those writes. This is ecosystem-agnostic (run_eject is shared). I found it with npm.
Repro (main 045d7ec, Linux, npm 10.9.4 / Node 22; local mock patch API serving a hosted left-pad@1.3.0 patch)
Any eject that fails after the snapshot works. The simplest npm trigger is the over-broad vendor_workspace_member refusal (filed separately), or the lockfileVersion 1 refusal from #659.
mkdir -p p/third_party/left-pad && cd p
printf '{"name":"left-pad","version":"1.3.0","main":"index.js"}\n' > third_party/left-pad/package.json
printf 'module.exports = () => "fork";\n' > third_party/left-pad/index.js
echo '{"name":"p","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","lp-local":"file:./third_party/left-pad"}}' > package.json
npm i
socket-patch scan --mode hosted --json --yes <api flags> # exit 0, hosted pin + .npmrc
socket-patch vendor --json <api flags> > inside.json; echo $? $(wc -c < inside.json) # 1 0 <- empty
socket-patch vendor --json <api flags> > ../outside.json; echo $? $(wc -c < ../outside.json) # 1 716 <- partialFailure + eject_rolled_back
socket-patch vendor <api flags> > vendor.log 2>&1; cat vendor.log # only the "Ejecting 1 hosted package…" line
ls -i README.md # 1966494
(for i in $(seq 400); do echo "line $i" >> build.log; sleep 0.005; done) &
sleep 0.3; socket-patch vendor --json <api flags> > /dev/null; wait
wc -l build.log; ls -i README.md # 393 build.log; 1967075 README.md (new inode)
Reproduced 3 times for the redirect case (> and >>) and once for the concurrent writer.
Expected vs actual
- Expected (CLI_CONTRACT.md, vendor eject): "the wet run snapshots every file the eject may touch under one
apply.lock, restores upstream, then vendors. If any package then fails, the snapshot is put back." The files an npm eject may touch are the pins' lockfiles, .npmrc, package.json and the vendor ledger. A rollback should leave other root files alone. It should also only write back files whose bytes actually changed.
- Actual: every regular root file is snapshotted and rewritten, including the shell's redirect target, unrelated logs and README.md.
Matrix
| OS |
npm |
--json > root file |
> root log 2>&1 |
concurrent root writer |
untouched root file |
| Linux |
8.19.4 (lock v2) |
eject exit 1, rolled back (seen with output outside the root) |
— |
— |
— |
| Linux |
10.9.4 (lock v3) |
empty file (x3) |
error lines lost |
7/400 lines lost |
rewritten (new inode) |
| Linux |
12.2.0 / Node 24 (lock v3) |
eject exit 1, rolled back (seen with output outside the root) |
— |
— |
— |
| macOS / Windows |
not run |
|
|
|
|
Not run on macOS or Windows: probe branches can't be deleted through this sandbox's proxy, so I didn't push one. On Windows the rename-over of a file the shell holds open may fail instead, which would turn this into eject_rollback_failed. That's worth checking.
First bad version: v4.0.0 has no eject (vendor in a hosted project returns noManifest), so this arrived with the v5 eject. The shallow history here only shows root_file_names as of de316b4 (#358).
Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:1066 EjectSnapshot::take: rels starts from root_file_names(root), which is every regular file in the root.
crates/socket-patch-cli/src/commands/vendor.rs:1086 EjectSnapshot::restore: it rewrites each snapshotted file unconditionally. Restricting it to touched + EXTRA, or skipping files whose current bytes equal the snapshot, would avoid both effects. root_files is still needed to delete root files the eject created.
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
When a hosted→vendored eject (
socket-patch vendorin a hosted project) fails after it has started writing, it restores its pre-eject snapshot. That snapshot isn't limited to the files the eject touches.EjectSnapshot::takereads every regular file directly in the project root, andrestoreatomically rewrites all of them (atomic_write_bytes_preserving_mode, a rename over the path). Every root file therefore gets replaced by a new inode holding its bytes from the start of the run. As a result:vendor --json > vendor-report.json, the file is empty after the run (exit 1). The envelope, including theeject_rolled_backwarning, went to the unlinked inode. Withvendor > vendor.log 2>&1, the log stops atEjecting 1 hosted package into .socket/vendor/...; theError: Cannot vendor …line and theVendored 0 packages; 1 failed.summary are gone.build.loglost 7 of its 400 lines.The pre-flight refusals (
eject_refused, before the snapshot) are unaffected: their envelope reaches a redirected file normally.Impact
A CI job that runs
socket-patch vendor --json > report.jsonand then reads the report gets an empty file and no reason for the exit 1. Logs captured in the repo root lose the error message. Any other tool writing to a root file during the eject, such as a build log or an editor save, silently loses those writes. This is ecosystem-agnostic (run_ejectis shared). I found it with npm.Repro (main
045d7ec, Linux, npm 10.9.4 / Node 22; local mock patch API serving a hostedleft-pad@1.3.0patch)Any eject that fails after the snapshot works. The simplest npm trigger is the over-broad
vendor_workspace_memberrefusal (filed separately), or the lockfileVersion 1 refusal from #659.Reproduced 3 times for the redirect case (
>and>>) and once for the concurrent writer.Expected vs actual
apply.lock, restores upstream, then vendors. If any package then fails, the snapshot is put back." The files an npm eject may touch are the pins' lockfiles,.npmrc,package.jsonand the vendor ledger. A rollback should leave other root files alone. It should also only write back files whose bytes actually changed.Matrix
--json > root file> root log 2>&1Not run on macOS or Windows: probe branches can't be deleted through this sandbox's proxy, so I didn't push one. On Windows the rename-over of a file the shell holds open may fail instead, which would turn this into
eject_rollback_failed. That's worth checking.First bad version: v4.0.0 has no eject (
vendorin a hosted project returnsnoManifest), so this arrived with the v5 eject. The shallow history here only showsroot_file_namesas ofde316b4(#358).Suspect code
crates/socket-patch-cli/src/commands/vendor.rs:1066EjectSnapshot::take:relsstarts fromroot_file_names(root), which is every regular file in the root.crates/socket-patch-cli/src/commands/vendor.rs:1086EjectSnapshot::restore: it rewrites each snapshotted file unconditionally. Restricting it totouched+EXTRA, or skipping files whose current bytes equal the snapshot, would avoid both effects.root_filesis still needed to delete root files the eject created.