Repository navigation
fix(npm): harden postinstall binary downloader - #57
Merged
Merged
Conversation
…starts Route the outer request error handler through the same fail() path used for response and write-stream errors once a download has begun, so a truncated binary is not left on disk when the socket fails mid-transfer.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Success close deletes downloaded file
- Added a settled guard in the fail path close handler so cleanupPartial cannot run after the success handler has already resolved.
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 7d30bb5. Configure here.
Guard the fail path close handler with a settled check so a belated response error cannot delete a completed download after the success handler has already resolved.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What Changed
npm/scripts/postinstall.js) so an interrupted or failed download no longer leaves a truncated binary on disk: write/response error handlers now defer cleanup until the write stream closes, thenrmSyncthe partial file before rejecting — preventing the nextnpm installfrom treating a corrupt partial as "binary already present".tests/postinstall.test.cjscoveringensureDirrecursion, downloading into a missingbin/, partial-file cleanup on an interrupted download, and graceful rejection when the destination directory can't be created.CHANGELOG.md.Risk Assessment
✅ Low: The change is well-bounded hardening with thorough test coverage, and the previously-flagged cleanup race has been correctly resolved via the deferred close-event pattern; only a narrow, low-likelihood error-path gap remains.
Testing
Ran the existing
node:testsuite forpostinstall.js(4/4 pass) and, since unit pass alone isn't sufficient evidence, wrote and ran an end-to-end demo driving the actual exported downloader against a real local HTTP server. The transcript shows each intended hardening behavior as an end user/installer would experience it: a fresh install auto-creates the missingbin/and writes the binary after following a 302 redirect; an interrupted download is rejected and leaves no partial binary behind (the core fix); a blocked destination path is rejected gracefully with a clearprepare … failedmessage; and importing the module produces no download side effects (require.main guard). No UI surface is involved — this is a CLI/npm-lifecycle change, so the reviewer-visible evidence is the CLI transcript rather than a screenshot. Worktree left clean; temp download dirs were removed and only evidence files remain.Evidence: End-to-end postinstall downloader demo transcript
--- Scenario 1: download into a MISSING bin/ directory --- bin/ exists before download? false bin/ created automatically? true binary written? true content matches server? true (followed 302 redirect -> 200, then wrote 34 bytes) --- Scenario 2: connection drops mid-download (interrupted) --- download rejected? true rejection message: socket hang up partial binary left behind? false <-- must be false (cleaned up) --- Scenario 3: destination dir cannot be created (path blocked by a file) --- download rejected gracefully? true rejection message: prepare .../s3/not-a-dir/gsd-browser-bin failed: EEXIST: file already exists, mkdir '.../s3/not-a-dir' --- Scenario 4: importing postinstall.js as a module is side-effect free --- exports available: downloadFile=function, ensureDir=function (no "postinstall failed" output above => main() did not run on require)Evidence: Demo harness script
Pipeline
Updates from git push no-mistakes
⏭️ **intent** - skipped
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
npm/scripts/postinstall.js:65- On a write error (file.on("error")) or interrupted download (res.on("error")), the partially-written file atdestisdestroy()ed but never removed from disk. Becausemain()short-circuits onfs.existsSync(targetPath)and reports 'binary already present' (line 107-113), a truncated/corrupt binary left behind by a failed download is treated as valid on the nextnpm install, silently installing a broken executable. The fallback path within a single run truncates viacreateWriteStream, but a leftover partial from a fully-failed run persists across runs. Addfs.rmSync(dest, { force: true })in both error handlers before rejecting.npm/scripts/postinstall.js:44- On a 301/302 the redirect responseresis not consumed/drained before recursing, leaving the socket holding an unread body. Pre-existing in bothdownloadFileandfetchJSON; minor since the redirect target is GitHub and connections are short-lived, but draining (res.resume()) before recursing would be cleaner.🔧 Fix: remove partial binary on failed postinstall download
1 warning still open:
npm/scripts/postinstall.js:72- In both error handlers,file.destroy()is followed immediately by a synchronouscleanupPartial()(fs.rmSync(dest)).WriteStream.destroy()closes the underlying fd asynchronously, sormSyncruns while the fd is still open. On POSIX this is harmless (unlink of an open file succeeds), but on Windows (win32-x64 is a supported platform) removing a file with an open handle throws EBUSY, whichcleanupPartialsilently swallows. The partial binary then survives, and on the nextnpm installthefs.existsSync(targetPath)short-circuit in main() reports 'binary already present' — reintroducing exactly the stale-partial bug this change fixes, for Windows users. Additionally, rejecting before the fd closes means the fallbackdownloadFileto the samedestmay also hit a write conflict. Fix: defer cleanup/settle until the stream actually closes, e.g.file.once('close', () => { cleanupPartial(); settle(reject, err); }); file.destroy();🔧 Fix: defer partial cleanup until write stream closes
1 info still open:
npm/scripts/postinstall.js:95- The ClientRequest-level handler.on("error", reject)settles the promise directly, bypassing thesettle/cleanupPartial/failingmachinery used by theres.on("error")andfile.on("error")paths. If a connection error surfaces on the request after the response callback has already created the write stream (possible for some socket-reset timings), the promise rejects but the partially-written file atdestis neither destroyed nor unlinked — the same stale-partial class of bug this change otherwise fixes, just via a less-common error path. In practice post-response socket errors usually surface onres(which is handled), so this is narrow; cleanly fixing it would require hoistingfile/cleanupPartialto be reachable from the request error handler.✅ **Test** - passed
✅ No issues found.
node --test tests/postinstall.test.cjs— all 4 subtests pass (ensureDir recursion, download into missing bin/, partial-file cleanup on interrupted download, graceful rejection when dest dir can't be created)End-to-end demonode demo-postinstall.cjs <postinstall.js>exercising the real exporteddownloadFile/ensureDiragainst a live 127.0.0.1 HTTP server: 302→200 download into a missing bin/, mid-download socket drop, dir-creation blocked by a file, and module-import side-effect check✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.
Note
Low Risk
Scoped to npm install lifecycle and download error paths; behavior change is defensive hardening with unit tests, though a narrow request-level error path may still leave a partial file.
Overview
Hardens the npm postinstall binary downloader so failed installs are less likely to leave a broken executable or skip a real download on the next run.
downloadFilenow creates the destination parent directory immediately before opening the write stream (with clearprepare … failederrors), routes write/response failures through a singlefailpath that defersrmSyncof partial files until the write stream closes (important on Windows), and waits forclosebefore resolving successful writes.main()only runs when the script is executed directly (require.main === module); **ensureDir**, **downloadFile**, and **ensureDir** are exported for tests. Unused **execSync`** import was removed.Adds
tests/postinstall.test.cjs(fournode:testcases) and an [Unreleased] CHANGELOG entry for these fixes.Reviewed by Cursor Bugbot for commit deb8b17. Bugbot is set up for automated code reviews on this repo. Configure here.
Need help on this PR? Tag
/codesmithwith what you need. Autofix is enabled.