Repository navigation
Release SshCommand's session subscriptions and channel on completion (#1204) - #1846
Open
harsh-official wants to merge 2 commits into
Open
harsh-official wants to merge 2 commits into
harsh-official wants to merge 2 commits into
Conversation
harsh-official
requested review from
Rob-Hague,
WojciechNagorski and
drieseng
as code owners
October 9, 2026 20:24
…shnet#1204) SshCommand subscribed to Session.Disconnected/ErrorOccured in its constructor and, since sshnet#1596, created its channel there too and only disposed it in SshCommand.Dispose. A command that is executed but not disposed (e.g. the result of SshClient.RunCommand) was therefore kept alive by the session for the session's lifetime, together with its ChannelSession, which itself stays subscribed to ~12 session events and holds 4 wait handles. With a long-lived SshClient this grows without bound and slows down message dispatch. Now: - the constructor neither creates the channel nor subscribes to the session; ExecuteAsync does both, - the session subscriptions are removed as soon as the execution completes (any completion path), - the channel is disposed as soon as it has been closed at both ends and no thread is blocked in a request on it. A use count around SendExecRequest/SendSignalRequest keeps the sshnet#1596 race fixed: if SSH_MSG_CHANNEL_CLOSE is processed before SendExecRequest returns, the dispose is deferred to the executing thread. - if the execution completes while the channel is still open (cancellation without a reply from the server, session error or disconnect), the channel is disposed on a thread-pool thread, since that sends SSH_MSG_CHANNEL_CLOSE and waits up to ChannelCloseTimeout. - if Open/SendExecRequest throws, the channel and subscriptions are released and the instance can be executed again. - the subscriptions and the channel are released, and the output streams disposed, before the task completes, so that this cleanup cannot race with a re-execution started on the task's completion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FhcS92GWC4Aixeh8JxWMXy
harsh-official
force-pushed
the
fix/1204-sshcommand-release-on-completion
branch
from
October 9, 2026 20:33
c0ef2f2 to
694feb3
Compare
Ssh_RemotePortForwarding: s.Expect("ABC"/"DEF") only matches the
terminal echo of the line typed into telnet, not its delivery through
the forwarded port. The shell (and with it telnet) was then closed
immediately, and the listeners were only waited on afterwards, for 1s.
Wait for each listener to receive its bytes before closing the shell
that drives its telnet session.
Common_DetectSessionKilledOnServer: Session.RaiseError raises
ErrorOccurred on the message listener thread before it disconnects the
session, so IsConnected can still be true right after the event is
signaled. Wait (bounded) for IsConnected to become false.
Both tests have also failed intermittently on unrelated branches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Relates to #1204 (the "keep the connection alive and run
RunCommand/CreateCommandwithout disposing" scenario from the later comments; it does not touch theShellStreamscenario of the original report).Root cause
SshCommandsubscribes toSession.DisconnectedandSession.ErrorOccuredin its constructor, and since #1596 it also creates itsChannelSessionthere. Both are released only inSshCommand.Dispose(). So a command that has run to completion but was never disposed, e.g.stays reachable from the session for the session's whole lifetime. Its channel stays reachable too, and the channel is itself subscribed to ~12 session events and holds 4
ManualResetEvents. With a long-livedSshClient, memory and handle counts grow without bound, and every incoming channel message is dispatched to a growing list of dead channels.Why not a finalizer
As discussed in the issue, a finalizer isn't the answer.
SshCommandowns no unmanaged resources. And the thing keeping the command alive is the session's reference to it through the event handlers, so the command never becomes unreachable and a finalizer would never run. Even if it did run, it couldn't safely touch theSessionor the channel, since finalization order is non-deterministic. The fix has to be that a completed command no longer needs to be referenced by the session.The change
ExecuteAsyncdoes both.Dispose.Channel.Dispose()neither sends a message nor blocks.Open()orSendExecRequest()throws, the channel and subscriptions are released and the task is faulted. Previously the instance was left "in progress", so it could never be executed again.Disposing a completed command remains correct and is still recommended. It just stops being the only way to release the session's references.
The #1596 race is preserved
#1596 moved channel creation to the constructor and stopped disposing the channel on completion because
SSH_MSG_CHANNEL_CLOSEcan be processed on the message loop thread before the executing thread has finished waiting inSendExecRequest(). Disposing the channel at that point disposes_channelRequestResponseunderneath the waiting thread.This PR keeps that fixed with a per-channel use count, guarded by a lock:
ExecuteAsyncsets the use count to 1 beforeOpen()/SendExecRequest()and decrements it afterwards.CancelAsyncdoes the same aroundSendSignalRequest().Channel_Closedonly marks the channel as closed. Release is attempted on completion and on every decrement, and happens only when the channel is closed and the use count is zero. If the close is processed whileSendExecRequest()is still waiting, the dispose is deferred to the executing thread onceSendExecRequest()returns.Dispose()sendsSSH_MSG_CHANNEL_CLOSEand waits up toChannelCloseTimeoutfor the server, so it's disposed on a thread-pool thread (ThreadAbstraction.ExecuteThread) rather than on the thread completing the execution, which may be the message loop. Our handlers are detached from the channel first, so the command is not kept alive while that happens._channelLockis never held while calling into the channel, so it can't deadlock againstChannel._messagingLock. Disposing the channel from within its ownClosedevent (message loop thread) is safe:Close()re-enters_messagingLockand is a no-op, sinceIsOpenis already false.Tests
New
SshCommandTest_NotDisposed:ConstructorShouldNotCreateChannelNorSubscribeToSessionCompletedCommandShouldReleaseChannelWithoutDisposeChannelClosedBeforeSendExecRequestReturnsShouldNotDisposeChannelUnderneathIt(the Don't dispose channel when completing SshCommand #1596 race)CompletedCommandShouldNotBeKeptAliveBySession(WeakReference + GC; it clears Moq's recorded invocations, which would otherwise root the command through the delegates passed toadd_/remove_)FailureToStartShouldReleaseChannelAndAllowReexecutionCancelledCommandWhoseChannelIsNotClosedShouldReleaseChannelSessionErrorShouldReleaseChannelCancelledCommandWhoseChannelIsNotClosedShouldNotBeKeptAliveBySessionAll eight fail against
develop.CompletedCommandShouldNotBeKeptAliveBySessionfails for the intended reason ("still referenced by the session"). Locally on macOS (arm64), net8.0/net9.0/net10.0:SshCommand*unit tests pass in Debug and Release;develop(the same 53 pre-existing ForwardedPort test failures on this machine, on both branches);dotnet build Renci.SshNet.slnxwithCI=true(warnings as errors) is clean.net462 was built but not run, and the integration tests were not run locally (no Docker on this machine). I'm relying on CI for those.
Flaky integration tests (separate commit)
The first CI run failed on two integration tests that don't depend on this change. Both have also failed intermittently on unrelated branches (
bcl-x25519dh,feature/configurable-socket-buffer-size,feature/connection-handler). The second commit fixes them. It's independent of the #1204 change, so I'm happy to drop it or move it to its own PR.Ssh_RemotePortForwarding:s.Expect("DEF")only matches the terminal's echo of the line typed into telnet, not its delivery through the forwarded port. The shell (and with it telnet) was closed immediately, and the listeners were only waited on afterwards, for 1s. The test now waits (up to 10s) for each listener to receive its bytes before closing the shell that drives its telnet session.Common_DetectSessionKilledOnServer:Session.RaiseErrorraisesErrorOccurredon the message listener thread before it callsDisconnect(), soIsConnectedcan still be true right after the event is signaled. The test now waits (up to 5s) forIsConnectedto become false, rather than asserting it immediately.AI disclosure
Per CONTRIBUTING.md: the root-cause analysis, design, implementation, tests and this description were written by Claude (Claude Code) under my direction. I'm relying on the local testing described above and on CI.
🤖 Generated with Claude Code