Skip to content

Report exhausted host fds as ENOMEM, not EBADF - #323

Open
xalestar wants to merge 1 commit into
sysprog21:mainfrom
xalestar:poll-fd-exhaustion-enomem
Open

Report exhausted host fds as ENOMEM, not EBADF#323
xalestar wants to merge 1 commit into
sysprog21:mainfrom
xalestar:poll-fd-exhaustion-enomem

Conversation

@xalestar

@xalestarxalestar commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

ppoll and pselect6 duplicate every descriptor in a wait list while sibling threads are alive. Running the host descriptor table out that way reached the guest as POLLNVAL or EBADF on descriptors that were still open, and the standard reaction to both is to close them. Exhaustion returns ENOMEM now, which is what Linux reports for a wait whose tables it cannot build.

Follow-up to #316 and #318, which covered descriptors the host poll() refuses. This one is about descriptors the host never got to look at.

Measured

Host soft limit 1280, one temp file duped until EMFILE. Single-threaded guests borrow rather than duplicate, so the second thread is what triggers it.

 main (628181b) this branch
ppoll(nfds=256) ret=256 POLLNVAL=4 ret=-1 ENOMEM POLLNVAL=0
pselect(nfds=1024) ret=-1 EBADF ret=-1 ENOMEM
still open afterwards 256/256, 1021/1021 unchanged

Scope notes

The other callers inherit the split.host_fd_ref_open_io{,_gen,_state} has 24 call sites, 22 outside the wait paths, and all but fuse.c propagate the return value. read, ioctl, splice, copy_file_range, fsync and friends answer an exhausted host table with ENOMEM now. They were returning EBADF for a descriptor that is open, so the path was already wrong; ENOMEM is undocumented for a couple of them (read, lseek) and documented for others (splice, copy_file_range, process_vm_readv). No existing test depended on the old value.

host_fd_ref_open is left alone. The 0/-1 spelling next door collapses the same two failures across 54 call sites that each pick their own errno. That is a return-convention change, not an added case.

Descriptor usage is unchanged. A wait consumes threads x list length, which HOST_FD_RESERVE's two-per-thread budget does not cover, as the comment in src/elfuse-limits.h notes. The call still fails; it just stops blaming the caller's descriptors.

Test plan

tests/test-poll-fd-exhaustion.c holds a sibling thread open, fills the descriptor table, and requires that no still-open descriptor comes back POLLNVAL and that pselect6 does not fail with EBADF. In tests/manifest.txt with host_nofile=elfuse-minimum, and in tests/test-matrix.sh.

CheckResult
new test vs main (628181b)2 failed
new test vs this branch2 passed
new test vs real kernel, qemu-system-aarch642 passed
make check, make check-format, all six .ci/*.shrc=0
build/test-poll22 passed

The reference-kernel run is what backs the matrix entry: a real kernel never duplicates here, so the test asserts Linux behaviour rather than an elfuse-specific expectation.


Summary by cubic

Report host FD table exhaustion as ENOMEM instead of EBADF/POLLNVAL. Previously, ppoll marked entries POLLNVAL and pselect6 failed with EBADF when duplication ran out; now both return ENOMEM, preventing valid descriptors from being closed and matching Linux.

Notes for review

  • Split failures in host_fd_ref_open_io_state: return ENOMEM when dup() fails after a live snapshot; return EBADF for closed, out-of-range, or O_PATH descriptors.
  • Update ppoll/pselect6 to propagate ENOMEM on mid-list exhaustion and avoid emitting POLLNVAL/EBADF for open descriptors; other callers of host_fd_ref_open_io{,_gen,_state} inherit ENOMEM on host table exhaustion, while host_fd_ref_open is unchanged.
  • Add tests/test-poll-fd-exhaustion.c and wire it into the build/matrix to verify no POLLNVAL/EBADF on open descriptors and ENOMEM on exhaustion.

Written for commit 7b738af. Summary will update on new commits.

Review in cubic

@cubic-dev-aicubic-dev-aiBot 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.

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadsrc/syscall/internal.h
Comment threadtests/test-poll-fd-exhaustion.c Outdated
Comment threadtests/test-poll-fd-exhaustion.c Outdated
Comment threadsrc/syscall/internal.h
A guest thread's wait list is duplicated descriptor by descriptor while
siblings are alive, so a long list can run the host table out partway
through. fd_snapshot_and_dup reports that with the same -1 it uses for a
slot that was never open, and host_fd_ref_open_io_state turns both into
EBADF. ppoll then marks the entry POLLNVAL and pselect6 fails the whole
call with EBADF, naming descriptors that are still open. The standard
reaction to either is to close the descriptor, so running the host out
of fds made the guest tear down its own working connections, and the
pselect6 spelling does not even say which one.
The two failures are already distinguishable at the point where they
merge: the snapshot survives only when the slot held a file, so a
failure after it is the dup. That case returns ENOMEM now, which is
what Linux itself reports for a wait whose tables it cannot build.
O_PATH is classified before the exhaustion is, since it is rejected
either way and its answer must not move with how full the host table
is.
The helper is shared, so the other callers inherit the split. Their
reads and writes and ioctls answered an exhausted host table with EBADF
too, and an open descriptor is not a bad one; ENOMEM is undocumented
for a few of them where EBADF was simply wrong. The 0/-1 spelling next
door, host_fd_ref_open, collapses the same two failures across 54 call
sites and is left alone: unpicking it means changing a return
convention, not adding a case to one.
Nothing here reduces the number of descriptors a wait consumes. The
call that ran the table out still fails; it just no longer blames the
caller's descriptors for it.
tests/test-poll-fd-exhaustion.c keeps a sibling thread alive, fills the
descriptor table, and requires that no still-open descriptor comes back
POLLNVAL and that pselect6 does not fail with EBADF. It lowers its own
RLIMIT_NOFILE to what its array can hold first, so the fill ends in
EMFILE rather than at an array bound that would prove nothing, and it
refuses to report a green run when that precondition is not met. It
passes against a real kernel under qemu-system-aarch64, where the wait
paths never duplicate anything.
@xalestar
xalestarforce-pushed the poll-fd-exhaustion-enomem branch from 42d3318 to 7b738afCompareAugust 22, 2026 16:25
Sign up for freeto 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.

1 participant

@xalestar