Skip to content

fix(ssh): release the socket, listening port and session when a tunnel ends - #2552

Merged
datlechin merged 1 commit into
mainfrom
fix/ssh-tunnel-resource-leaks
Aug 27, 2026
Merged

fix(ssh): release the socket, listening port and session when a tunnel ends#2552
datlechin merged 1 commit into
mainfrom
fix/ssh-tunnel-resource-leaks

Conversation

@datlechin

Copy link
Copy Markdown
Member

Two resource leaks in the SSH tunnel stack, found while investigating #2474 and verified with lsof against a running app.

A socket per Test Connection

cleanupChain released the SSH socket only when chain.socketFD != chain.initialSocketFD. Those differ only when the chain went through a jump host: with hops, chain.socketFD is the last relay's socketpair end and initialSocketFD is the first hop's socket, which the hop loop closes. Without hops they are the same fd, the guard is false, and the hop loop is empty, so nothing closes it.

The guard was the whole bug. chain.socketFD is only ever a hop's socket when there are no hops, which is exactly the case that needed closing, so it is now closed unconditionally and the field that existed only for that comparison is gone.

Reproduce: connection form > SSH Tunnel > edit a profile with no jump hosts > press Test Connection repeatedly, watching lsof -p $(pgrep TablePro) | grep -c TCP. It rises by one per press and never falls. The same leak happens on every createTunnel failure after authentication, including the port-collision retry loop, which re-authenticates each attempt.

Everything, every time a tunnel dies

markDead() and close() both consumed the same isAlive latch, but only close() did any releasing. Keep-alive failure called markDead(), which took the latch and fired onDeath; close() then hit guard wasAlive else { return } and did nothing for the rest of the tunnel's life. SSHTunnelManager.handleTunnelDeath drops the tunnel from both dictionaries without calling close(), so the listening port, the socket to the server, the libssh2 session and every jump hop's session, channel and socket were never released, and terminateAllProcessesSync could not reclaim them because the registry entry was already gone.

Reproduce: connect through an SSH tunnel, note the 127.0.0.1:6xxxx LISTEN in lsof -p $(pgrep -x TablePro) -iTCP -P -n, then kill that sshd session server-side. The log shows "marking tunnel dead" and "Tunnel died" and never "Tunnel closed". The port stays bound and cannot be rebound even with SO_REUSEADDR (measured: errno 48). Every sleep/wake or network drop adds another.

The fix

The latch is the thing that was ambiguous, so it now says what it means. TeardownLatch.claim() returns true to exactly one caller ever, and whoever gets true owes the teardown; isLive observes without claiming. close(), closeSync() and markDead() each claim it and each perform the teardown, with markDead() additionally firing onDeath afterwards.

A bare boolean invited the shape that caused this: one path read it as "someone else will tear down", the other as "I already did", and neither was true.

Tests

TeardownLatchTests covers the invariant directly, including 200 rounds of eight concurrent claimants each asserting exactly one winner. That is the property the tunnel needs and the one a boolean could not express.

The fd-level behaviour itself has no unit test: LibSSH2Tunnel needs a live LIBSSH2_SESSION and its teardown calls into libssh2, so constructing one in a test would need a real server and would still be measuring the OS rather than the code. Both leaks were confirmed by lsof against a running build instead, with the reproductions above.

Verification

  • verify.sh build PASS
  • verify.sh test TeardownLatchTests SSHTunnelErrorTests SSHKeepAliveResultTests PASS, 21/21
  • swiftlint --strict clean over the four changed files

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 61a601e into main Aug 27, 2026
8 checks passed
@datlechin
datlechin deleted the fix/ssh-tunnel-resource-leaks branch August 27, 2026 06:45
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