Improve port-forwarding resilience under bursty multi-port load#144
Merged
DavidObando merged 3 commits intomainfrom May 5, 2026
Merged
Improve port-forwarding resilience under bursty multi-port load#144DavidObando merged 3 commits intomainfrom
DavidObando merged 3 commits intomainfrom
Conversation
Follow-up to #138. PR #138 prevented host-process crashes from unhandled 'error' events on duplex streams inside StreamForwarder, but several related issues remained that either (a) leaked resources, (b) still left a small unhandled-'error' window outside the forwarder, or (c) wasted work on doomed connections. This change addresses them. Changes: * StreamForwarder: accept an optional onDisposed callback so the PortForwardingService can remove disposed forwarders from its tracking collection. Add a clarifying comment about pipe()'s end-vs-error semantics. * PortForwardingService: change streamForwarders from an append-only array to a Set, with a removeStreamForwarder callback wired into every StreamForwarder. Previously disposed forwarders were retained for the lifetime of the session, holding references to their socket and SshStream — a real leak under bursty connect/disconnect workloads (the exact workload PR #138 was reacting to). * LocalPortForwarder.acceptConnection: attach a temporary 'error' handler on the accepted socket *before* awaiting openChannel and the forwardedPortConnecting event. Without this, a peer reset during that await window emits 'error' on a listener-less socket and crashes the host — the same crash class as #138, just earlier in the lifecycle. Also destroy the socket when the connecting event handler rejects the connection (was previously leaked). * RemotePortForwarder.forwardChannel: - Bug fix: when the local TCP connect fails, return early instead of falling through to construct a StreamForwarder around a destroyed socket. The forwardedStream is now also destroyed so the underlying SSH channel doesn't leak. - Attach a temporary 'error' handler on the SshStream during the forwardedPortConnecting + connect window for the same reason as the local side: a remote channel reset in that window would emit an unhandled 'error'. * SshStream.destroy: trace channel.close() failures instead of silently swallowing them with .catch(). Aids diagnosis of teardown issues that the new error path may now expose. Behavior is unchanged on the happy path. On the error path, forwarders self-dispose, are removed from the PFS collection, and traces include the side and reason. No public API changes. Verified locally: npm run compile, npm run eslint, and npm run test-ts all pass (one pre-existing unrelated failure in VersionTests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… and tests Fix several edge cases identified during code review of PR #144: 1. Listener leak if forwardedPortConnecting throws: wrap the call in try/catch in both localPortForwarder and remotePortForwarder so the temporary error handler is always removed and streams are destroyed on unexpected exceptions. 2. SSH channel leak in localPortForwarder.acceptConnection: when the forwardedPortConnecting event handler rejects (returns null), the already-opened SSH channel was not being closed. Now destroy the SshStream so the channel is released. Also remove the temporary error listener in the openChannel catch path for consistency. 3. Synchronous-dispose race in StreamForwarder constructor: if an error fires during pipe() setup (before the caller can call streamForwarders.add()), the onDisposed callback runs on a forwarder not yet in the set, then the caller adds a dead forwarder. Fixed by adding a public isDisposed getter and guarding add() with it. 4. StreamForwarder tests: add streamForwarderTests.ts with 10 tests covering data forwarding, error-triggered dispose, callback invocation, idempotent dispose, the synchronous-dispose race, and onDisposed error swallowing. Export StreamForwarder from the package (constructor made public) to enable testing. 5. Ensure sshStream is destroyed when forwardedStream is user-substituted: in remotePortForwarder error paths, if the forwardedStream returned by the connecting event is not the original sshStream, explicitly destroy sshStream so the underlying SSH channel does not leak. Verified: npm run compile, npm run eslint, npm run test-ts all pass (357 passing, 1 pre-existing unrelated VersionTests failure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Improve resilience and lifecycle management of SSH TCP port-forwarding under bursty connect/disconnect and multi-port load by closing pre-construction unhandled 'error' windows, preventing leaked forwarders, and improving diagnostics.
Changes:
- Add pre-forwarding temporary
'error'handlers for accepted sockets and SSH streams; ensure resources are destroyed on rejection/failure paths. - Replace append-only forwarder tracking with
Set<StreamForwarder>and add anonDisposedcallback to support removal on disposal. - Add unit tests for
StreamForwarderdata forwarding and error/disposal behavior; add warning trace onSshStream.destroy()close failures.
Show a summary per file
| File | Description |
|---|---|
| test/ts/ssh-test/streamForwarderTests.ts | Adds StreamForwarder unit tests covering data piping, error-triggered disposal, and disposal callback behavior. |
| src/ts/ssh/sshStream.ts | Traces channel.close() failures during destroy() instead of silently swallowing them. |
| src/ts/ssh-tcp/services/streamForwarder.ts | Adds onDisposed callback + isDisposed and ensures callback errors are handled during disposal. |
| src/ts/ssh-tcp/services/remotePortForwarder.ts | Adds temporary SSH stream error handler during setup, fixes failure-path teardown, and integrates forwarder removal via callback + Set. |
| src/ts/ssh-tcp/services/portForwardingService.ts | Changes streamForwarders to a Set and adds a removal callback; clears set on dispose. |
| src/ts/ssh-tcp/services/localPortForwarder.ts | Adds temporary socket error handler during setup, destroys socket on rejection, and integrates forwarder removal via callback + Set. |
| src/ts/ssh-tcp/index.ts | Exports StreamForwarder from the package entrypoint (public surface change). |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 7/7 changed files
- Comments generated: 4
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
leti367
approved these changes
May 5, 2026
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.
Context
Follow-up to #138, which fixed unhandled
'error'events on duplex streams insideStreamForwarderthat were crashing Node host processes (extension host using the Codespaces extension, Node apps usingTunnelRelayTunnelClient, etc.) under bursty multi-port forwarding.While reviewing #138 we found several related issues that PR did not cover. This PR addresses the ones that are small, tightly related, and don't change public API or wire-protocol behavior. Two larger follow-ups (TCP socket options on forwarded sockets, and
SshStreamflow-control improvements) are deferred to their own discussions.Changes
1. Bug:
RemotePortForwarder.forwardChannelconstructed aStreamForwardereven after the local TCP connect failedThe previous code set
request.failureReason = connectFailedbut then fell through and built a forwarder around a destroyed socket and a still-open SSH channel. Pre-#138 this was a likely crash trigger; post-#138 it created a self-disposing zombie. Now wereturnearly anddestroy()theforwardedStreamso the underlying SSH channel is released.2. Memory leak:
pfs.streamForwarderswas append-onlyPortForwardingService.streamForwarderswas an array that everyone pushed to but no one removed from. Disposed forwarders were retained for the lifetime of the session, holding references to aSocketand anSshStream. Under the bursty connect/disconnect workload that motivated #138, this is a real leak.Now it's a
Set<StreamForwarder>andStreamForwarderaccepts an optionalonDisposedcallback that the PFS uses to splice itself out.3. Pre-construction unhandled
'error'window onLocalPortForwarder.acceptConnectionBetween accepting a TCP connection and constructing the
StreamForwarder(which now installs the listeners from #138), the code awaitspfs.openChannel(...)andpfs.forwardedPortConnecting(...). A peer reset during that await window emits'error'on a listener-less socket — same crash class as #138, just earlier in the lifecycle. Fixed by attaching a temporary'error'listener on accept and removing/transferring it when the forwarder takes over.Also: when the connecting event handler rejects the connection (returns falsy), the previous code returned without destroying the socket — leaked file descriptor. Now destroyed.
4. Pre-construction unhandled
'error'window onRemotePortForwarder.forwardChannelSymmetric fix on the SSH-stream side: attach a temporary listener on the
SshStreamwhile we awaitforwardedPortConnectingand the localnet.createConnection. A remote channel reset in that window would otherwise emit an unhandled'error'from theSshStream.5.
SshStream.destroyswallowedchannel.close()errorsWas
void this.channel.close().catch();— silently dropped any rejection. Now traces them atWarningso the new error path doesn't lose diagnostic information.Deferred
// TODO: Set socket options?in both forwarders) —setNoDelay/setKeepAliveare clearly desirable for bursty workloads, but choosing keepalive intervals, deciding on configurability vs. hardcoded defaults, and checking C# parity needs its own discussion. Detailed notes have been written up separately.SshStreamflow-control improvements — the existing self-aware comment insshStream.tsabout choppy SSH window adjustment is real, but addressing it requires either decoupling window adjust frompush()backpressure (lower-risk patch) or replacingDuplexwith a custom pull-based implementation (ideal but invasive). Detailed notes have been written up separately.stream.pipeline()inStreamForwarder— would simplify the manual error/teardown wiring, but changes teardown semantics (pipeline destroys both streams on error) and warrants its own PR with focused testing.Behavior
Happy path is unchanged. On the error path: forwarders self-dispose, are removed from the PFS collection, and warning traces include the side (
local/remote) and reason. No public API changes, no wire-protocol changes.Impact on Codespaces clients
SshStream.destroytracing apply; the new error windows mostly close pre-existing latent races. Nonet.Socketpaths run.'error'/leak scenarios that Fix StreamForwarder crashing host process on unhandled socket errors #138 didn't cover. Combined with Fix StreamForwarder crashing host process on unhandled socket errors #138, the extension host should now tolerate the full bursty multi-port-forward + reconnect workload without process termination or unbounded memory growth in the PFS.Verification
npm run compile✅npm run eslint✅npm run test-ts✅