Repository navigation
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.
| var dHash = createHash('sha256').update(dContent).digest('hex'); | ||
| if (hash !== dHash) { | ||
| fs.writeFileSync(tmpModulePath, moduleContent); | ||
| writeFileAtomic(tmpModulePath, moduleContent); |
There was a problem hiding this comment.
This branch repeats the "exists → read → compare → write" steps from the end of cpRecursive (lines 192-201). That makes three different ways to check whether the bytes match: hash both sides, compare against a hash computed earlier, and Buffer.equals inside writeFileAtomic.
Why: The atomic guarantee only holds if every cache write goes through the same path. A later fix to the skip-if-identical or publish logic that lands in only one copy would bring the truncation race back.
Fix: Move the compare-then-publish steps into one helper used by both call sites, and settle on one way to compare.
| // process may already have mapped into memory. The sibling temp file keeps | ||
| // rename on the same filesystem; randomness also separates worker threads | ||
| // that share a process.pid. | ||
| function writeFileAtomic(dest, content) { |
There was a problem hiding this comment.
About 14 lines of comments sit on a roughly 45-line private helper, and several of them restate the code. The try blocks also nest 4 deep, with a renamed flag.
Why: Someone reading this for the first time has to keep renamed, identical and the rethrow in their head at once.
Fix: Cut the header to one line, and keep only the NAME_MAX, Windows-rename and EEXIST comments. Pulling the "is it already the same bytes" check into a small named predicate would remove one nesting level.
| ); | ||
| // Only enter cleanup once we own the file. An EEXIST must not remove | ||
| // another writer's temporary file. | ||
| var fd = fs.openSync(temporary, 'wx'); |
There was a problem hiding this comment.
The open with 'wx', then write and close through the fd, exists so cleanup never deletes another writer's temp file after EEXIST. With pid + 48 random bits, that collision essentially can't happen.
Why: Two reviewers pulled in opposite directions here. Treating 'wx' as O_EXCL protection against a planted symlink at the temp path is reasonable, and it costs little, so keeping it is fine.
Fix: Optional. A plain writeFileSync(temporary, content) would save about 6 lines, plus the EEXIST test and its fixture branch.
| // Do not extend the destination basename: it may already be near NAME_MAX. | ||
| var temporary = path.join( | ||
| path.dirname(dest), | ||
| '.pkg-' + process.pid + '-' + randomBytes(6).toString('hex') + '.tmp', |
There was a problem hiding this comment.
If the process is killed between openSync and renameSync, a .pkg-<pid>-<hex>.tmp file is left in the cache. Nothing ever removes it, and in the node_modules layout it sits inside the copied package tree.
Why: The cache slowly takes up more disk space. Loading still works.
Fix: Accepting this is fine. A sentence in the PR or a short comment would be enough.
| try { | ||
| fs.renameSync(temporary, dest); | ||
| renamed = true; | ||
| } catch (error) { |
There was a problem hiding this comment.
On Windows, Defender or the search indexer often holds a newly written file open for a moment, so MoveFileEx fails with EPERM/EACCES/EBUSY. When the bytes differ (the cache-repair path), that error is rethrown right away. graceful-fs retries renames for this reason.
Why: This is rare, and the old writeFileSync could fail the same way, so it isn't a clear regression.
Fix: Optionally retry a few times on those codes before comparing the bytes.
| } | ||
|
|
||
| for (const layout of ['node_modules', 'standalone'] as const) { | ||
| describe(`native addon cache (${layout})`, () => { |
There was a problem hiding this comment.
The node_modules layout copies a package that has exactly one file, so cpRecursive is only tested on a single file in a single directory.
Why: A bug that only shows up with several files (a shared temp name, or stopping at the first match) would not be caught.
Fix: Add a variant with at least two files, one of them in a nested directory, where one is identical and one is stale. Assert that only the stale one is rewritten and no .pkg-*.tmp files remain.
| f.assertCache(content); | ||
| }); | ||
|
|
||
| it('uses a fresh sibling filename for each cache repair', (t) => { |
| }); | ||
| } | ||
|
|
||
| it("does not remove another writer's temporary file on open EEXIST", (t) => { |
|
|
||
| it('publishes an addon with a 240-byte basename', (t) => { | ||
| // A valid basename near NAME_MAX must not be extended with a temp suffix. | ||
| const filename = `${'a'.repeat(235)}.node`; |
| const sharedSource = fs.readFileSync(sharedPath, 'utf8'); | ||
| const requireBuiltin = createRequire(__filename); | ||
| // Synthetic bytes only: the fake dlopen below never loads a native library. | ||
| const content = Buffer.from([0, 1, 2, 3, 4, 5, 255, 0]); |
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
Add a shared
writeFileAtomic()helper for all three regular-file write sites:wx.EEXIST.The helper is shared by the traditional bootstrap, SEA main bootstrap, and SEA workers. Generated SEA bundles are rebuilt by the existing build script and remain untracked.
Tests
Add 24 deterministic cases using the actual shared prelude, isolated VM contexts, real temporary files, and a fake
dlopenthat never loads a native library. Both package-directory and standalone-addon extraction paths cover:ENOSPCwrites and descriptor/temp-file cleanupBefore the implementation change, the initial 22-case suite had 20 failures and only the two warm-cache cases passed. All 24 final cases pass with the fix.
Validation
Passed locally:
yarn build(including regenerated SEA main/worker bundles)node -r esbuild-register --test --test-reporter=dot 'test/unit/**/*.test.ts'(full unit suite)node -r esbuild-register --test --test-reporter=spec test/unit/native-addon-cache.test.ts(24 passed)yarn --silent prettier --check prelude/bootstrap-shared.js test/unit/native-addon-cache.test.tsyarn --silent eslint prelude/bootstrap-shared.js test/unit/native-addon-cache.test.tsyarn --silent tsc --noEmit --target es2022 --module commonjs --moduleResolution node --esModuleInterop --skipLibCheck --strict --types node test/unit/native-addon-cache.test.tsgit diff --checkExisting baseline failure:
yarn lintpasses formatting but fails onprelude/bootstrap.js:597becausehomediris undefined. The same ESLint failure was confirmed against the unchanged file at base commit43feb8bb. This PR does not modify that file; #309 addresses the separate cache-location/import issue.Packaged-binary E2E tests and real Windows locking were not run locally; the Windows rename race is covered through deterministic fault injection.