From a263ed36fd4b4a8bcc7332c288bc1fb53f474a4a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 09:46:55 -0700 Subject: [PATCH] fix: drop late remote terminal subscription handles (#4254) --- .../remote-runtime-terminal-multiplexer.ts | 18 ++++++++--- .../runtime/runtime-terminal-stream.test.ts | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 6c3aa2f615e..1013e3174db 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -174,7 +174,7 @@ class RemoteRuntimeTerminalMultiplexer { if (this.connectPromise) { return this.connectPromise } - this.connectPromise = new Promise((resolve, reject) => { + const connectPromise = new Promise((resolve, reject) => { this.readyResolver = resolve this.readyRejecter = reject void window.api.runtimeEnvironments @@ -193,16 +193,26 @@ class RemoteRuntimeTerminalMultiplexer { } ) .then((subscription) => { + if (this.connectPromise !== connectPromise || (!this.ready && !this.readyRejecter)) { + // Why: close/error can arrive before subscribe() resolves because + // preload listens before ipcMain.handle() returns. The multiplexer + // may already be released; do not retain the late handle. + subscription.unsubscribe() + return + } this.subscription = subscription this.resolveReadyIfConnected() }) .catch((error) => { - this.connectPromise = null - this.readyResolver = null - this.readyRejecter = null + if (this.connectPromise === connectPromise) { + this.connectPromise = null + this.readyResolver = null + this.readyRejecter = null + } reject(error instanceof Error ? error : new Error(String(error))) }) }) + this.connectPromise = connectPromise return this.connectPromise } diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 5f4931464f6..0d0a59212f0 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -46,6 +46,8 @@ describe('remote runtime terminal data subscriptions', () => { let callbacks: { onResponse: (response: unknown) => void onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void } | null = null beforeEach(() => { @@ -156,4 +158,34 @@ describe('remote runtime terminal data subscriptions', () => { expect(sendBinary).not.toHaveBeenCalled() expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) }) + + it('unsubscribes a late subscription handle after pre-resolution transport close', async () => { + let resolveSubscribe!: (handle: { + unsubscribe: () => void + sendBinary: typeof sendBinary + }) => void + runtimeSubscribe.mockImplementationOnce((_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + callbacks?.onClose?.() + return new Promise((resolve) => { + resolveSubscribe = resolve + }) + }) + + const subscriptionPromise = subscribeToRuntimeTerminalData( + { activeRuntimeEnvironmentId: 'env-fallback' }, + 'remote:env-1@@terminal-1', + 'watcher-1', + vi.fn() + ) + + await expect(subscriptionPromise).rejects.toThrow('Remote Orca runtime closed the connection.') + expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(0) + expect(unsubscribe).not.toHaveBeenCalled() + + resolveSubscribe({ unsubscribe, sendBinary }) + await Promise.resolve() + + expect(unsubscribe).toHaveBeenCalledOnce() + }) })