Repository navigation
fix: publish native addon cache files atomically - #313
Conversation
Stage extracted files in exclusively created sibling temporary files before renaming them into the shared native-addon cache. Preserve mapped destination files, clean up failed writes and renames, and only accept an identical concurrent winner. Add deterministic regression tests for package and standalone addon extraction, descriptor cleanup, rename races, temporary-file ownership, and long filenames.
|
Tring to close/reopen to see if ci starts, dunno what happened to GH |
robertsLando
left a comment
There was a problem hiding this comment.
Code review
Verdict: Ship with minor changes. There are no blocking or major issues.
The approach is right. Writing to a temp file and then renaming it into place fixes the real truncation/SIGBUS bug on POSIX. The Windows fallback, which accepts an existing file only when the bytes are identical, is the right rule for a cache whose folders are named by content hash. The repo has no existing atomic-write helper this could reuse, and prelude/ can't pull in npm packages.
Strengths
- Readers can no longer see a partly written addon.
- The
'wx'open with a random temp name can't follow a planted symlink. Renaming also stops the oldwriteFileSync(dest)from writing through a symlink atdest. - Temp cleanup runs on every failure path, and it never deletes a file this process didn't create.
Top risks
- The symlink branch of
cpRecursive(line 152, not changed in this PR) still callsunlinkSyncthensymlinkSync. If two processes extract a package with versioned.sosymlinks on first launch, one can getEEXIST. That code is rethrown, sodlopenfails. This race existed before the PR, but it is now the one cache write the PR doesn't make safe for concurrent processes. A follow-up or a sentence in the PR would cover it. Possible fixes: publish the link under a temp name and rename it into place, or treatEEXISTas success whenreadlinkSyncshows the same target. - The test file is about 7x the size of the fix and checks the exact syscall sequence, so harmless refactors will break it. Details are in the inline comments.
- The compare-then-publish logic now exists in two places (
cpRecursiveand the standalonepatchDlopenbranch).
FYI (not from this PR): PKG_NATIVE_CACHE_PATH can point at a folder other users can write to, such as /tmp. Another local user could then swap the addon between the hash check and dlopen. This was the same before the PR. A possible hardening step later: create the cache folder with mode 0o700.
Note on CI: CI on this PR currently fails in build_artifact. The failure is a lint error in prelude/bootstrap.js (homedir is not defined), and it comes from main, not from this PR. A fix PR for main is coming, and CI will be re-run here after it merges.
|
@robertsLando do you want me to fixup the code for each of those comments? |
Share one syncCacheFile helper between cpRecursive and the standalone dlopen path, compare bytes with Buffer.equals after a size check, and drop the fd plumbing. Trim the tests to behaviour checks and add the unreadable-destination and multi-file package cases. Also give the test's fake process a versions field: main now reads process.versions when bootstrap-shared.js loads.
Retry a refused rename briefly on Windows, where antivirus can hold the just-written temp file open and a cold cache would otherwise fail dlopen. Seed the stale-cache tests with a truncated file so the size check is covered.
- Accept EEXIST when recreating a symlink if another process already created the same link (concurrent first launch). - Update in place when the cache dir refuses the temp file (EACCES). - Never unlink a temp name another writer created (open EEXIST). - Name the addon in errors from writing the temp file. - Test the Windows retry bound, the in-loop winner check, and the 'wx' flag.
- Keep a cached symlink that already points at the right target instead of deleting and recreating it, so a concurrent start can't lose it. - Test the symlink EPERM copy fallback, the Windows retry with a different-bytes winner, and the write/rename failures on both layouts.
finalize-pr: 6 rounds, no Blocker/Major left. CI not run (fork workflows need maintainer approval)Fixed during the run
Declined
Open notes from the last round (not fixed, review budget spent)
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #313 +/- ##
=======================================
Coverage 87.62% 87.62%
=======================================
Files 23 23
Lines 7926 7926
Branches 1214 1214
=======================================
Hits 6945 6945
Misses 974 974
Partials 7 7 🚀 New features to boost your workflow:
|
Problem
cpRecursive()and the standalone-addon path inpatchDlopen()write directly to shared cache files withfs.writeFileSync(). Concurrent packaged processes or workers can both decide that a file needs extraction. One can then truncate the cached addon after another has loaded or memory-mapped it, potentially causing SIGBUS/SIGSEGV or exposing partial contents.Change
Both write sites now go through one
syncCacheFile(dest, content)helper inprelude/bootstrap-shared.js:destalready has the same bytes (hasSameContent: size check, thenBuffer.equals). This replaces the two SHA-256 compares..pkg-<pid>-<random>.tmp, opened with'wx'so it can't follow a planted symlink) and rename it overdest. A process that already mapped the old file keeps its inode and never sees it truncated.EPERM/EACCES/EBUSYfor up to ~1s. Antivirus often holds a freshly written file open for a moment, and on a cold cache that would otherwise faildlopen.destalready holds the exact same bytes. Otherwise rethrow the original error..pkg-*.tmpbehind; it is never loaded.EACCES, e.g. a pre-provisioned read-only dir with writable files), fall back to the old in-place write.cause.Symlinks in a copied package (versioned
.solinks) get the same treatment incpRecursive: a cached link that already points at the right target is kept instead of deleted and recreated, so a concurrent start can't lose it mid-dlopen. If two processes create it at once,EEXISTis accepted when the existing link has the same target.The helper is shared by the traditional bootstrap, SEA main bootstrap, and SEA workers. Cache layout and extraction behavior are unchanged.
Tests
test/unit/native-addon-cache.test.tsruns the real shared prelude in a VM with a fault-injectingfsand a fakedlopen, against real temp directories. It covers, for both thenode_modulesand standalone layouts:cold cache and truncated stale cache published via a sibling temp file opened with
'wx'identical warm cache not rewritten
partial
ENOSPCwrite: nothing published, temp removedrefused rename: existing bytes kept, temp removed
and once each:
dest), stopping early once a same-bytes winner appears; no retry on other platformsEPERM)Validation
yarn test:unit(314 pass)yarn lintPackaged-binary E2E and real Windows locking were not run locally; the Windows cases use fault injection.