[BUG] foreground_pipe leaks an epoll fd on every command
Describe the bug
The long-running Waydroid container manager leaks an epoll file descriptor
whenever tools.helpers.run_core.foreground_pipe() executes a command.
foreground_pipe() creates a selectors.DefaultSelector(), but the stock
implementation does not close it. On Linux, this selector owns an epoll fd.
Because Waydroid runs lxc-info approximately every 1.5 seconds, the manager
continuously accumulates anon_inode:[eventpoll] descriptors. After reaching
the soft RLIMIT_NOFILE, Waydroid can no longer execute lxc-info and reports
the container as stopped even though the LXC container is still running.
Environment
Waydroid: 1.6.2
Distribution: Ubuntu 26.04 LTS
Kernel: 7.0.0-27-generic
Python: 3.14.4
Device: Linux Desktop
RLIMIT_NOFILE: 1024 soft, 524288 hardSteps to reproduce
Start the Waydroid container and session.
Find the container manager PID:
pid=$(pgrep -f '^/usr/bin/python3 /usr/bin/waydroid container start$')Periodically count its open file descriptors:
sudo find "/proc/$pid/fd" -maxdepth 1 -type l | wc -lObserve that the count grows continuously.
Observed result
The process grew from approximately 87 to 167 descriptors in 30 seconds and eventually reached 1023.
Most leaked descriptors were:
anon_inode:[eventpoll]Waydroid then repeatedly reported:
Couldn't get LXC status. Assuming STOPPED.
Error while stopping container: [Errno 24] Too many open files
RuntimeError: Already tracking a sessionwaydroid status reported:
Session: RUNNING
Container: STOPPEDHowever, the LXC container and Android applications were still running. The Waydroid manager had only lost the ability to query the container.
strace evidence
Tracing the manager with:
sudo strace -f -p "$pid" \
-e trace=eventfd2,epoll_create1,pipe2,closeshowed a new epoll descriptor after each lxc-info execution:
epoll_create1(EPOLL_CLOEXEC) = 202
epoll_create1(EPOLL_CLOEXEC) = 204
epoll_create1(EPOLL_CLOEXEC) = 206
epoll_create1(EPOLL_CLOEXEC) = 208There was no corresponding close for these descriptors in the stock implementation.
Suspected source
tools/helpers/run_core.py, inside foreground_pipe():
sel = selectors.DefaultSelector()
sel.register(process.stdout, selectors.EVENT_READ)
...
sel.select(timeout)The selector is not closed before the function returns.
Locally tested fix
Keep the existing selector behavior, but close it reliably on every normal or exceptional path:
sel = selectors.DefaultSelector()
- sel.register(process.stdout, selectors.EVENT_READ)
- timeout = args.timeout if output_timeout else None
- while process.poll() is None:
- ...
+ try:
+ sel.register(process.stdout, selectors.EVENT_READ)
+ timeout = args.timeout if output_timeout else None
+ while process.poll() is None:
+ ...
+ finally:
+ sel.close()Validation
With this patch and the original soft limit of 1024:
t=0s FD=15
t=30s FD=15
t=60s FD=15The FD count remained stable during two consecutive 30-second tests. A second
strace showed the selector fd being closed and reused after each poll.
Waydroid remained healthy:
Session: RUNNING
Container: RUNNINGSource: waydroid/waydroid