mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(ssh): stop a relay-era shell when its bridged tab closes (#29620)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { SshRemotePtyLease } from '../../../../shared/ssh-types'
|
||||
import { installLegacyRelayBridge } from '../../../ssh/legacy-relay-bridge/legacy-relay-bridge-registry'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
import { killPtyFromRuntimeController } from './kill'
|
||||
|
||||
const TARGET = 'ssh-box'
|
||||
const lease: SshRemotePtyLease = {
|
||||
targetId: TARGET,
|
||||
ptyId: 'pty2:epoch:1',
|
||||
tabId: 'tab-1',
|
||||
state: 'detached',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
describe('a generic kill of a terminal an older relay still runs', () => {
|
||||
it('leaves its lease to the bridge instead of tombstoning a shell it never stopped', () => {
|
||||
const retireLease = vi.fn()
|
||||
installLegacyRelayBridge({
|
||||
leases: () => [lease],
|
||||
retireLease,
|
||||
// Disconnected: the bridge can only record the stop for the next connect.
|
||||
connection: () => null,
|
||||
onPanesChanged: () => {},
|
||||
onData: () => {},
|
||||
onReplay: () => {},
|
||||
onExit: () => {},
|
||||
report: () => {}
|
||||
})
|
||||
const finishPtyShutdown = vi.fn()
|
||||
const deps = {
|
||||
runtime: null,
|
||||
store: null,
|
||||
getLocalPtyProviderStartupPromise: () => null,
|
||||
shutdownProviderAndDetectExit: vi.fn(),
|
||||
finishPtyShutdown,
|
||||
retiredRejectedPtyIds: new Set<string>()
|
||||
}
|
||||
const accepted = killPtyFromRuntimeController(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch returns before reading any other dep.
|
||||
deps as unknown as PtyRuntimeControllerDeps,
|
||||
toAppSshPtyId(TARGET, lease.ptyId)
|
||||
)
|
||||
expect(accepted).toBe(true)
|
||||
expect(finishPtyShutdown).not.toHaveBeenCalled()
|
||||
expect(deps.shutdownProviderAndDetectExit).not.toHaveBeenCalled()
|
||||
expect(retireLease).not.toHaveBeenCalled()
|
||||
installLegacyRelayBridge(null)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
import { errorMessage } from '../../../../shared/error-message'
|
||||
import {
|
||||
closeLegacyRelayPane,
|
||||
isLegacyRelayBridgedPty
|
||||
} from '../../../ssh/legacy-relay-bridge/legacy-relay-bridge-registry'
|
||||
|
||||
// Bounds the wait for an observed exit to reach the runtime record when the caller set no deadline.
|
||||
const OBSERVED_EXIT_RECORD_WAIT_MS = 10_000
|
||||
@@ -38,6 +42,12 @@ export function killPtyFromRuntimeController(
|
||||
} = deps
|
||||
runtime?.markPtyStopRequested?.(ptyId)
|
||||
const connectionId = getPtySshConnectionId(ptyId)
|
||||
// COMPAT(legacy-relay-bridge, revisit 2027-01-15): an older relay's shell stops through the
|
||||
// bridge, which retires its lease only on the relay's proof; tombstoning it here would orphan it.
|
||||
if (connectionId && isLegacyRelayBridgedPty(ptyId)) {
|
||||
void closeLegacyRelayPane(ptyId)
|
||||
return true
|
||||
}
|
||||
// Why: close runtime tails without clearing provider ownership, so
|
||||
// a retry can still target a PTY that survived the failed shutdown.
|
||||
const reportStopFailure = (err: unknown): void => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { knownSshHostPlatform } from '../ssh-host-platform-memo'
|
||||
import { hostProps } from '../ssh-host-server-telemetry'
|
||||
import {
|
||||
attachLegacyRelayPane,
|
||||
closeLegacyRelayPane,
|
||||
detachLegacyRelayPane,
|
||||
installLegacyRelayBridge,
|
||||
legacyRelayBridgedPanes,
|
||||
@@ -74,6 +75,7 @@ export function registerLegacyRelayBridgeIpc(args: {
|
||||
})
|
||||
ipcMain.removeHandler(CHANNELS.listPanes)
|
||||
ipcMain.removeHandler(CHANNELS.attach)
|
||||
ipcMain.removeHandler(CHANNELS.close)
|
||||
for (const channel of [
|
||||
CHANNELS.write,
|
||||
CHANNELS.resize,
|
||||
@@ -87,6 +89,10 @@ export function registerLegacyRelayBridgeIpc(args: {
|
||||
const args = PaneArgsSchema.safeParse(payload)
|
||||
return args.success ? attachLegacyRelayPane(args.data.ptyId) : { status: 'not-bridged' }
|
||||
})
|
||||
ipcMain.handle(CHANNELS.close, async (_event, payload: unknown) => {
|
||||
const args = PaneArgsSchema.safeParse(payload)
|
||||
return args.success ? closeLegacyRelayPane(args.data.ptyId) : { status: 'not-bridged' }
|
||||
})
|
||||
ipcMain.on(CHANNELS.write, (_event, payload: unknown) => {
|
||||
const args = WriteArgsSchema.safeParse(payload)
|
||||
if (args.success) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Which relay-era leases the bridge may serve.
|
||||
* COMPAT(legacy-relay-bridge): revisit 2027-01-15.
|
||||
*/
|
||||
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
@@ -35,6 +36,14 @@ export function bridgeableLeasesByRelayId(
|
||||
return new Map(leases.filter(isBridgeableLease).map((lease) => [lease.ptyId, lease]))
|
||||
}
|
||||
|
||||
export function leasedAppPtyIds(targetId: string, leases: SshRemotePtyLease[]): Set<string> {
|
||||
return new Set(
|
||||
[...bridgeableLeasesByRelayId(leases).keys()].map((relayPtyId) =>
|
||||
toAppSshPtyId(targetId, relayPtyId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** One leased pane the bridge tracks, and the relay endpoint that listed it. */
|
||||
export type Pane = {
|
||||
lease: SshRemotePtyLease
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { EventProps } from '../../../shared/telemetry-events'
|
||||
import type {
|
||||
LegacyRelayBridgeAttachResult,
|
||||
LegacyRelayBridgeCloseResult,
|
||||
LegacyRelayBridgedPane
|
||||
} from '../../../shared/legacy-relay-bridge-contract'
|
||||
import { parseAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
@@ -19,7 +20,12 @@ import { createLegacyRelayCliServer } from './legacy-relay-cli-server'
|
||||
import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints'
|
||||
import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge'
|
||||
import { LegacyRelayHiddenOutput } from './legacy-relay-hidden-output'
|
||||
import { bridgeableLeasesByRelayId, isBridgeableLease } from './legacy-relay-bridge-leases'
|
||||
import { LegacyRelayPaneStops } from './legacy-relay-pane-stops'
|
||||
import {
|
||||
bridgeableLeasesByRelayId,
|
||||
isBridgeableLease,
|
||||
leasedAppPtyIds
|
||||
} from './legacy-relay-bridge-leases'
|
||||
|
||||
/** How long a connect waits for the first look at its relays before publishing the host. */
|
||||
const START_BUDGET_MS = 20_000
|
||||
@@ -53,6 +59,7 @@ const bridges = new Map<string, LegacyRelayTargetBridge>()
|
||||
* or the leases go: a hang-up is loss of contact, not exit (docs/reference/ssh-execution-boundary.md).
|
||||
*/
|
||||
const heldPanes = new Map<string, LegacyRelayBridgedPane[]>()
|
||||
const paneStops = new Map<string, LegacyRelayPaneStops>()
|
||||
|
||||
export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): void {
|
||||
for (const bridge of bridges.values()) {
|
||||
@@ -60,6 +67,8 @@ export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): vo
|
||||
}
|
||||
bridges.clear()
|
||||
heldPanes.clear()
|
||||
paneStops.forEach((stops) => stops.dispose())
|
||||
paneStops.clear()
|
||||
host = next
|
||||
hiddenOutput = next
|
||||
? new LegacyRelayHiddenOutput({
|
||||
@@ -135,6 +144,7 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null {
|
||||
// A running bridge's own listing answers for its target; a disposed one's does not.
|
||||
if (bridges.get(targetId) === bridge) {
|
||||
heldPanes.delete(targetId)
|
||||
stopsFor(targetId).flush(bridge)
|
||||
}
|
||||
installed.onPanesChanged(legacyRelayBridgedPanes())
|
||||
},
|
||||
@@ -149,6 +159,16 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null {
|
||||
return bridge
|
||||
}
|
||||
|
||||
function stopsFor(targetId: string): LegacyRelayPaneStops {
|
||||
const existing = paneStops.get(targetId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = new LegacyRelayPaneStops(targetId, () => host?.leases(targetId) ?? [])
|
||||
paneStops.set(targetId, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** A dead socket is the one failed open that proves its relay gone; Windows pipes never are. */
|
||||
async function posixEndpointExited(
|
||||
conn: SshConnection,
|
||||
@@ -213,6 +233,8 @@ export function stopLegacyRelayBridge(targetId: string): void {
|
||||
export function forgetLegacyRelayTarget(targetId: string): void {
|
||||
stopLegacyRelayBridge(targetId)
|
||||
releaseHeldPanes(targetId)
|
||||
paneStops.get(targetId)?.dispose()
|
||||
paneStops.delete(targetId)
|
||||
}
|
||||
|
||||
function releaseHeldPanes(targetId: string): void {
|
||||
@@ -290,6 +312,22 @@ export function setLegacyRelayPaneOutputPaused(ptyId: string, paused: boolean):
|
||||
hiddenOutput?.setPaused(ptyId, paused)
|
||||
}
|
||||
|
||||
/** Whether this PTY is an older relay's terminal the bridge serves, so only the bridge stops it. */
|
||||
export function isLegacyRelayBridgedPty(ptyId: string): boolean {
|
||||
const targetId = parseAppSshPtyId(ptyId)?.connectionId
|
||||
return Boolean(targetId && host && leasedAppPtyIds(targetId, host.leases(targetId)).has(ptyId))
|
||||
}
|
||||
|
||||
/** Its tab closed: the relay stops the shell, or is asked once it can be reached. */
|
||||
export async function closeLegacyRelayPane(ptyId: string): Promise<LegacyRelayBridgeCloseResult> {
|
||||
const targetId = parseAppSshPtyId(ptyId)?.connectionId
|
||||
if (!targetId || !host || !hasBridgeableLeases(targetId)) {
|
||||
return { status: 'not-bridged' }
|
||||
}
|
||||
// Disconnected: recorded now, and the target's next connect lists the relay and sends the stop.
|
||||
return stopsFor(targetId).stop(ptyId, host.connection(targetId) ? bridgeFor(targetId) : null)
|
||||
}
|
||||
|
||||
export function detachLegacyRelayPane(ptyId: string): void {
|
||||
bridgeForPty(ptyId)?.detach(ptyId)
|
||||
}
|
||||
|
||||
@@ -243,6 +243,21 @@ describe('the legacy relay client', () => {
|
||||
expect(postOutput).toHaveBeenCalledWith(params, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('stops a terminal with the old relay’s own pty.shutdown request', async () => {
|
||||
const client = await open()
|
||||
fake.respond = () => undefined
|
||||
await client.shutdown('pty-1')
|
||||
expect(fake.requests.at(-1)).toEqual({
|
||||
method: 'pty.shutdown',
|
||||
params: { id: 'pty-1', immediate: false, keepHistory: false }
|
||||
})
|
||||
await client.shutdown('pty-1', 'inc-1')
|
||||
expect(fake.requests.at(-1)).toEqual({
|
||||
method: 'pty.shutdown',
|
||||
params: { id: 'pty-1', immediate: false, keepHistory: false, expectedIncarnationId: 'inc-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('hangs up only itself on close', async () => {
|
||||
const client = await open()
|
||||
const closed = vi.fn()
|
||||
|
||||
@@ -212,6 +212,23 @@ export class LegacyRelayClient {
|
||||
this.mux.notify('pty.resize', { id: relayPtyId, cols, rows })
|
||||
}
|
||||
|
||||
/**
|
||||
* The old relay's own stop: it kills the shell and reports `pty.exit` (v1.4.2xx wire shape).
|
||||
* The incarnation fence is omitted, never sent undefined, for relays that predate it.
|
||||
*/
|
||||
async shutdown(relayPtyId: string, expectedIncarnationId?: string): Promise<void> {
|
||||
await this.mux.request(
|
||||
'pty.shutdown',
|
||||
{
|
||||
id: relayPtyId,
|
||||
immediate: false,
|
||||
keepHistory: false,
|
||||
...(expectedIncarnationId ? { expectedIncarnationId } : {})
|
||||
},
|
||||
{ timeoutMs: REQUEST_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
|
||||
onData(listener: DataListener): () => void {
|
||||
this.dataListeners.add(listener)
|
||||
return () => this.dataListeners.delete(listener)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* One target's stops for bridged panes whose tab closed, sent with the old relay's own
|
||||
* `pty.shutdown`. COMPAT(legacy-relay-bridge): revisit 2027-01-15.
|
||||
*
|
||||
* Held outside the bridge, so a reconnect's new bridge still sends them. A stop is pending until
|
||||
* the relay reports the exit or a listing proves the terminal gone; while the relay can't be asked
|
||||
* the answer is `unverifiable` and the stop goes out once it can. Loss of contact is never exit.
|
||||
*/
|
||||
import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import type { LegacyRelayBridgeCloseResult } from '../../../shared/legacy-relay-bridge-contract'
|
||||
import { leasedAppPtyIds } from './legacy-relay-bridge-leases'
|
||||
import { LegacyRelayRetry } from './legacy-relay-bridge-timers'
|
||||
import type { LegacyRelayClient } from './legacy-relay-client'
|
||||
import type { LegacyRelayTargetBridge } from './legacy-relay-target-bridge'
|
||||
|
||||
type StopTarget = Pick<
|
||||
LegacyRelayTargetBridge,
|
||||
'snapshot' | 'liveRoute' | 'listedIncarnation' | 'refresh'
|
||||
>
|
||||
|
||||
/**
|
||||
* Who the closed tab's process was: the relay's incarnation when it reports one, else the live
|
||||
* connection that showed it. Neither, for a stop recorded while the relay couldn't be asked.
|
||||
*/
|
||||
type PendingStop = { incarnationId?: string; client?: LegacyRelayClient }
|
||||
|
||||
export class LegacyRelayPaneStops {
|
||||
private readonly closing = new Map<string, PendingStop>()
|
||||
private bridge: StopTarget | null = null
|
||||
private readonly relist = new LegacyRelayRetry(() => void this.bridge?.refresh())
|
||||
|
||||
constructor(
|
||||
private readonly targetId: string,
|
||||
private readonly leases: () => SshRemotePtyLease[]
|
||||
) {}
|
||||
|
||||
/** `bridge` is null while the target is disconnected; its next listing sends the stop. */
|
||||
async stop(ptyId: string, bridge: StopTarget | null): Promise<LegacyRelayBridgeCloseResult> {
|
||||
const pane = bridge?.snapshot().find((entry) => entry.ptyId === ptyId)
|
||||
if (!pane && !leasedAppPtyIds(this.targetId, this.leases()).has(ptyId)) {
|
||||
return { status: 'exited' }
|
||||
}
|
||||
// A repeated close while the relay can't be asked keeps the identity the first one saw.
|
||||
const earlier = this.closing.get(ptyId)
|
||||
const incarnationId = bridge?.listedIncarnation(ptyId) ?? earlier?.incarnationId
|
||||
const client = bridge?.liveRoute(ptyId)?.client ?? earlier?.client
|
||||
this.closing.set(ptyId, {
|
||||
...(incarnationId ? { incarnationId } : {}),
|
||||
...(client ? { client } : {})
|
||||
})
|
||||
if (!bridge || pane?.state !== 'live') {
|
||||
return { status: 'unverifiable' }
|
||||
}
|
||||
const sent = await this.send(ptyId, bridge)
|
||||
// Only the relay's exit report or a listing without the terminal proves the stop.
|
||||
void bridge.refresh()
|
||||
return sent ? { status: 'requested' } : { status: 'unverifiable' }
|
||||
}
|
||||
|
||||
/** Runs after each listing: re-sends pending stops, re-listing on backoff while one still runs. */
|
||||
flush(bridge: StopTarget): void {
|
||||
this.bridge = bridge
|
||||
const panes = new Map(bridge.snapshot().map((pane) => [pane.ptyId, pane]))
|
||||
const leased = leasedAppPtyIds(this.targetId, this.leases())
|
||||
let pending = false
|
||||
for (const ptyId of this.closing.keys()) {
|
||||
const pane = panes.get(ptyId)
|
||||
if (!pane) {
|
||||
if (!leased.has(ptyId)) {
|
||||
this.closing.delete(ptyId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pending = true
|
||||
if (pane.state === 'live') {
|
||||
void this.send(ptyId, bridge)
|
||||
}
|
||||
}
|
||||
if (pending) {
|
||||
this.relist.schedule()
|
||||
} else {
|
||||
this.relist.reset()
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.relist.cancel()
|
||||
this.closing.clear()
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.closing.size
|
||||
}
|
||||
|
||||
/** The old relay's own stop, fenced to the process the tab ran; false when it can't be asked. */
|
||||
private async send(ptyId: string, bridge: StopTarget): Promise<boolean> {
|
||||
const route = bridge.liveRoute(ptyId)
|
||||
const pending = this.closing.get(ptyId)
|
||||
if (!route || !pending) {
|
||||
return false
|
||||
}
|
||||
const listed = bridge.listedIncarnation(ptyId)
|
||||
// Only positive identity sends a stop: a legacy `pty-N` id restarts at 1 with its relay, and an
|
||||
// older relay ignores the incarnation fence. A `pty2:` id carries its relay's start epoch.
|
||||
const sameProcess = route.client === pending.client || route.relayPtyId.startsWith('pty2:')
|
||||
const proven = pending.incarnationId
|
||||
? listed === pending.incarnationId || (listed === undefined && sameProcess)
|
||||
: sameProcess
|
||||
if (!proven) {
|
||||
this.closing.delete(ptyId)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await route.client.shutdown(route.relayPtyId, pending.incarnationId)
|
||||
return true
|
||||
} catch (error) {
|
||||
// The relay's own fence refused it: that id now runs another process.
|
||||
if (/incarnation mismatch/i.test(error instanceof Error ? error.message : String(error))) {
|
||||
this.closing.delete(ptyId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { SshRemotePtyLease } from '../../../shared/ssh-types'
|
||||
import { getRemoteHostPlatform } from '../ssh-remote-platform'
|
||||
import type { LegacyRelayClient } from './legacy-relay-client'
|
||||
import type { LegacyRelayEndpoint, LegacyRelayEndpointListing } from './legacy-relay-endpoints'
|
||||
import { LegacyRelayPaneStops } from './legacy-relay-pane-stops'
|
||||
import {
|
||||
LegacyRelayTargetBridge,
|
||||
type LegacyRelayTargetBridgeDeps
|
||||
@@ -17,6 +18,12 @@ type FakeRelay = {
|
||||
attachError: Error | null
|
||||
clients: FakeClient[]
|
||||
openError: Error | null
|
||||
shutdowns: string[]
|
||||
shutdownError: Error | null
|
||||
/** The process identity each listed PTY reports, when the relay reports one. */
|
||||
incarnations: Map<string, string>
|
||||
/** Whether a stop reports `pty.exit` (the relay's normal answer). */
|
||||
exitOnShutdown: boolean
|
||||
}
|
||||
|
||||
type FakeClient = LegacyRelayClient & {
|
||||
@@ -43,13 +50,27 @@ function fakeClient(relay: FakeRelay): FakeClient {
|
||||
get closed() {
|
||||
return closed
|
||||
},
|
||||
list: async () => [...relay.ptys].map((relayPtyId) => ({ relayPtyId })),
|
||||
list: async () =>
|
||||
[...relay.ptys].map((relayPtyId) => {
|
||||
const incarnationId = relay.incarnations.get(relayPtyId)
|
||||
return incarnationId ? { relayPtyId, incarnationId } : { relayPtyId }
|
||||
}),
|
||||
attach: async () => {
|
||||
if (relay.attachError) {
|
||||
throw relay.attachError
|
||||
}
|
||||
return { replay: 'SCREEN' }
|
||||
},
|
||||
shutdown: async (id: string, expectedIncarnationId?: string) => {
|
||||
if (relay.shutdownError) {
|
||||
throw relay.shutdownError
|
||||
}
|
||||
relay.shutdowns.push(expectedIncarnationId ? `${id}#${expectedIncarnationId}` : id)
|
||||
relay.ptys.delete(id)
|
||||
if (relay.exitOnShutdown) {
|
||||
exits.forEach((listener) => listener(id, 0))
|
||||
}
|
||||
},
|
||||
write: (_id: string, chunk: string) => writes.push(chunk),
|
||||
resize: () => {},
|
||||
onData: (listener: (id: string, data: string) => void) => {
|
||||
@@ -99,10 +120,11 @@ let relay: FakeRelay
|
||||
let leases: SshRemotePtyLease[]
|
||||
let listing: LegacyRelayEndpointListing
|
||||
let endpointExited: boolean
|
||||
let stops: LegacyRelayPaneStops
|
||||
let events: { data: string[]; exits: string[]; exitedTelemetry: number; panesChanged: number }
|
||||
|
||||
function bridge(overrides: Partial<LegacyRelayTargetBridgeDeps> = {}): LegacyRelayTargetBridge {
|
||||
return new LegacyRelayTargetBridge({
|
||||
const subject: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({
|
||||
targetId: TARGET,
|
||||
leases: () => leases.filter((row) => row.state !== 'terminated'),
|
||||
retireLease: (relayPtyId) => {
|
||||
@@ -122,6 +144,7 @@ function bridge(overrides: Partial<LegacyRelayTargetBridgeDeps> = {}): LegacyRel
|
||||
endpointExited: async () => endpointExited,
|
||||
onPanesChanged: () => {
|
||||
events.panesChanged += 1
|
||||
stops.flush(subject)
|
||||
},
|
||||
onData: (_id, chunk) => events.data.push(chunk),
|
||||
onExit: (id) => events.exits.push(id),
|
||||
@@ -130,11 +153,22 @@ function bridge(overrides: Partial<LegacyRelayTargetBridgeDeps> = {}): LegacyRel
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
return subject
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
relay = { ptys: new Set(['pty-1']), attachError: null, clients: [], openError: null }
|
||||
relay = {
|
||||
ptys: new Set(['pty-1']),
|
||||
attachError: null,
|
||||
clients: [],
|
||||
openError: null,
|
||||
shutdowns: [],
|
||||
shutdownError: null,
|
||||
incarnations: new Map(),
|
||||
exitOnShutdown: true
|
||||
}
|
||||
stops = new LegacyRelayPaneStops(TARGET, () => leases.filter((row) => row.state !== 'terminated'))
|
||||
leases = [lease()]
|
||||
listing = { kind: 'listed', endpoints: [ENDPOINT], exhaustive: true }
|
||||
endpointExited = false
|
||||
@@ -297,6 +331,117 @@ describe('one target’s legacy relay bridge', () => {
|
||||
expect(relay.clients).toEqual([])
|
||||
})
|
||||
|
||||
it('stops a closed tab’s shell through the relay and retires it on the relay’s exit report', async () => {
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'requested' })
|
||||
expect(relay.shutdowns).toEqual(['pty-1'])
|
||||
expect(events.exits).toEqual([PTY])
|
||||
expect(leases[0].state).toBe('terminated')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(relay.shutdowns).toEqual(['pty-1'])
|
||||
})
|
||||
|
||||
it('retires a stopped shell once a listing proves it gone, without an exit report', async () => {
|
||||
relay.exitOnShutdown = false
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
await stops.stop(PTY, subject)
|
||||
expect(leases[0].state).toBe('detached')
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(leases[0].state).toBe('terminated')
|
||||
expect(stops.size).toBe(0)
|
||||
})
|
||||
|
||||
it('holds a close for an unreachable relay as unverifiable and sends it once the relay answers', async () => {
|
||||
relay.incarnations.set('pty-1', 'inc-1')
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
relay.openError = new Error('ssh down')
|
||||
relay.clients[0].drop()
|
||||
await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'unverifiable' })
|
||||
expect(relay.shutdowns).toEqual([])
|
||||
// Loss of contact is never exit.
|
||||
expect(leases[0].state).toBe('detached')
|
||||
expect(events.exits).toEqual([])
|
||||
relay.openError = null
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
// Fenced to the process the tab ran.
|
||||
expect(relay.shutdowns).toEqual(['pty-1#inc-1'])
|
||||
expect(leases[0].state).toBe('terminated')
|
||||
})
|
||||
|
||||
it('keeps the identity a first close saw when the tab’s close repeats while unreachable', async () => {
|
||||
relay.incarnations.set('pty-1', 'inc-1')
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
relay.openError = new Error('ssh down')
|
||||
relay.clients[0].drop()
|
||||
await stops.stop(PTY, subject)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await stops.stop(PTY, subject)
|
||||
relay.openError = null
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(relay.shutdowns).toEqual(['pty-1#inc-1'])
|
||||
})
|
||||
|
||||
it('never stops a different process that took a closed tab’s recycled relay id', async () => {
|
||||
relay.incarnations.set('pty-1', 'inc-1')
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
relay.openError = new Error('ssh down')
|
||||
relay.clients[0].drop()
|
||||
await stops.stop(PTY, subject)
|
||||
relay.incarnations.set('pty-1', 'inc-2')
|
||||
relay.openError = null
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(relay.shutdowns).toEqual([])
|
||||
expect(stops.size).toBe(0)
|
||||
})
|
||||
|
||||
it('sends a close recorded while disconnected once the next bridge lists the pane', async () => {
|
||||
const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1')
|
||||
leases = [lease({ ptyId: 'pty2:epoch:1' })]
|
||||
relay.ptys = new Set(['pty2:epoch:1'])
|
||||
await expect(stops.stop(epochPty, null)).resolves.toEqual({ status: 'unverifiable' })
|
||||
expect(leases[0].state).toBe('detached')
|
||||
await bridge().refresh()
|
||||
expect(relay.shutdowns).toEqual(['pty2:epoch:1'])
|
||||
})
|
||||
|
||||
it('never replays a blind close for a legacy id that could name another shell', async () => {
|
||||
await stops.stop(PTY, null)
|
||||
await bridge().refresh()
|
||||
expect(relay.shutdowns).toEqual([])
|
||||
expect(leases[0].state).toBe('detached')
|
||||
})
|
||||
|
||||
it('never replays a fenced legacy stop to a relay that no longer reports the incarnation', async () => {
|
||||
relay.incarnations.set('pty-1', 'inc-1')
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
relay.openError = new Error('ssh down')
|
||||
relay.clients[0].drop()
|
||||
await stops.stop(PTY, subject)
|
||||
// A restarted or downgraded relay lists the same id with no identity, and may ignore the fence.
|
||||
relay.incarnations.clear()
|
||||
relay.openError = null
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(relay.shutdowns).toEqual([])
|
||||
})
|
||||
|
||||
it('never replays an unfenced legacy stop on a later connection, which may be a new relay', async () => {
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
relay.shutdownError = new Error('timed out')
|
||||
await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'unverifiable' })
|
||||
relay.shutdownError = null
|
||||
relay.clients[0].drop()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(relay.clients.length).toBeGreaterThan(1)
|
||||
expect(relay.shutdowns).toEqual([])
|
||||
})
|
||||
|
||||
it('hangs up every connection on dispose and leaves the relays to their own grace', async () => {
|
||||
const subject = bridge()
|
||||
await subject.refresh()
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ATTACH_WAIT_MS,
|
||||
bridgeableLeasesByRelayId,
|
||||
findLivePane,
|
||||
leasedAppPtyIds,
|
||||
type LegacyRelayPty,
|
||||
PROVEN_EXITED,
|
||||
toBridgedPane,
|
||||
@@ -54,6 +55,8 @@ export class LegacyRelayTargetBridge {
|
||||
private readonly endpoints = new Map<string, EndpointState>()
|
||||
private readonly panes = new Map<string, Pane>()
|
||||
readonly livePaneForKey = (paneKey: string) => findLivePane(this.panes, paneKey)
|
||||
/** The process identity the relay listed for a pane, which fences a stop against a recycled id. */
|
||||
readonly listedIncarnation = (ptyId: string) => this.panes.get(ptyId)?.listed?.incarnationId
|
||||
private readonly attached = new Map<string, number>()
|
||||
private readonly waiters = new LegacyRelayWaiters()
|
||||
private readonly retry = new LegacyRelayRetry(() => void this.refresh())
|
||||
@@ -101,9 +104,7 @@ export class LegacyRelayTargetBridge {
|
||||
pane = this.panes.get(ptyId)
|
||||
}
|
||||
if (!pane) {
|
||||
const leased = [...bridgeableLeasesByRelayId(this.deps.leases()).keys()].some(
|
||||
(relayPtyId) => toAppSshPtyId(this.deps.targetId, relayPtyId) === ptyId
|
||||
)
|
||||
const leased = leasedAppPtyIds(this.deps.targetId, this.deps.leases()).has(ptyId)
|
||||
return leased ? { status: 'unverifiable' } : { status: 'exited' }
|
||||
}
|
||||
const client = pane.endpoint ? this.endpoints.get(pane.endpoint)?.client : null
|
||||
@@ -318,7 +319,8 @@ export class LegacyRelayTargetBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private liveRoute(ptyId: string): { client: LegacyRelayClient; relayPtyId: string } | null {
|
||||
/** The connection that carries a live pane, or null while its relay can't be asked. */
|
||||
liveRoute(ptyId: string): { client: LegacyRelayClient; relayPtyId: string } | null {
|
||||
const pane = this.panes.get(ptyId)
|
||||
const client = pane?.endpoint ? this.endpoints.get(pane.endpoint)?.client : null
|
||||
return pane?.state === 'live' && client ? { client, relayPtyId: pane.relayPtyId } : null
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import {
|
||||
LEGACY_RELAY_BRIDGE_CHANNELS as CHANNELS,
|
||||
type LegacyRelayBridgeAttachResult,
|
||||
type LegacyRelayBridgeCloseResult,
|
||||
type LegacyRelayBridgedPane
|
||||
} from '../../shared/legacy-relay-bridge-contract'
|
||||
|
||||
@@ -15,6 +16,8 @@ export type LegacyRelayBridgeApi = {
|
||||
write: (args: { ptyId: string; data: string }) => void
|
||||
resize: (args: { ptyId: string; cols: number; rows: number }) => void
|
||||
detach: (args: { ptyId: string }) => void
|
||||
/** Stops the shell its closed tab ran. */
|
||||
close: (args: { ptyId: string }) => Promise<LegacyRelayBridgeCloseResult>
|
||||
setOutputPaused: (args: { ptyId: string; paused: boolean }) => void
|
||||
onPanes: (callback: (panes: LegacyRelayBridgedPane[]) => void) => () => void
|
||||
onData: (callback: (event: { ptyId: string; data: string }) => void) => () => void
|
||||
@@ -36,6 +39,7 @@ export const legacyRelayBridgeApi: LegacyRelayBridgeApi = {
|
||||
write: (args) => ipcRenderer.send(CHANNELS.write, args),
|
||||
resize: (args) => ipcRenderer.send(CHANNELS.resize, args),
|
||||
detach: (args) => ipcRenderer.send(CHANNELS.detach, args),
|
||||
close: (args) => ipcRenderer.invoke(CHANNELS.close, args),
|
||||
setOutputPaused: (args) => ipcRenderer.send(CHANNELS.setOutputPaused, args),
|
||||
onPanes: (callback) => subscribe(CHANNELS.panes, callback),
|
||||
onData: (callback) => subscribe(CHANNELS.data, callback),
|
||||
|
||||
@@ -14,6 +14,7 @@ const listeners = new Set<() => void>()
|
||||
/** Which pane (tab and leaf) holds each bridged PTY; a second pane never drives the same shell. */
|
||||
const claims = new Map<string, string>()
|
||||
let subscribed = false
|
||||
let firstListing: Promise<void> = Promise.resolve()
|
||||
|
||||
function ensureSubscribed(): void {
|
||||
const api = typeof window === 'undefined' ? undefined : window.api?.legacyRelayBridge
|
||||
@@ -31,7 +32,13 @@ function ensureSubscribed(): void {
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
api.onPanes(apply)
|
||||
void api.listPanes().then(apply, () => {})
|
||||
firstListing = api.listPanes().then(apply, () => {})
|
||||
}
|
||||
|
||||
/** Settles once main's first pane list arrived, so a close never reads the empty initial cache. */
|
||||
export function legacyRelayBridgedPanesListed(): Promise<void> {
|
||||
ensureSubscribed()
|
||||
return firstListing
|
||||
}
|
||||
|
||||
ensureSubscribed()
|
||||
@@ -87,6 +94,26 @@ export function hasLegacyRelayBridgedPane(tabId: string): boolean {
|
||||
return bridgedPanesInTab(tabId, null).inTab.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridged PTYs a closing tab runs: the panes main lists for its relay-era tab (only the named
|
||||
* leaf for a `tab::leaf` mirror), plus any bridged PTY the tab itself was bound to.
|
||||
*/
|
||||
export function legacyRelayBridgedPtyIdsForClosingTab(
|
||||
tabId: string,
|
||||
boundPtyIds: readonly string[]
|
||||
): string[] {
|
||||
const { inTab, leaf } = bridgedPanesInTab(tabId, null)
|
||||
const named =
|
||||
leaf === null ? inTab : [bridgedPaneFor(tabId, null)].filter((pane) => pane !== undefined)
|
||||
const ids = new Set(named.map((pane) => pane.ptyId))
|
||||
for (const ptyId of boundPtyIds) {
|
||||
if (panes.some((pane) => pane.ptyId === ptyId)) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay-era PTY a runtime pane should reattach through the bridge: one main reports serving
|
||||
* for this tab and leaf, or the `ssh:` id the pane itself still names from an earlier bridging.
|
||||
|
||||
@@ -9,6 +9,7 @@ const bridge = vi.hoisted(() => ({
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn(async () => ({ status: 'requested' })),
|
||||
setOutputPaused: vi.fn(),
|
||||
listPanes: vi.fn(async (): Promise<LegacyRelayBridgedPane[]> => []),
|
||||
panes: new Set<(panes: LegacyRelayBridgedPane[]) => void>(),
|
||||
@@ -25,6 +26,7 @@ vi.stubGlobal('window', {
|
||||
write: bridge.write,
|
||||
resize: bridge.resize,
|
||||
detach: bridge.detach,
|
||||
close: bridge.close,
|
||||
setOutputPaused: bridge.setOutputPaused,
|
||||
onPanes: (listener: (panes: LegacyRelayBridgedPane[]) => void) => {
|
||||
bridge.panes.add(listener)
|
||||
@@ -48,7 +50,8 @@ vi.stubGlobal('window', {
|
||||
|
||||
const { createLegacyRelayBridgeTransport: createTransport } =
|
||||
await import('./legacy-relay-bridge-transport')
|
||||
const { legacyRelayBridgePtyIdForPane } = await import('./legacy-relay-bridge-panes')
|
||||
const { legacyRelayBridgePtyIdForPane, legacyRelayBridgedPtyIdsForClosingTab } =
|
||||
await import('./legacy-relay-bridge-panes')
|
||||
|
||||
const created: PtyTransport[] = []
|
||||
function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransport): PtyTransport {
|
||||
@@ -280,6 +283,40 @@ describe('a runtime pane bridged to an older relay’s terminal', () => {
|
||||
await vi.waitFor(() => expect(cb.onExit).toHaveBeenCalledWith(-1))
|
||||
})
|
||||
|
||||
it('stops the old shell when its pane is closed, and never when it is only detached', async () => {
|
||||
bridge.attach.mockResolvedValue({ status: 'attached', replay: '' })
|
||||
const detached = createLegacyRelayBridgeTransport(PTY, innerTransport())
|
||||
await detached.connect({ url: '', sessionId: PTY, callbacks: callbacks() })
|
||||
detached.detach?.()
|
||||
expect(bridge.close).not.toHaveBeenCalled()
|
||||
const closed = createLegacyRelayBridgeTransport(PTY, innerTransport())
|
||||
await closed.connect({ url: '', sessionId: PTY, callbacks: callbacks() })
|
||||
closed.destroy?.()
|
||||
expect(bridge.close).toHaveBeenCalledWith({ ptyId: PTY })
|
||||
})
|
||||
|
||||
it('names the bridged shells a closing tab runs, or only its leaf for a split mirror', () => {
|
||||
const second = 'ssh:ssh-box@@pty-2'
|
||||
publishPanes([
|
||||
{ ptyId: PTY, targetId: 'ssh-box', tabId: 'tab-1', leafId: 'leaf-1', state: 'live' },
|
||||
{
|
||||
ptyId: second,
|
||||
targetId: 'ssh-box',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-2',
|
||||
state: 'unverifiable'
|
||||
},
|
||||
{ ptyId: 'ssh:ssh-box@@pty-3', targetId: 'ssh-box', tabId: 'tab-2', state: 'live' }
|
||||
])
|
||||
expect(legacyRelayBridgedPtyIdsForClosingTab('web-terminal-tab-1', [])).toEqual([PTY, second])
|
||||
const leaf = `web-terminal-${encodeURIComponent('tab-1::leaf-2')}`
|
||||
expect(legacyRelayBridgedPtyIdsForClosingTab(leaf, [])).toEqual([second])
|
||||
// A tab still bound to a bridged shell names it even under another id; others are ignored.
|
||||
expect(
|
||||
legacyRelayBridgedPtyIdsForClosingTab('local-tab', ['ssh:ssh-box@@pty-3', 'pty-local'])
|
||||
).toEqual(['ssh:ssh-box@@pty-3'])
|
||||
})
|
||||
|
||||
it('hands any other connect straight to the managed server’s transport', async () => {
|
||||
const inner = innerTransport()
|
||||
const transport = createLegacyRelayBridgeTransport(PTY, inner)
|
||||
|
||||
@@ -278,6 +278,10 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp
|
||||
inner.detach?.(options)
|
||||
},
|
||||
destroy: (options) => {
|
||||
if (bridged()) {
|
||||
// An explicit pane close: stop the shell, as the other transports' destroy does.
|
||||
void api?.close({ ptyId }).catch(() => {})
|
||||
}
|
||||
if (bridged() || mode === 'pending') {
|
||||
release()
|
||||
mode = 'pending'
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalTabRetirementPlan } from '../slices/terminal-tab-retirement'
|
||||
|
||||
const PTY = 'ssh:ssh-box@@pty-1'
|
||||
const TAB = 'web-terminal-tab-1'
|
||||
|
||||
const api = vi.hoisted(() => {
|
||||
let release: () => void = () => {}
|
||||
const listed = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return {
|
||||
close: vi.fn(async () => ({ status: 'requested' })),
|
||||
// Main answers the first listing late, after the close already started.
|
||||
listPanes: vi.fn(async () => {
|
||||
await listed
|
||||
return [{ ptyId: 'ssh:ssh-box@@pty-1', targetId: 'ssh-box', tabId: 'tab-1', state: 'live' }]
|
||||
}),
|
||||
release: () => release()
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
legacyRelayBridge: { listPanes: api.listPanes, close: api.close, onPanes: () => () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
const { stopLegacyRelayBridgedShellsForTab } = await import('./legacy-relay-bridged-tab-close')
|
||||
|
||||
function plan(overrides: Partial<TerminalTabRetirementPlan> = {}): TerminalTabRetirementPlan {
|
||||
return {
|
||||
tabId: TAB,
|
||||
worktreeId: 'wt-1',
|
||||
ptyIds: [],
|
||||
localOrSshPtyIds: [],
|
||||
runtimeTerminals: [],
|
||||
cleanupOnlyPtyIds: [],
|
||||
sharedPtyIds: [],
|
||||
unroutablePtyIds: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function state(otherTabPtyId: string | null = null) {
|
||||
return {
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: TAB, ptyId: PTY },
|
||||
...(otherTabPtyId ? [{ id: 'moved-to', ptyId: otherTabPtyId }] : [])
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
deferredSshSessionIdsByTabId: {},
|
||||
pendingReconnectPtyIdByTabId: {}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.close.mockClear()
|
||||
})
|
||||
|
||||
describe('closing a tab whose shell an older relay still runs', () => {
|
||||
it('stops that shell, waiting for main’s first pane list rather than reading it empty', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close reads only the tab and pty maps this fixture fills.
|
||||
const closing = stopLegacyRelayBridgedShellsForTab(state() as never, TAB, plan())
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(api.close).not.toHaveBeenCalled()
|
||||
api.release()
|
||||
await closing
|
||||
expect(api.close).toHaveBeenCalledWith({ ptyId: PTY })
|
||||
})
|
||||
|
||||
it('never stops a shell another tab now shows, or one the plan says is shared', async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
await stopLegacyRelayBridgedShellsForTab(state(PTY) as never, TAB, plan())
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
await stopLegacyRelayBridgedShellsForTab(state() as never, TAB, plan({ sharedPtyIds: [PTY] }))
|
||||
expect(api.close).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A closed tab stops the shells an older Orca's relay still runs for it, as closing any other
|
||||
* terminal tab ends its shell. COMPAT(legacy-relay-bridge): revisit 2027-01-15.
|
||||
*/
|
||||
import {
|
||||
collectPtyIdsForTab,
|
||||
type TerminalTabRetirementPlan,
|
||||
type TerminalTabRetirementState
|
||||
} from '../slices/terminal-tab-retirement'
|
||||
|
||||
/** `state` is read before the close prunes the tab, so its own PTYs are still listed. */
|
||||
export async function stopLegacyRelayBridgedShellsForTab(
|
||||
state: TerminalTabRetirementState,
|
||||
tabId: string,
|
||||
plan: TerminalTabRetirementPlan
|
||||
): Promise<void> {
|
||||
const api = window.api?.legacyRelayBridge
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
// Loaded lazily: the module subscribes to main's pane list on import.
|
||||
const { legacyRelayBridgedPanesListed, legacyRelayBridgedPtyIdsForClosingTab } =
|
||||
await import('@/components/terminal-pane/legacy-relay-bridge-panes')
|
||||
await legacyRelayBridgedPanesListed()
|
||||
// Why: a pane moved to another tab keeps its shell, and main's lease list can lag the move.
|
||||
const keep = new Set(plan.sharedPtyIds)
|
||||
for (const tabs of Object.values(state.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.id !== tabId) {
|
||||
collectPtyIdsForTab(state, tab.id, tab.ptyId).forEach((ptyId) => keep.add(ptyId))
|
||||
}
|
||||
}
|
||||
}
|
||||
const ptyIds = legacyRelayBridgedPtyIdsForClosingTab(tabId, plan.ptyIds).filter(
|
||||
(ptyId) => !keep.has(ptyId)
|
||||
)
|
||||
await Promise.allSettled(ptyIds.map((ptyId) => api.close({ ptyId })))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../slices/terminal-tab-retirement'
|
||||
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
|
||||
import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers'
|
||||
import { stopLegacyRelayBridgedShellsForTab } from './legacy-relay-bridged-tab-close'
|
||||
import { commitTerminalSurfaceClose } from './terminal-surface-close-intent'
|
||||
import { recordTerminalTabCloseThroughLayoutOwner } from './terminal-tab-close-layout-owner'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
@@ -51,6 +52,10 @@ export function createTerminalTabCloseActions(
|
||||
state: get(),
|
||||
tabId
|
||||
})
|
||||
if (opts?.localPtyTeardownOwnedExternally !== true) {
|
||||
// Why regardless of a host-owned close: the managed server never ran a relay-era shell.
|
||||
void stopLegacyRelayBridgedShellsForTab(get(), tabId, retirementPlan)
|
||||
}
|
||||
}
|
||||
settleAttentionRemovedBy(set, get, () =>
|
||||
set((s) => {
|
||||
|
||||
@@ -20,12 +20,22 @@ export type LegacyRelayBridgeAttachResult =
|
||||
/** The relay holding it can't be reached right now; the pane stays bound and waits. */
|
||||
| { status: 'unverifiable' }
|
||||
|
||||
export type LegacyRelayBridgeCloseResult =
|
||||
/** The relay accepted the stop; its own exit report retires the pane. */
|
||||
| { status: 'requested' }
|
||||
/** No relay for the host can be holding it. */
|
||||
| { status: 'exited' }
|
||||
| { status: 'not-bridged' }
|
||||
/** The relay can't be asked now; the stop is sent once it can. Never read as exited. */
|
||||
| { status: 'unverifiable' }
|
||||
|
||||
export const LEGACY_RELAY_BRIDGE_CHANNELS = {
|
||||
listPanes: 'legacyRelayBridge:listPanes',
|
||||
attach: 'legacyRelayBridge:attach',
|
||||
write: 'legacyRelayBridge:write',
|
||||
resize: 'legacyRelayBridge:resize',
|
||||
detach: 'legacyRelayBridge:detach',
|
||||
close: 'legacyRelayBridge:close',
|
||||
setOutputPaused: 'legacyRelayBridge:setOutputPaused',
|
||||
panes: 'legacyRelayBridge:panes',
|
||||
data: 'legacyRelayBridge:data',
|
||||
|
||||
Reference in New Issue
Block a user