Skip to content

GUACAMOLE-1625: Close sockets inherited by connection processes. - #698

Merged
mike-jumper merged 2 commits into
apache:staging/1.6.1from
Heyzi:GUACAMOLE-1625
Aug 6, 2026
Merged

GUACAMOLE-1625: Close sockets inherited by connection processes.#698
mike-jumper merged 2 commits into
apache:staging/1.6.1from
Heyzi:GUACAMOLE-1625

Conversation

@Heyzi

@Heyzi Heyzi commented Aug 4, 2026

Copy link
Copy Markdown

Connection processes inherit every descriptor open in the parent, as guacd_create_proc() closes only its own end of the socketpair it shares with the new child. For a busy parent, this means the parent's end of every other connection's user socketpair, the parent's end of every other connection process' socketpair, and the socket guacd listens on. FD_CLOEXEC cannot help, as connection processes are forked but never exec'd.

Those inherited descriptors keep unrelated sockets referenced after the parent has closed them, so their peers never observe EOF. A write which should fail with EPIPE instead blocks forever, and that blocked write is performed while holding a read lock on the client's list of users, so guac_client_remove_user() can never acquire the corresponding write lock. The count of connected users therefore never reaches zero, guacd_proc_stop() is never called, and the process never exits.

The effect compounds, as the more connections are open, the more descriptors each new process inherits. A parent with 469 such processes was observed holding tens of gigabytes, with descriptor counts rising from 98 in the oldest process to 756 in the youngest, and a single socket held by 40 of 40 sampled processes.

Close inherited sockets within the newly-forked process. Only sockets are closed, as descriptors for anything else may be held by libraries unaware that they are being closed, and reusing such a number for a later socket would silently redirect their reads and writes.

@necouchman

Copy link
Copy Markdown
Contributor

@Heyzi As with #699, could you rebase against staging/1.6.1?

@Heyzi
Heyzi changed the base branch from main to staging/1.6.1 August 4, 2026 19:00
@mike-jumper

Copy link
Copy Markdown
Contributor

I'm concerned about the potential performance impact of looping through all file descriptors at connection start. Would close_range() be an option here?

@Heyzi

Heyzi commented Aug 5, 2026

Copy link
Copy Markdown
Author

I'm concerned about the potential performance impact of looping through all file descriptors at connection start. Would close_range() be an option here?

Yeah, fair point. I'll push an update soon.

@Heyzi

Heyzi commented Aug 5, 2026

Copy link
Copy Markdown
Author

I'm concerned about the potential performance impact of looping through all file descriptors at connection start. Would close_range() be an option here?

Switched to close_range(), with two fallbacks - both are load-bearing:

  • musl has no wrapper (the guacd image is Alpine), so the syscall is invoked directly
  • RHEL/Rocky 8 has neither — glibc 2.28, Linux 4.18 — so /proc/self/fd is used there

Benchmarked against the previous per-descriptor approach, 25 runs per point:

open fds scan + fstat close_range
100 0.363 ms 0.077 ms
500 1.582 ms 0.381 ms
1000 3.101 ms 0.819 ms
5000 18.115 ms 7.172 ms

The RHEL 8 fallback matters too: with the default ulimit -n of 1048576 in a Rocky 8 container, walking the full range costs 229ms per connection vs 0.05ms via /proc/self/fd.

We've deployed a build of this to our own production, since the issue had become critical for us.
v1
close_range
Graphs show ~200 sessions per hour, so nearly all sessions turn over hourly.

Heyzi added 2 commits August 6, 2026 10:48
Connection processes inherit every descriptor open in the parent, as
guacd_create_proc() closes only its own end of the socketpair it shares with
the new child. For a busy parent, this means the parent's end of every other
connection's user socketpair, the parent's end of every other connection
process' socketpair, and the socket guacd listens on. FD_CLOEXEC cannot help,
as connection processes are forked but never exec'd.

Those inherited descriptors keep unrelated sockets referenced after the parent
has closed them, so their peers never observe EOF. A write which should fail
with EPIPE instead blocks forever, and that blocked write is performed while
holding a read lock on the client's list of users, so guac_client_remove_user()
can never acquire the corresponding write lock. The count of connected users
therefore never reaches zero, guacd_proc_stop() is never called, and the
process never exits.

The effect compounds, as the more connections are open, the more descriptors
each new process inherits. A parent with 469 such processes was observed
holding tens of gigabytes, with descriptor counts rising from 98 in the oldest
process to 756 in the youngest, and a single socket held by 40 of 40 sampled
processes.

Close inherited sockets within the newly-forked process. Only sockets are
closed, as descriptors for anything else may be held by libraries unaware that
they are being closed, and reusing such a number for a later socket would
silently redirect their reads and writes.
Testing each descriptor individually costs a syscall per descriptor, which
close_range() avoids entirely. Where it is unavailable, the cost is bounded by
closing only those descriptors which are actually open, as the limit on the
number of descriptors may be orders of magnitude greater than the number in
use.

A C library may lack a wrapper for close_range() while the kernel still
provides the system call, in which case it is invoked directly. That is the
case for musl, and thus for the guacd Docker image.

Descriptors are no longer tested to determine whether they refer to sockets.
Every descriptor inherited here is a socket, and neither libuuid nor OpenSSL
retains one across the fork, both obtaining randomness through getrandom().
@mike-jumper
mike-jumper merged commit f691de4 into apache:staging/1.6.1 Aug 6, 2026
1 check passed
Sign up for free to 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.

3 participants