Skip to content

feat: fall back to same-dir write when rename is cross-device (EXDEV)#6

Merged
gxkl merged 2 commits into
masterfrom
fix/exdev-fallback
Jul 15, 2026
Merged

feat: fall back to same-dir write when rename is cross-device (EXDEV)#6
gxkl merged 2 commits into
masterfrom
fix/exdev-fallback

Conversation

@gxkl

@gxkl gxkl commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

set() writes to a fixed <cacheDir>/.tmp/<uuid> and then fs.renames it onto the target file to get an atomic write. When .tmp and the target file are on different filesystems (e.g. .tmp is a separate mount), fs.rename throws EXDEV: cross-device link not permitted and the write fails outright:

Error: EXDEV: cross-device link not permitted,
  rename '<cacheDir>/.tmp/<uuid>' -> '<cacheDir>/foo.txt'

Change

Add a fallback (on by default) that, on EXDEV, writes a temp file in the target's own directory and renames it there. A same-directory rename is always on one filesystem, so it avoids EXDEV while keeping the atomic replace.

  • new option fallback (default true; set false to keep the old throw behavior)
  • new option fallbackTmpfileName (defaults to a random name; a fixed name is only safe when concurrent writes to the same directory can't happen)
  • fallback logic isolated in the private _renameWithFallback method
  • temp files are cleaned up on every failure branch (including a failed fallback write/rename)

Tests

Added cases under EXDEV fallback:

  • falls back to same-dir write when rename is cross-device
  • uses a custom fallbackTmpfileName (asserts the exact fallback path is used)
  • cleans up and rethrows when the fallback rename also fails
  • throws EXDEV when fallback: false

npm test — lint clean, 10 passing.

Summary by CodeRabbit

  • New Features

    • Added configurable fallback handling for cross-device file rename errors.
    • Added options to enable or disable the fallback and customize its temporary filename.
    • Exposed the current fallback settings for inspection.
  • Bug Fixes

    • File writes now retry safely in the destination directory when cross-device renames fail.
    • Temporary files are cleaned up after successful or failed operations.
  • Documentation

    • Documented the new fallback options and concurrency considerations.

set() writes to a fixed <cacheDir>/.tmp/<uuid> and renames it onto the
target for atomicity. When .tmp and the target file are on different
filesystems, fs.rename throws EXDEV and the write fails.

Add a fallback (on by default) that, on EXDEV, writes a temp file in the
target's own directory and renames it there. A same-directory rename stays
on one filesystem, so it avoids EXDEV while keeping the atomic replace.

- new option `fallback` (default true, set false to keep throwing)
- new option `fallbackTmpfileName` (defaults to a random name; a fixed name
  is only safe without concurrent writes to the same directory)
- fallback logic isolated in the private `_renameWithFallback` method
- tests for the fallback, custom name, disabled, and fallback-failure paths
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@gxkl, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 78a709b8-bb6e-45d7-8bf2-cdf7d3bfd9ea

📥 Commits

Reviewing files that changed from the base of the PR and between 153fd19 and dcee3e7.

📒 Files selected for processing (1)
  • .github/workflows/nodejs.yml
📝 Walkthrough

Walkthrough

DiskStore adds configurable handling for EXDEV rename failures, including a same-directory temporary-file retry, cleanup behavior, public getters, documentation, and tests for success, custom filenames, failure propagation, and disabled fallback.

Changes

EXDEV fallback handling

Layer / File(s) Summary
Fallback options and public configuration
lib/index.js, README.md
Documents fallback and fallbackTmpfileName, exposes both values through DiskStore getters, and describes their defaults and usage.
Fallback rename flow and validation
lib/index.js, test/index.test.js
Retries EXDEV failures using a target-directory temporary file, removes temporary files during success and error paths, and tests configurable, failing, and disabled fallback behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DiskStore.set
  participant fs.rename
  participant DiskStore._renameWithFallback
  participant TargetDirectory
  DiskStore.set->>fs.rename: rename primary temp file
  fs.rename-->>DiskStore.set: EXDEV error
  DiskStore.set->>DiskStore._renameWithFallback: retry fallback
  DiskStore._renameWithFallback->>TargetDirectory: write fallback temp file
  DiskStore._renameWithFallback->>fs.rename: rename fallback temp file
  fs.rename-->>DiskStore._renameWithFallback: success or error
  DiskStore._renameWithFallback-->>DiskStore.set: result or propagated error
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding an EXDEV cross-device rename fallback.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/exdev-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an EXDEV fallback mechanism to DiskStore to handle cross-device rename failures. When a rename fails with EXDEV, the store can now fallback to writing a temporary file in the target file's own directory before renaming it, ensuring an atomic replacement. This behavior is configurable via the new fallback and fallbackTmpfileName options. The changes also include comprehensive unit tests. The review feedback highlights a critical issue where using a static, shared fallbackTmpfileName for concurrent writes in the same directory can lead to race conditions and data corruption. It is recommended to append a unique identifier, such as a UUID, to the custom fallback file name to prevent these conflicts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread lib/index.js
async _renameWithFallback(dir, filepath, data) {
const fallbackTmpfile = path.join(
dir,
this.fallbackTmpfileName || `.${path.basename(filepath)}.${crypto.randomUUID()}.tmp`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using a static, shared fallbackTmpfileName across different target files in the same directory can lead to severe race conditions and data corruption during concurrent writes. For example, if store.set('dir/fileA') and store.set('dir/fileB') run concurrently, both will attempt to write to and rename the exact same temporary file path (dir/fallbackTmpfileName), causing one to overwrite the other's data or fail with ENOENT.

To prevent this, we should ensure that the temporary file name is always unique per write operation, even when a custom name/prefix is provided. Appending a unique identifier (like a UUID) to the custom name is a robust way to avoid these conflicts.

      this.fallbackTmpfileName
        ? `${this.fallbackTmpfileName}.${crypto.randomUUID()}`
        : `.${path.basename(filepath)}.${crypto.randomUUID()}.tmp`

macos-latest is now Apple Silicon (arm64) and Node.js 14 has no macOS
arm64 build, so `macos-latest x 14` fails at the setup-node step. Split
the matrix so 16/18 keep running on all three OS while 14 runs on Linux
and Windows only.
@gxkl gxkl requested review from fengmk2 and killagu July 14, 2026 12:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
test/index.test.js (1)

142-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider using assert.rejects for cleaner async error testing.

Both sites use a verbose try/catch block combined with an intentional assert(false) to validate that a promise rejects. Consider using assert.rejects for a more idiomatic and concise approach to testing async errors.

  • test/index.test.js#L142-L147: Simplify the rejection check for the fallback rename error.
  • test/index.test.js#L162-L167: Simplify the rejection check for the EXDEV error.
💡 Proposed refactors

For test/index.test.js#L142-L147:

-      try {
-        await diskStore.set('exdev-fail/a', 'v');
-        assert(false, 'should not run here');
-      } catch (err) {
-        assert(err.message === 'mock fallback rename error');
-      }
+      await assert.rejects(
+        diskStore.set('exdev-fail/a', 'v'),
+        err => err.message === 'mock fallback rename error'
+      );

For test/index.test.js#L162-L167:

-      try {
-        await store.set('exdev-off', 'v');
-        assert(false, 'should not run here');
-      } catch (err) {
-        assert(err.code === 'EXDEV');
-      }
+      await assert.rejects(
+        store.set('exdev-off', 'v'),
+        err => err.code === 'EXDEV'
+      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/index.test.js` around lines 142 - 147, Replace the try/catch plus
intentional assert(false) rejection checks in test/index.test.js at lines
142-147 and 162-167 with assert.rejects assertions. Preserve each test’s
existing promise operation and expected error message: “mock fallback rename
error” at the anchor site and the EXDEV error at the sibling site.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/index.test.js`:
- Around line 142-147: Replace the try/catch plus intentional assert(false)
rejection checks in test/index.test.js at lines 142-147 and 162-167 with
assert.rejects assertions. Preserve each test’s existing promise operation and
expected error message: “mock fallback rename error” at the anchor site and the
EXDEV error at the sibling site.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8806322-f690-4c4d-9d71-b20815839fa1

📥 Commits

Reviewing files that changed from the base of the PR and between fb99247 and 153fd19.

📒 Files selected for processing (3)
  • README.md
  • lib/index.js
  • test/index.test.js

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

LGTM

@gxkl gxkl merged commit f517258 into master Jul 15, 2026
13 checks passed
@gxkl gxkl deleted the fix/exdev-fallback branch July 15, 2026 01:50
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