From 4b1f7b33ac42a7968c83f7659784d4729ce27abc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 04:19:32 -0700 Subject: [PATCH] fix: release idle remote terminal multiplexers (#4101) --- .../remote-runtime-terminal-multiplexer.ts | 31 +++++++++++++++-- .../runtime/runtime-terminal-stream.test.ts | 34 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 4049307d3..6c3aa2f61 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -81,7 +81,13 @@ class RemoteRuntimeTerminalMultiplexer { private ready = false private nextStreamId = 1 - constructor(private readonly environmentId: string) {} + constructor( + private readonly environmentId: string, + private readonly releaseIfCurrent: ( + environmentId: string, + multiplexer: RemoteRuntimeTerminalMultiplexer + ) => void + ) {} async subscribeTerminal(args: { terminal: string @@ -347,6 +353,10 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onError?.(message) } } + // Why: a closed transport has no live streams or subscription; keeping it + // in the module map only retains callbacks for an environment that must + // reconnect through a fresh subscription anyway. + this.releaseIfCurrent(this.environmentId, this) } private closeIfIdle(): void { @@ -357,22 +367,39 @@ class RemoteRuntimeTerminalMultiplexer { this.subscription = null this.connectPromise = null this.ready = false + this.releaseIfCurrent(this.environmentId, this) } } const multiplexers = new Map() +function releaseRemoteRuntimeTerminalMultiplexer( + environmentId: string, + multiplexer: RemoteRuntimeTerminalMultiplexer +): void { + if (multiplexers.get(environmentId) === multiplexer) { + multiplexers.delete(environmentId) + } +} + export function getRemoteRuntimeTerminalMultiplexer( environmentId: string ): RemoteRuntimeTerminalMultiplexer { let multiplexer = multiplexers.get(environmentId) if (!multiplexer) { - multiplexer = new RemoteRuntimeTerminalMultiplexer(environmentId) + multiplexer = new RemoteRuntimeTerminalMultiplexer( + environmentId, + releaseRemoteRuntimeTerminalMultiplexer + ) multiplexers.set(environmentId, multiplexer) } return multiplexer } +export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { + return multiplexers.size +} + export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() } diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0c42a7345..5f4931464 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -6,7 +6,10 @@ import { encodeTerminalStreamFrame, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' -import { resetRemoteRuntimeTerminalMultiplexersForTests } from './remote-runtime-terminal-multiplexer' +import { + _getRemoteRuntimeTerminalMultiplexerCountForTest, + resetRemoteRuntimeTerminalMultiplexersForTests +} from './remote-runtime-terminal-multiplexer' import { getRemoteRuntimePtyEnvironmentId, getRemoteRuntimeTerminalHandle, @@ -106,8 +109,36 @@ describe('remote runtime terminal data subscriptions', () => { ) expect(watcher).toHaveBeenCalledWith('live') + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) dispose() expect(unsubscribe).toHaveBeenCalled() + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) + }) + + it('keeps the shared terminal multiplexer until the last watcher closes', async () => { + const firstDispose = await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + vi.fn() + ) + const secondDispose = await subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-2', + 'watcher-2', + vi.fn() + ) + + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) + + firstDispose() + expect(unsubscribe).not.toHaveBeenCalled() + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) + + secondDispose() + expect(unsubscribe).toHaveBeenCalledOnce() + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) it('rejects remote terminal subscriptions when the multiplex connection fails', async () => { @@ -123,5 +154,6 @@ describe('remote runtime terminal data subscriptions', () => { ).rejects.toThrow('offline') expect(sendBinary).not.toHaveBeenCalled() + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) })