Skip to content

fix(pane-stream): release FIFO read fd on pane WebSocket disconnect - #116

Merged
tdody merged 2 commits into
mainfrom
fix/pane-stream-fifo-fd-leak
Jun 26, 2026
Merged

fix(pane-stream): release FIFO read fd on pane WebSocket disconnect#116
tdody merged 2 commits into
mainfrom
fix/pane-stream-fifo-fd-leak

Conversation

@tdody

@tdody tdody commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes a backend file-descriptor leak that degrades responsiveness the longer Switchboard stays open — the UI gets sluggish and typing into panes lags.

Reported symptom: "As the application stays open over time, the UI becomes much slower, typing isn't as responsive."

Root cause

PaneStreamer.run() streams a pane by transferring the FIFO read fd into an asyncio read transport via connect_read_pipe, then sets the bare fd = -1. The finally block only guarded that now-dead fd and never closed the transport:

file_obj = os.fdopen(fd, "rb", buffering=0)
fd = -1  # ownership transferred
await loop.connect_read_pipe(..., file_obj)   # transport now owns the fd
...
finally:
    if fd >= 0:          # always -1 here → dead code
        os.close(fd)
    os.unlink(fifo_path) # unlinks the file, but the fd stays open

The transport only self-closes on EOF. On a WebSocket disconnect the tmux cat writer is still alive, so no EOF arrives — the .fifo is unlinked but the read fd leaks, one per terminal open. Over a working session this climbs toward the process fd soft limit (256 on macOS) and bloats the asyncio selector (it polls every dead FIFO fd each loop iteration), slowing every backend operation — including the keystroke round-trip that carries typing.

Fix

Capture the transport from connect_read_pipe and close it deterministically in finally, with file_obj / bare-fd fallbacks for the earlier failure windows. transport.close() releases the fd and deregisters the loop reader.

Verification

  • New regression test test_run_releases_fifo_read_fd_on_ws_disconnect — models the exact no-EOF disconnect path (writer held open) and asserts no fd survives teardown. Fails before the fix (leaked 1 fd), passes after.
  • Full backend suite: 386/386 pass. Ruff + ty clean.
  • Live (before): +1 FIFO fd per open/close cycle (98 → 104 over 6 cycles).
  • Live (after): 0 leaked fds across 8 clean cycles and 6 worst-case mid-stream closes; total process fds flat.

Notes

  • The frontend was investigated and cleared — no JS heap leak (idle, modal churn, and heavy streaming all plateau under authoritative CDP GC). The transient heap growth during streaming is normal young-gen garbage that GC reclaims.

🤖 Generated with Claude Code

PaneStreamer.run() transfers the FIFO read fd into an asyncio read
transport via connect_read_pipe and sets the bare `fd` to -1, but the
finally block only guarded that now-dead `fd` and never closed the
transport. The transport only self-closes on EOF — and on a WS
disconnect the tmux `cat` writer is still alive, so no EOF arrives and
the read fd leaks one-per-stream (the .fifo is unlinked but the fd stays
open). Over a long session of opening pane terminals this marches toward
the process fd limit and bloats the asyncio selector, slowing every
backend operation including the keystroke round-trip.

Capture the transport and close it in finally (with file_obj / bare-fd
fallbacks for the earlier failure windows). transport.close() releases
the fd and deregisters the loop reader.

Verified live: was +1 FIFO fd per terminal open; now 0 leaked across
clean and worst-case mid-stream open/close cycles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tdody
tdody merged commit 938a133 into main Jun 26, 2026
3 checks passed
@tdody
tdody deleted the fix/pane-stream-fifo-fd-leak branch June 26, 2026 00:13
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.

1 participant