Skip to content

fix: skip non-regular files in default readFile - #551

Merged
2wce merged 4 commits into
2wce:mainfrom
christowiz:bugfix/skip-non-regular-files-in-load
Oct 19, 2026
Merged

2wce merged 4 commits into
2wce:mainfrom
christowiz:bugfix/skip-non-regular-files-in-load

Conversation

@christowiz

@christowiz christowiz commented Oct 16, 2026 •

Copy link
Copy Markdown

Description

Make the default readFile check that a path is a regular file before reading it, so the load hook no longer returns directory contents as module source or hands a directory to the bundler's file watcher.

The problem

The load hook assumes that any id passing the include/exclude filter is a readable source file. With no options (sourcemaps()), createFilter(undefined, undefined) admits every id, and the default readFile calls fs.readFile(id, 'utf8') with no check that id is actually a file.

On APFS, ext4 and friends, reading a directory fails with EISDIR and the hook falls through to this.warn('Failed reading file') and returns null. macOS devfs is different: it is the one filesystem mounted at root whose driver permits read() on a directory. fs.readFile('/dev') succeeds and returns roughly 6.7 KB of raw directory records. The hook then:

  1. returns those bytes as module source, and
  2. calls this.addWatchFile('/dev'), handing devfs to the bundler's file watcher.
[vite] Internal server error: Failed to parse source for import analysis because the content contains invalid JS syntax.
  File: /dev
...
node:events:497
      throw er; // Unhandled 'error' event
Error: EBADF: bad file descriptor, lstat '/dev/fd/108'

How it surfaces.

In a Vite SPA with a client-side route named /dev, a hard navigation (typed URL, bookmark, refresh) sends GET /dev to the dev server.

Vite classifies an extensionless path with no trailing slash as a JS module request, and its transform middleware runs ahead of the SPA HTML fallback, so /dev reaches plugin load hooks as the module id. The directory bytes fail import analysis (HTTP 500, content contains invalid JS syntax), and the watcher then walks /dev/fd/*, hits EBADF on lstat of an fd that is not open in the Vite process, and because Vite registers no error listener on its watcher Node raises it as an unhandled event and the dev server exits.

Originally bisected on Vite 5.4.17, where bare Vite serves /dev correctly and enabling sourcemaps() alone reproduces the crash. The minimal repro confirms the crash from a browser navigation on Vite 5.4.21, 6.4.4 and 7.1.7. Vite 7.1.8 (vitejs/vite#20866) stopped routing browser navigations through the transform middleware, and 8.3.2 (vitejs/vite#23503) added a watcher error listener, so current 8.x only logs file watcher error: EBADF. The plugin's behaviour is the same on every version; the 5.x and 6.x lines are still maintained and still crash. Client-side navigation to the same route is unaffected because no request for the route path is ever made.

Reproduction

https://github.com/christowiz/rollup-plugin-sourcemaps2-devfs-repro

A three-file Vite project pinned to this plugin at 0.5.9 and Vite 7.1.7. pnpm install && pnpm dev, then open http://localhost:5173/dev in a browser on macOS.

The fix. defaultReadFile now calls fs.stat first and only proceeds to fs.readFile when stats.isFile() is true. A non-file path (directory, character device) fails the read with Not a regular file: <path>, so load takes its existing catch path: the query-suffix retry runs, then this.warn('Failed reading file') and return null. Neither the raw bytes nor the addWatchFile call ever happen.

Behaviour for real files is unchanged, fs.stat follows symlinks so symlinked sources keep working, and the guard lives inside the default reader only, so consumers who pass their own readFile (virtual filesystems, in-memory sources) see no change at all.

Changes Made

  • src/index.ts: wrap the default readFile in an fs.stat check and reject anything that is not a regular file. One new error path, no new options, no public API change. A comment explains the devfs case since it is the only reason the guard exists.
  • src/__tests__/index.test.ts: two tests plus a small helper, loadWithDefaultReadFile, that calls the plugin's load hook directly with a minimal PluginContext stand-in. Direct invocation is needed because every existing rollupBundle test injects a custom readFile, which replaces the code under test. The precedent is the existing "finds last source map file definition" test, which calls resolveSourceMap directly.
    • skips directories even when the filesystem lets them be read is the regression test. A plain temp directory is not enough: on APFS/ext4 fs.readFile on a directory already fails, so the old code was also green there. The test stubs fs.readFile to succeed (mimicking devfs) while leaving fs.stat real, then asserts the result is null, fs.readFile was never called, addWatchFile was never called, and warn was called with 'Failed reading file'. Reverting the fix makes this test fail with 'not a source file' instead of null.
    • reads regular files with the default readFile is the happy path. The default reader had no coverage before; this proves the stat wrapper did not break it (content returned, addWatchFile called once with the path, no warning).
    • Adds os and vi imports and a PluginContext type import to support the above. No new dependencies, no new lint directives.
  • biome.json: bump $schema from 2.5.0 to 2.5.15. Dependabot bumped @biomejs/biome to 2.5.15 in chore(deps-dev): bump @biomejs/biome from 2.5.14 to 2.5.15 #540 but the schema URL was left at 2.5.0, so every biome ci / biome check run (including pnpm build via prebuild) prints The configuration schema version does not match the CLI version 2.5.15 and suggests biome migrate. This is exactly what biome migrate produces: a one-line change to the $schema URL, no rule or formatter changes. It is info-level, not a failure. It is in its own commit so it can be dropped from this PR if you would rather take it separately.
  • .changeset/skip-non-regular-files.md: patch changeset so the release workflow opens a "Version packages" PR for this fix.

Related Issues

Screenshots (if applicable)

image

Checklist

  • I have tested the changes locally.
  • I have updated the documentation accordingly. (No README changes needed: no option or behaviour documented there changes. A code comment explains the guard.)
  • I have added appropriate comments where necessary.
  • All tests pass successfully. (vitest run: 13 passed. tsc: clean. biome ci .: clean.)
  • I have squashed and rebased my commits. (Rebased on main. Commits are deliberately kept as four logical units: fix, lint config, test, changeset. Squash at merge is fine.)

Additional Notes

  • The guard is deliberately in defaultReadFile rather than in load, so a custom readFile is never second-guessed by an fs.stat on a path that may not exist on disk.
  • fs.stat rather than fs.lstat: symlinked sources should resolve to their target and be read normally.
  • /dev/null and other character devices are rejected by the same isFile() check. No separate test; it exercises no additional line.
  • The regression test uses a temp directory plus a stubbed fs.readFile rather than the real /dev so the suite behaves identically on Linux CI and macOS.

@changeset-bot

changeset-bot Bot commented Oct 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6bea833

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
rollup-plugin-sourcemaps2 Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@2wce 2wce left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you, @christowiz, for the contribution and the clear reproduction! The fix is focused, preserves custom readers and symlinked sources, and includes a regression test that catches the original behavior. I verified all 13 tests, lint, type checking, and the build with the locked dependencies, and confirmed that the regression fails without the fix. Approved.

@2wce
2wce enabled auto-merge (squash) October 19, 2026 08:47
@2wce
2wce disabled auto-merge October 19, 2026 08:47
@2wce
2wce merged commit f104531 into 2wce:main Oct 19, 2026
6 checks 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.

Default readFile returns directory contents as module source on macOS (/dev), crashing Vite dev server

2 participants