diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 5d8564531e9..823368fb1bc 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3965,6 +3965,7 @@ "persisted local shutdown waits for daemon startup instead of accepting unknown-id success from the fallback provider", "an in-flight replaced local provider cannot complete or delete the newer shutdown owner", "local and SSH stale-provider not-found rejections preserve replacement ownership and coalesced callers fail closed", + "direct local and SSH kill requests share the retained owner and re-drive after in-flight provider replacement", "host-scoped liveness lists only the selected provider instead of fanning out to unrelated SSH hosts", "relay generation reuse removes stale retry ownership without touching the new PTY" ] diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 77ec50a8f02..25b600eb097 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3447,6 +3447,92 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() }) + it('re-drives a direct local kill when its provider is replaced in flight', async () => { + vi.useFakeTimers() + const oldShutdown = makeDeferred() + const makeProvider = (shutdown: (id: string) => Promise) => + ({ + shutdown, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}) + }) as never + const oldProviderShutdown = vi.fn(() => oldShutdown.promise) + const newShutdown = vi.fn().mockResolvedValue(undefined) + const removePendingLocalPtyShutdown = vi.fn() + setLocalPtyProvider(makeProvider(oldProviderShutdown)) + handlers.clear() + registerPtyHandlers(mainWindow as never, undefined, undefined, undefined, undefined, { + getPendingLocalPtyShutdowns: () => [], + upsertPendingLocalPtyShutdown: vi.fn(), + removePendingLocalPtyShutdown + } as never) + + const kill = handlers.get('pty:kill')!(null, { id: 'daemon-pty' }) as Promise + expect(oldProviderShutdown).toHaveBeenCalledOnce() + setLocalPtyProvider(makeProvider(newShutdown)) + oldShutdown.resolve() + + await expect(kill).rejects.toThrow('PTY shutdown still pending') + await vi.advanceTimersByTimeAsync(0) + expect(newShutdown).toHaveBeenCalledOnce() + expect(removePendingLocalPtyShutdown).toHaveBeenCalledWith('daemon-pty') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('re-drives a direct SSH kill when its provider is replaced in flight', async () => { + vi.useFakeTimers() + const oldShutdown = makeDeferred() + const newShutdown = vi.fn().mockResolvedValue(undefined) + const lease: Record = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + relayInstanceId: 'boot-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyShutdownRequested: vi.fn(() => { + lease.shutdownRequestedAt = 2 + }), + markSshRemotePtyLease: vi.fn() + } + registerSshPtyProvider('ssh-1', { + shutdown: vi.fn(() => oldShutdown.promise), + onExit: vi.fn(() => () => {}) + } as never) + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + const id = 'ssh:ssh-1@@boot-1@@relay-pty' + + const kill = handlers.get('pty:kill')!(null, { id }) as Promise + registerSshPtyProvider('ssh-1', { + shutdown: newShutdown, + onExit: vi.fn(() => () => {}) + } as never) + oldShutdown.resolve() + + await expect(kill).rejects.toThrow('PTY shutdown still pending') + await vi.advanceTimersByTimeAsync(0) + expect(newShutdown).toHaveBeenCalledOnce() + expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', id, 'terminated') + unregisterSshPtyProvider('ssh-1') + await vi.advanceTimersByTimeAsync(30_000) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + it('rebinds a persisted local shutdown after async daemon startup replaces the provider', async () => { vi.useFakeTimers() const startup = makeDeferred() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9355b07b3fc..28c2469e8af 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -183,6 +183,7 @@ type RetainedPtyShutdown = { attempts: number nextRetryAt: number inFlight: Promise | null + lastError: Error | null } const pendingSshShutdownRetries = new Map() const pendingLocalShutdownRetries = new Map() @@ -632,6 +633,7 @@ function attemptRetainedPtyShutdown(retained: RetainedPtyShutdown): Promise { // Why: provider replacement can finish while an older shutdown is in flight. @@ -654,6 +656,7 @@ function attemptRetainedPtyShutdown(retained: RetainedPtyShutdown): Promise