Skip to content

link(2) into a directory named node_modules on a bind mount silently creates a copy instead of a hard link #2622

Description

@merlinorg

Describe the bug

On an OrbStack bind mount, link(2) targeting a destination inside a directory named node_modules returns success but creates an independent copy rather than a hard link. The new file has its own inode and a link count of 1, and the source's link count does not change. Writes to one are not visible through the other.

The match is on the destination path only, and it is case-insensitive: node_modules, NODE_MODULES and Node_Modules all reproduce it, while .node_modules, notnode_modules and node_modules_x all link correctly. Any node_modules component anywhere above the destination is enough. The same test on non-shared storage inside the VM (/var/tmp) links correctly, so this is specific to the shared filesystem rather than to the guest kernel.

Because the call reports success, nothing above it can detect the failure — no EXDEV, no error, no warning. The practical effect is that any tool relying on hard links inside node_modules silently degrades to full copies. That covers Yarn's nmMode: hardlinks-global and hardlinks-local, Yarn's nodeLinker: pnpm (whose store lives at node_modules/.store), and pnpm's content-addressable store, which is built entirely on this mechanism.

I found this because a project that had been sharing one inode across 14 checkouts stopped doing so after switching to OrbStack. An instrumented Yarn install made 132,483 link() calls, every one returning success, and produced 132,540 files each with a link count of 1. Disk cost for that one project went from about 60 MB per checkout to about 1.3 GB.

To Reproduce

From a directory on your Mac that is shared into containers:

d=$(mktemp -d ~/orbstack-hardlink-repro.XXXXXX) && cd "$d"

docker run --rm -v "$PWD:/mnt" alpine sh -c '
  cd /mnt || exit 1
  mkdir node_modules plain
  echo content > src

  ln src node_modules/l
  ln src plain/l

  echo "src            : $(stat -c "ino=%i nlink=%h" src)"
  echo "node_modules/l : $(stat -c "ino=%i nlink=%h" node_modules/l)"
  echo "plain/l        : $(stat -c "ino=%i nlink=%h" plain/l)"

  echo changed > src
  echo "after rewriting src:"
  echo "  node_modules/l reads: $(cat node_modules/l)"
  echo "  plain/l        reads: $(cat plain/l)"
'

Observed (run inside a Debian 13 container with the same bind-mount setup):

src            : ino=4044908 nlink=2
node_modules/l : ino=4044909 nlink=1
plain/l        : ino=4044908 nlink=2
after rewriting src:
  node_modules/l reads: content
  plain/l        reads: changed

node_modules/l has a different inode, a link count of 1, and keeps the stale contents — so it is a genuine independent copy, not a stat-cache artefact. plain/l behaves correctly in the same run, on the same mount, from the same source file.

cp -l behaves identically, so this is the syscall rather than a quirk of ln.

The same divergence is present on the host.

Expected behavior

link() into a node_modules directory should behave exactly as it does into any other directory: the destination shares the source's inode, both link counts increment, and writes through one path are visible through the other. Failing that, it should return an error — EPERM or EXDEV — rather than reporting success for an operation it did not perform.

Diagnostic report (REQUIRED)

OrbStack info:
Version: 2.2.1
Commit: 0e182b501fcd9e05b99ffb363fce03610390c400 (v2.2.1)

System info:
macOS: 26.5.1 (25F80)
CPU: arm64, 10 cores
CPU model: Apple M1 Max
Model: MacBookPro18,2
Memory: 64 GiB

Full report: https://orbstack.dev/_admin/diag/orbstack-diagreport_2026-08-02T03-27-43.278388Z.zip

Screenshots and additional context (optional)

Environment

  • OrbStack: 2.2.1 (build 20628), the current release
  • macOS: 26.5.1 (25F80) (Apple Silicon)
  • Guest: Debian GNU/Linux 13 (trixie), aarch64
  • Guest kernel: 7.0.11-orbstack-00360-gc9bc4d96ac70
  • Mount: virtiofs, options rw,relatime, sharing a directory under /Users

Boundaries, all verified in the same session

destination result
node_modules/ copy
NODE_MODULES/, Node_Modules/ copy (match is case-insensitive)
sub/node_modules/ copy
a/node_modules/deep/deeper/ copy (any ancestor component matches)
.node_modules/ link
notnode_modules/, node_modules_x/ link
plain/, deep/a/b/c/ link
source inside node_modules, destination outside link (only the destination matters)
same test on in-VM storage (/var/tmp/node_modules) link (shared-filesystem-specific)

Analysis — inference, not established fact

I have no visibility into OrbStack's internals, so this is a guess from the outside: the shape of it (an exact, case-insensitive match on one directory name, only on the shared filesystem) looks like a deliberate special case for node_modules in the file-sharing layer, presumably a performance optimisation, whose path does not implement link(2) faithfully and falls back to a copy while still reporting success. Everything above this line is measured; this paragraph is not.

Related, but not duplicates

  • #1424 — hard links on bind mounts in 1.7.1. Same subject area, opposite failure mode: that one failed loudly (ln: ... Invalid argument) across the whole mount, and was closed against the 1.7.2 milestone. This one succeeds, and is scoped to a single directory name.

Why it is worth more than its performance cost

The performance and disk consequences are real but recoverable. The part I would flag is that a syscall reports success for something it did not do, which makes the failure undetectable from above. Anything that relies on hard-link semantics for correctness rather than for speed — deduplication that assumes a shared inode, link-count checks, atomic-swap patterns — will get wrong behaviour here with no error anywhere in the stack.


Disclosure: I used an AI assistant (Claude Code) to investigate this bug and to draft this report. The reproduction was run on my machine and I have verified the commands, the output, and every claim above; the analysis section is labelled as inference for the same reason. Follow-up questions are mine to answer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    t/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions