Spend one host descriptor on a directory fd
Summary
The host descriptor budget in src/elfuse-limits.h is FD_TABLE_SIZE guest fds plus HOST_FD_RESERVE for elfuse's own plumbing, and that arithmetic assumes one host descriptor per guest fd. A directory took two. fd_alloc_opened_host dup'd the descriptor so fdopendir could adopt one copy while the fd table kept the other, and the duplicate lived as long as the guest fd rather than as long as a call.
Measured with the host limit at the minimum elfuse itself raises it to, which is what any launch from a lower soft limit gets:
| before | after | Linux 6.12, RLIMIT_NOFILE 1024 | |
|---|---|---|---|
open(dir, O_DIRECTORY) until EMFILE | 636 | 1021 | 1021 |
open a plain file until EMFILE | 1021 | 1021 | 1021 |
400 open + dup pairs on a directory | 318 pairs | 400 pairs | 400 pairs |
Overrunning the table is the visible half. The other half is that the overflow comes out of the reserve, so the first thing to break is not the open that caused it: with 636 directories held, fork failed inside src/runtime/forkipc.c with socketpair returning EMFILE, and the guest saw ENOMEM from a fork a real kernel completes. HOST_NOFILE_MIN is unchanged — this spends fewer descriptors rather than asking for more.
Why the duplicate was there, and why it could not just go
fdopendir adopts the descriptor it is handed, and only closedir gives it back. A slot that also closed its own host_fd would close it twice, so two owners with independent lifetimes needed two descriptors. That is a real constraint, not an oversight: an in-flight getdents64 must keep reading after a sibling closes the guest fd.
fdclosedir(3) returns the descriptor without closing it and would remove the need for a second owner outright. It is macOS 26.4, and this tree supports macOS 13, so it is not available.
So the ownership goes the other way instead. The stream owns the descriptor, and everything that needs it to stay valid holds a reference on the stream rather than a descriptor of its own. There are three such holders: the fd table slot, an in-flight getdents64 (which already took one), and an fd_lifetime pin, which now delegates its close to the stream instead of calling close itself. The descriptor returns to the host at the stream's last release, so dir_stream_release is the single point where a directory fd is given back.
Ordering, and why the stream is part of the slot's identity
Two steps in fd_cleanup_entry are keyed by the descriptor's number rather than by the slot: the pty side tables and the readiness watch. The slot's reference on the stream is therefore released after both, so the number cannot be recycled underneath them.
A slot published as FD_DIR before its stream exists is a slot whose descriptor looks plain to a concurrent close, which would close it and leave the opening thread's stream sitting on a number the next open can claim. Both creation paths — openat and dup — now build the stream first and publish it in the fd_lock window that creates the slot, which is what the fd_alloc_dir entry points already do for FD_EPOLL. The alias allocators hand back the allocation generation so the metadata install that follows can still prove the slot is its own, and install_fd_alias_metadata_atomic no longer installs the stream at all.
fd_alloc_opened_host now takes ownership of host_fd on every path including its failures, because for a directory that transfer is mandatory rather than tidy: once fdopendir has succeeded there is no way to hand the descriptor back. Its four callers no longer close on failure.
One host behaviour stops being invisible once the descriptor is shared: Darwin's fdopendir sets FD_CLOEXEC on the descriptor it adopts. That was harmless on a private duplicate. On the guest's own descriptor it flips a flag elfuse never asked for, so dir_stream_open clears it back — the guest's O_CLOEXEC lives in fd_entry_t.linux_flags, never on the host descriptor.
Test
tests/test-dir-fd-budget.c pins the budget from both directions, how many directories fit and whether dup'ing them fits too, and runs at host_nofile=elfuse-minimum where the doubling has nowhere to hide. Against the parent commit it reports 636 of a promised 1021 and 318 of 400 pairs. It is registered in the matrix rather than exempted, because the reference kernel agrees with every number it asserts.
tests/test-getdents-refcount.c already covers the window this change makes load-bearing — close/dup2 racing an in-flight getdents64 — and passes unchanged.
What this does not fix
A dup'd directory fd reads as an empty stream: getdents64 on it returns 0 entries, not even . and .., where Linux lists the directory. This predates the change and is unrelated to descriptor accounting — verified by running the same probe against the parent commit, which behaves identically. The test above therefore checks the surviving dup by using it as an openat anchor rather than by reading it, so it pins ownership without depending on that defect.
/dev/ptmx is the other guest fd that costs two host descriptors, since elfuse holds a keepalive slave alongside the master. That one is bounded by the host's kern.tty.ptmx_max rather than by the descriptor budget, so it is a different problem and is left alone here.
Checks
Based on 300bd1d.
make check, rc=0: 90 in the manifest suite, 7/7, 82/84 (the two skips need a raw socket and an interactive terminal), 27/27, 4/4, 6/6, guardrail PASS.
make test-matrix, rc=0: elfuse-aarch64 271 passed, 0 failed; qemu-aarch64 250 passed, 0 failed. test-dir-fd-budget passes in both lanes.
make lint, rc=0, with no new clang-tidy warning on a touched line. make check-format, rc=0.
Summary by cubic
Spends one host descriptor per directory fd instead of two, so directory-heavy workloads reach the full guest fd table and stop draining the host descriptor reserve that fork and other elfuse plumbing depend on.
- The directory stream now owns the slot's descriptor and closes it at its last release; an in-flight
getdents64or anfd_lifetimepin holds a stream reference instead of a duplicate descriptor, and fork restore adopts the received descriptor the same way. - The stream is published in the slot's creation window so a concurrent close never sees a directory entry whose descriptor looks plain.
- Darwin's
fdopendirsetsFD_CLOEXECon the descriptor it adopts, sodir_stream_openclears it back; the guest'sO_CLOEXECstill lives only infd_entry_t.linux_flags. - Adds
tests/test-dir-fd-budget.c, which pins the one-descriptor budget at the minimum host limit and reports 636 of a promised 1021 directories before the fix.