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.
Describe the bug
On an OrbStack bind mount,
link(2)targeting a destination inside a directory namednode_modulesreturns 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_MODULESandNode_Modulesall reproduce it, while.node_modules,notnode_modulesandnode_modules_xall link correctly. Anynode_modulescomponent 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 insidenode_modulessilently degrades to full copies. That covers Yarn'snmMode: hardlinks-globalandhardlinks-local, Yarn'snodeLinker: pnpm(whose store lives atnode_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:
Observed (run inside a Debian 13 container with the same bind-mount setup):
node_modules/lhas 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/lbehaves correctly in the same run, on the same mount, from the same source file.cp -lbehaves identically, so this is the syscall rather than a quirk ofln.The same divergence is present on the host.
Expected behavior
link()into anode_modulesdirectory 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 —EPERMorEXDEV— 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
7.0.11-orbstack-00360-gc9bc4d96ac70virtiofs, optionsrw,relatime, sharing a directory under/UsersBoundaries, all verified in the same session
node_modules/NODE_MODULES/,Node_Modules/sub/node_modules/a/node_modules/deep/deeper/.node_modules/notnode_modules/,node_modules_x/plain/,deep/a/b/c/node_modules, destination outside/var/tmp/node_modules)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_modulesin the file-sharing layer, presumably a performance optimisation, whose path does not implementlink(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
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.