#2357·waydroid

[BUG] foreground_pipe leaks an epoll fd on every command

Author: Summer-DeZCreated Jul 11, 2026Updated Sep 15, 2026

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 hard

Steps to reproduce

  1. Start the Waydroid container and session.

  2. Find the container manager PID:

    bash
    pid=$(pgrep -f '^/usr/bin/python3 /usr/bin/waydroid container start$')
  3. Periodically count its open file descriptors:

    bash
    sudo find "/proc/$pid/fd" -maxdepth 1 -type l | wc -l
  4. Observe 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 session

waydroid status reported:

Session: RUNNING
Container: STOPPED

However, 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:

bash
sudo strace -f -p "$pid" \
  -e trace=eventfd2,epoll_create1,pipe2,close

showed 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) = 208

There was no corresponding close for these descriptors in the stock implementation.

Suspected source

tools/helpers/run_core.py, inside foreground_pipe():

python
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:

diff
     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=15

The 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: RUNNING