Skip to content

fix: publish native addon cache files atomically - #313

Merged
robertsLando merged 10 commits into
yao-pkg:mainfrom
jeffsee55:fix/atomic-native-addon-cache
Nov 2, 2026
Merged

robertsLando merged 10 commits into
yao-pkg:mainfrom
jeffsee55:fix/atomic-native-addon-cache

Conversation

@jeffsee55

@jeffsee55 jeffsee55 commented Oct 22, 2026 •

Copy link
Copy Markdown

Problem

cpRecursive() and the standalone-addon path in patchDlopen() write directly to shared cache files with fs.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 in prelude/bootstrap-shared.js:

  • Skip the write when dest already has the same bytes (hasSameContent: size check, then Buffer.equals). This replaces the two SHA-256 compares.
  • Otherwise write a sibling temp file (.pkg-<pid>-<random>.tmp, opened with 'wx' so it can't follow a planted symlink) and rename it over dest. A process that already mapped the old file keeps its inode and never sees it truncated.
  • On Windows, retry a rename refused with EPERM/EACCES/EBUSY for up to ~1s. Antivirus often holds a freshly written file open for a moment, and on a cold cache that would otherwise fail dlopen.
  • If the rename still fails (e.g. Windows refusing to replace an addon another process has loaded), accept the result only when dest already holds the exact same bytes. Otherwise rethrow the original error.
  • Remove our temp file on any failure, but never a temp name another writer created. A crash between write and rename can leave a .pkg-*.tmp behind; it is never loaded.
  • If the cache directory refuses the temp file (EACCES, e.g. a pre-provisioned read-only dir with writable files), fall back to the old in-place write.
  • Errors from writing the temp file name the addon being extracted, with the original error as cause.

Symlinks in a copied package (versioned .so links) get the same treatment in cpRecursive: 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, EEXIST is 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.ts runs the real shared prelude in a VM with a fault-injecting fs and a fake dlopen, against real temp directories. It covers, for both the node_modules and standalone layouts:

  • cold cache and truncated stale cache published via a sibling temp file opened with 'wx'

  • identical warm cache not rewritten

  • partial ENOSPC write: nothing published, temp removed

  • refused rename: existing bytes kept, temp removed

and once each:

  • rename race won with the same bytes (accepted) or different bytes (rejected)
  • Windows: retry of a briefly held file, giving up after 5 tries (also while a different-bytes winner holds dest), stopping early once a same-bytes winner appears; no retry on other platforms
  • read-only cache dir falls back to an in-place write
  • another writer's temp file is left alone
  • symlinks: a correct cached link is kept, one recreated first by another process is accepted, and the target is copied when symlinks are not allowed (EPERM)
  • unreadable destination keeps the original rename error
  • multi-file package: only the stale file is rewritten

Validation

  • yarn test:unit (314 pass)
  • yarn lint

Packaged-binary E2E and real Windows locking were not run locally; the Windows cases use fault injection.

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.
@robertsLando

Copy link
Copy Markdown
Member

Tring to close/reopen to see if ci starts, dunno what happened to GH

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 old writeFileSync(dest) from writing through a symlink at dest.
  • Temp cleanup runs on every failure path, and it never deletes a file this process didn't create.

Top risks

  1. The symlink branch of cpRecursive (line 152, not changed in this PR) still calls unlinkSync then symlinkSync. If two processes extract a package with versioned .so symlinks on first launch, one can get EEXIST. That code is rethrown, so dlopen fails. 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 treat EEXIST as success when readlinkSync shows the same target.
  2. 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.
  3. The compare-then-publish logic now exists in two places (cpRecursive and the standalone patchDlopen branch).

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.

Comment thread prelude/bootstrap-shared.js Resolved
Comment thread prelude/bootstrap-shared.js Resolved
Comment thread prelude/bootstrap-shared.js Resolved
Comment thread prelude/bootstrap-shared.js Resolved
Comment thread prelude/bootstrap-shared.js Resolved
Comment thread test/unit/native-addon-cache.test.ts Resolved
Comment thread test/unit/native-addon-cache.test.ts Resolved
Comment thread test/unit/native-addon-cache.test.ts Resolved
Comment thread test/unit/native-addon-cache.test.ts Resolved
Comment thread test/unit/native-addon-cache.test.ts Resolved
@jeffsee55

Copy link
Copy Markdown
Author

@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.
@robertsLando

Copy link
Copy Markdown
Member

finalize-pr: 6 rounds, no Blocker/Major left. CI not run (fork workflows need maintainer approval)

Fixed during the run

  • [Major] prelude/bootstrap-shared.js:windows-transient-rename-eperm-no-retry: on a cold cache, Windows antivirus can make the new rename fail. It now retries for up to ~1s on win32 only.
  • [Minor, pre-existing] symlink races in cpRecursive: an EEXIST with the same target is accepted, and a correct cached link is kept instead of being recreated.
  • [Minor] read-only cache dir falls back to an in-place write; no unlink of a temp file another writer created; temp-write errors name the addon.
  • 18 earlier review threads (shared syncCacheFile helper, trimmed tests), plus a test fixture that broke after main started reading process.versions.

Declined

  • [Minor] retry-loop-rechecks-dest-content: kept the same-bytes check inside the retry loop. Without it, two processes starting cold together wait ~1s.
  • [Minor] win32-retry-tests-really-sleep: the Windows retry tests sleep ~1.1s for real. Accepted.
  • [FYI] replacement-drops-existing-dest-mode: a replaced file gets default permissions. Nothing depends on the old mode.

Open notes from the last round (not fixed, review budget spent)

  • [Minor] the cpRecursive header still mentions "Per-file SHA-256 checksums". The check is now size, then bytes.
  • [Minor] no test for a win32 rename error outside the retry set (e.g. EXDEV) giving up on the first attempt.
  • [Nit] no test for a raced symlink with a different target, which should be rethrown.
  • [Minor, pre-existing] on the Windows copy fallback, cpRecursive still unlinks a correct copied file before rewriting it.

@codecov

codecov Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.62%. Comparing base (d6ee147) to head (579db67).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           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:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robertsLando
robertsLando merged commit 8ddf2aa into yao-pkg:main Nov 2, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants