fix(ssh): release the socket, listening port and session when a tunnel ends - #2552
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two resource leaks in the SSH tunnel stack, found while investigating #2474 and verified with
lsofagainst a running app.A socket per Test Connection
cleanupChainreleased the SSH socket only whenchain.socketFD != chain.initialSocketFD. Those differ only when the chain went through a jump host: with hops,chain.socketFDis the last relay's socketpair end andinitialSocketFDis 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.socketFDis 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 everycreateTunnelfailure after authentication, including the port-collision retry loop, which re-authenticates each attempt.Everything, every time a tunnel dies
markDead()andclose()both consumed the sameisAlivelatch, but onlyclose()did any releasing. Keep-alive failure calledmarkDead(), which took the latch and firedonDeath;close()then hitguard wasAlive else { return }and did nothing for the rest of the tunnel's life.SSHTunnelManager.handleTunnelDeathdrops the tunnel from both dictionaries without callingclose(), so the listening port, the socket to the server, the libssh2 session and every jump hop's session, channel and socket were never released, andterminateAllProcessesSynccould not reclaim them because the registry entry was already gone.Reproduce: connect through an SSH tunnel, note the
127.0.0.1:6xxxxLISTEN inlsof -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 withSO_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;isLiveobserves without claiming.close(),closeSync()andmarkDead()each claim it and each perform the teardown, withmarkDead()additionally firingonDeathafterwards.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
TeardownLatchTestscovers 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:
LibSSH2Tunnelneeds a liveLIBSSH2_SESSIONand 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 bylsofagainst a running build instead, with the reproductions above.Verification
verify.sh buildPASSverify.sh test TeardownLatchTests SSHTunnelErrorTests SSHKeepAliveResultTestsPASS, 21/21swiftlint --strictclean over the four changed files