mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
fix(terminal): arbitrate active desktop viewport ownership
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+79
-18
@@ -4075,12 +4075,13 @@ describe('registerPtyHandlers', () => {
|
||||
applied: { cols: number; rows: number } | null
|
||||
resize?: (cols: number, rows: number) => void
|
||||
getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null>
|
||||
}): void {
|
||||
}): ReturnType<typeof vi.fn> {
|
||||
const write = vi.fn()
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async (opts: { sessionId?: string }) => ({
|
||||
id: opts.sessionId ?? 'daemon-pty'
|
||||
})),
|
||||
write: vi.fn(),
|
||||
write,
|
||||
resize: vi.fn(args.resize ?? (() => {})),
|
||||
getAppliedSize: vi.fn(args.getAppliedSize ?? (async () => args.applied)),
|
||||
kill: vi.fn(),
|
||||
@@ -4090,6 +4091,7 @@ describe('registerPtyHandlers', () => {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
getForegroundProcess: vi.fn(async () => null)
|
||||
} as never)
|
||||
return write
|
||||
}
|
||||
|
||||
const resizeListener = (): ((event: unknown, args: unknown) => void) => {
|
||||
@@ -4170,12 +4172,10 @@ describe('registerPtyHandlers', () => {
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
isResizeSuppressed: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
recordRemoteDesktopHostReclaimTarget: vi.fn(),
|
||||
onExternalPtyResize: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
@@ -4204,12 +4204,10 @@ describe('registerPtyHandlers', () => {
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
isResizeSuppressed: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
recordRemoteDesktopHostReclaimTarget: vi.fn(),
|
||||
onExternalPtyResize: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
@@ -4232,7 +4230,7 @@ describe('registerPtyHandlers', () => {
|
||||
getDriver: vi.fn(() => ({ kind: 'idle' })),
|
||||
// The whole point of the fix: a PTY with a remote viewer reports true,
|
||||
// even though the presence-lock driver state stays idle/desktop.
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => true),
|
||||
isRemoteDesktopResizeDriven: vi.fn(() => true),
|
||||
isResizeSuppressed: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
@@ -4254,6 +4252,80 @@ describe('registerPtyHandlers', () => {
|
||||
expect(runtime.recordRemoteDesktopHostReclaimTarget).toHaveBeenCalledWith(id, 125, 48)
|
||||
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets trusted host activity reclaim remote viewport ownership', () => {
|
||||
const claimRemoteDesktopHost = vi.fn().mockResolvedValue(true)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
claimRemoteDesktopHost
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport')
|
||||
const claimListener = call?.[1] as
|
||||
| ((event: unknown, args: { id: string; cols: number; rows: number }) => void)
|
||||
| undefined
|
||||
expect(claimListener).toBeTypeOf('function')
|
||||
|
||||
claimListener?.(mainWindowIpcEvent, { id: 'pty-1', cols: 125, rows: 48 })
|
||||
|
||||
expect(claimRemoteDesktopHost).toHaveBeenCalledWith('pty-1', 125, 48)
|
||||
})
|
||||
|
||||
it('does not forward host input when viewport reclaim fails', async () => {
|
||||
const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } })
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'idle' })),
|
||||
claimRemoteDesktopHost: vi.fn().mockResolvedValue(false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
const claim = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:claimViewport')
|
||||
const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write')
|
||||
|
||||
claim?.[1](mainWindowIpcEvent, { id, cols: 125, rows: 48 })
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: 'x' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not populate the remote reclaim cache when only a phone drives', async () => {
|
||||
const resizeSpy = vi.fn()
|
||||
setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy })
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'mobile', clientId: 'phone-A' })),
|
||||
isRemoteDesktopResizeDriven: vi.fn(() => false),
|
||||
isResizeSuppressed: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
recordRemoteDesktopHostReclaimTarget: vi.fn(),
|
||||
onExternalPtyResize: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
resizeSpy.mockClear()
|
||||
|
||||
resizeListener()(mainWindowIpcEvent, { id, cols: 125, rows: 48 })
|
||||
|
||||
expect(resizeSpy).not.toHaveBeenCalled()
|
||||
expect(runtime.recordRemoteDesktopHostReclaimTarget).not.toHaveBeenCalled()
|
||||
expect(runtime.onExternalPtyResize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
|
||||
@@ -4326,7 +4398,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -4406,7 +4477,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -4438,7 +4508,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -4476,7 +4545,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -4574,7 +4642,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -4627,7 +4694,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -5116,7 +5182,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -5374,7 +5439,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -5484,7 +5548,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -5579,7 +5642,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -5684,7 +5746,6 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
isPtyResizeDrivenRemotely: vi.fn(() => false),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
|
||||
+58
-3
@@ -4441,6 +4441,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
|
||||
type PtyWritePayload = { id: string; data: string }
|
||||
type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
|
||||
|
||||
const isPtyWritePayload = (value: unknown): value is PtyWritePayload =>
|
||||
typeof value === 'object' &&
|
||||
@@ -4449,6 +4450,18 @@ export function registerPtyHandlers(
|
||||
(value as { id: string }).id.length > 0 &&
|
||||
typeof (value as { data?: unknown }).data === 'string'
|
||||
|
||||
const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { id?: unknown }).id === 'string' &&
|
||||
(value as { id: string }).id.length > 0 &&
|
||||
typeof (value as { cols?: unknown }).cols === 'number' &&
|
||||
Number.isFinite((value as { cols: number }).cols) &&
|
||||
typeof (value as { rows?: unknown }).rows === 'number' &&
|
||||
Number.isFinite((value as { rows: number }).rows) &&
|
||||
(value as { cols: number }).cols > 0 &&
|
||||
(value as { rows: number }).rows > 0
|
||||
|
||||
const isPtyWriteEventFromMainWindow = (
|
||||
event: IpcMainEvent | IpcMainInvokeEvent,
|
||||
mainWebContents: WebContents
|
||||
@@ -4512,17 +4525,55 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const hostViewportClaimTails = new Map<string, Promise<boolean>>()
|
||||
|
||||
ipcMain.on('pty:write', (event, args: unknown) => {
|
||||
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
|
||||
return
|
||||
}
|
||||
const claimTail = hostViewportClaimTails.get(args.id)
|
||||
if (claimTail) {
|
||||
void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false))
|
||||
return
|
||||
}
|
||||
writePtyInput(args)
|
||||
})
|
||||
ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise<boolean> => {
|
||||
if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) {
|
||||
return false
|
||||
}
|
||||
return writePtyInputAccepted(args)
|
||||
const claimTail = hostViewportClaimTails.get(args.id)
|
||||
return claimTail
|
||||
? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false))
|
||||
: writePtyInputAccepted(args)
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:claimViewport')
|
||||
ipcMain.on('pty:claimViewport', (event, args: unknown) => {
|
||||
if (
|
||||
!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) ||
|
||||
!runtime ||
|
||||
!isPtyViewportClaimPayload(args)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const prior = hostViewportClaimTails.get(args.id)
|
||||
// Why: two panes can mirror one PTY. Never let a later no-op claim replace
|
||||
// the in-flight resize that the following host input must await.
|
||||
const claim = (
|
||||
prior
|
||||
? prior.then(
|
||||
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows),
|
||||
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
||||
)
|
||||
: runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
||||
).catch(() => false)
|
||||
hostViewportClaimTails.set(args.id, claim)
|
||||
void claim.then(() => {
|
||||
if (hostViewportClaimTails.get(args.id) === claim) {
|
||||
hostViewportClaimTails.delete(args.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
|
||||
@@ -4548,8 +4599,12 @@ export function registerPtyHandlers(
|
||||
// — isResizeSuppressed handles the safeFit cascade after take-back; this
|
||||
// driver check handles the ongoing locked state. See
|
||||
// docs/mobile-presence-lock.md.
|
||||
if (runtime?.isPtyResizeDrivenRemotely(args.id)) {
|
||||
runtime.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows)
|
||||
const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile'
|
||||
const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true
|
||||
if (mobileOwnsResize || remoteDesktopOwnsResize) {
|
||||
if (remoteDesktopOwnsResize) {
|
||||
runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
const provider = tryGetProviderForPty(args.id)
|
||||
|
||||
@@ -48,7 +48,12 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
|
||||
ipcMain.handle(
|
||||
'runtime:getTerminalFitOverrides',
|
||||
(): { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[] => {
|
||||
(): {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}[] => {
|
||||
const overrides = runtime.getAllTerminalFitOverrides()
|
||||
return Array.from(overrides.entries()).map(([ptyId, override]) => ({
|
||||
ptyId,
|
||||
|
||||
@@ -1378,7 +1378,7 @@ type RuntimeNotifier = {
|
||||
resumeSleepingAgents?(worktreeId: string): void
|
||||
terminalFitOverrideChanged(
|
||||
ptyId: string,
|
||||
mode: 'mobile-fit' | 'desktop-fit',
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit',
|
||||
cols: number,
|
||||
rows: number
|
||||
): void
|
||||
@@ -2062,7 +2062,7 @@ export type DriverState = RuntimeTerminalDriverState
|
||||
export type PtyLayoutTarget =
|
||||
| { kind: 'desktop'; cols: number; rows: number }
|
||||
| { kind: 'phone'; cols: number; rows: number; ownerClientId: string }
|
||||
| { kind: 'remote-desktop'; cols: number; rows: number }
|
||||
| { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string }
|
||||
|
||||
// Why: authoritative layout state with monotonic seq. Bumped on every
|
||||
// applyLayout success; emitted on mobile subscribe-stream events so clients
|
||||
@@ -2181,7 +2181,13 @@ export class OrcaRuntimeService {
|
||||
// invoked from resizeForClient and onClientDisconnected/onPtyExit.
|
||||
private fitOverrideListeners = new Map<
|
||||
string,
|
||||
Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void>
|
||||
Set<
|
||||
(event: {
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
>
|
||||
>()
|
||||
private driverListeners = new Map<string, Set<(driver: DriverState) => void>>()
|
||||
private subscriptionCleanups = new Map<string, () => void>()
|
||||
@@ -2335,9 +2341,14 @@ export class OrcaRuntimeService {
|
||||
// each stream must release only the width floor it registered.
|
||||
private remoteDesktopViewers = new Map<
|
||||
string,
|
||||
Map<string, { clientId: string; cols: number; rows: number }>
|
||||
Map<string, { clientId: string; cols: number; rows: number; activity: number }>
|
||||
>()
|
||||
private remoteDesktopOwners = new Map<string, string>()
|
||||
private remoteDesktopActivity = 0
|
||||
private remoteDesktopHostReclaimTargets = new Map<string, { cols: number; rows: number }>()
|
||||
// Why: a completed host reclaim must not consume the cache if a newer
|
||||
// viewer mutation landed while that serialized layout was in flight.
|
||||
private remoteDesktopViewerRevisions = new Map<string, number>()
|
||||
|
||||
// Why: resubscribe-grace window. When the last mobile subscriber for a
|
||||
// PTY unsubscribes, we hold the driver=mobile{clientId} state and the
|
||||
@@ -6488,7 +6499,11 @@ export class OrcaRuntimeService {
|
||||
|
||||
subscribeToFitOverrideChanges(
|
||||
ptyId: string,
|
||||
listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void
|
||||
listener: (event: {
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
): () => void {
|
||||
return addListenerToMap(this.fitOverrideListeners, ptyId, listener)
|
||||
}
|
||||
@@ -6499,7 +6514,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
private notifyFitOverrideListeners(
|
||||
ptyId: string,
|
||||
mode: 'mobile-fit' | 'desktop-fit',
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit',
|
||||
cols: number,
|
||||
rows: number
|
||||
): void {
|
||||
@@ -7776,11 +7791,26 @@ export class OrcaRuntimeService {
|
||||
return this.terminalFitOverrides.get(ptyId) ?? null
|
||||
}
|
||||
|
||||
getAllTerminalFitOverrides(): Map<string, { mode: 'mobile-fit'; cols: number; rows: number }> {
|
||||
const result = new Map<string, { mode: 'mobile-fit'; cols: number; rows: number }>()
|
||||
getAllTerminalFitOverrides(): Map<
|
||||
string,
|
||||
{ mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }
|
||||
> {
|
||||
const result = new Map<
|
||||
string,
|
||||
{ mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }
|
||||
>()
|
||||
for (const [ptyId, override] of this.terminalFitOverrides) {
|
||||
result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows })
|
||||
}
|
||||
for (const [ptyId] of this.remoteDesktopOwners) {
|
||||
if (result.has(ptyId)) {
|
||||
continue
|
||||
}
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
if (size) {
|
||||
result.set(ptyId, { mode: 'remote-desktop-fit', ...size })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -7866,6 +7896,11 @@ export class OrcaRuntimeService {
|
||||
void this.applyRemoteDesktopLayout(ptyId)
|
||||
continue
|
||||
} else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
|
||||
if (this.remoteDesktopHostReclaimTargets.has(ptyId)) {
|
||||
this.setDriver(ptyId, { kind: 'idle' })
|
||||
void this.applyRemoteDesktopLayout(ptyId)
|
||||
continue
|
||||
}
|
||||
// Use the soft-leaver's snapshot baseline as a hint, falling
|
||||
// through to resolveDesktopRestoreTarget for missing values.
|
||||
const fallback = this.resolveDesktopRestoreTarget(ptyId)
|
||||
@@ -7909,6 +7944,11 @@ export class OrcaRuntimeService {
|
||||
void this.applyRemoteDesktopLayout(ptyId)
|
||||
continue
|
||||
} else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
|
||||
if (this.remoteDesktopHostReclaimTargets.has(ptyId)) {
|
||||
this.setDriver(ptyId, { kind: 'idle' })
|
||||
void this.applyRemoteDesktopLayout(ptyId)
|
||||
continue
|
||||
}
|
||||
const fallback = this.resolveDesktopRestoreTarget(ptyId)
|
||||
const cols = baseline?.cols ?? fallback.cols
|
||||
const rows = baseline?.rows ?? fallback.rows
|
||||
@@ -8038,7 +8078,9 @@ export class OrcaRuntimeService {
|
||||
this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' })
|
||||
}
|
||||
this.remoteDesktopViewers.delete(ptyId)
|
||||
this.remoteDesktopOwners.delete(ptyId)
|
||||
this.remoteDesktopHostReclaimTargets.delete(ptyId)
|
||||
this.remoteDesktopViewerRevisions.delete(ptyId)
|
||||
this.disposeHeadlessTerminal(ptyId)
|
||||
this.agentDetector?.onExit(ptyId)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
@@ -8097,10 +8139,10 @@ export class OrcaRuntimeService {
|
||||
// Why: the host's own fit cascade (window resize, split drag, tab reveal,
|
||||
// "+"-new-tab re-render) must not resize a PTY whose width a remote client
|
||||
// owns — that is the remote "porridge" bug. True while a phone (mobile driver)
|
||||
// OR a remote desktop viewer holds the PTY. Input is deliberately NOT gated
|
||||
// OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated
|
||||
// here (see the `writePtyInput` mobile-only checks): shared-control desktop
|
||||
// viewers may still type alongside the host.
|
||||
// Note: this is intentionally NOT a driver kind. A remote desktop viewer needs
|
||||
// Note: this is intentionally NOT a driver kind. An active remote viewer needs
|
||||
// only resize suppression, not the mobile driver machinery (input lock,
|
||||
// phone-fit, driver-change banners), so it lives in its own registry and does
|
||||
// not perturb the presence-lock state machine. It also coexists with mobile:
|
||||
@@ -8110,7 +8152,28 @@ export class OrcaRuntimeService {
|
||||
if (this.getDriver(ptyId).kind === 'mobile') {
|
||||
return true
|
||||
}
|
||||
return this.hasRemoteDesktopViewers(ptyId)
|
||||
return this.isRemoteDesktopResizeDriven(ptyId)
|
||||
}
|
||||
|
||||
isRemoteDesktopResizeDriven(ptyId: string): boolean {
|
||||
return this.remoteDesktopOwners.has(ptyId)
|
||||
}
|
||||
|
||||
isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean {
|
||||
return this.remoteDesktopOwners.get(ptyId) === subscriptionKey
|
||||
}
|
||||
|
||||
getRemoteDesktopFitHold(
|
||||
ptyId: string,
|
||||
subscriptionKey: string
|
||||
): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } {
|
||||
const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 }
|
||||
return {
|
||||
mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey)
|
||||
? 'desktop-fit'
|
||||
: 'remote-desktop-fit',
|
||||
...size
|
||||
}
|
||||
}
|
||||
|
||||
private hasRemoteDesktopViewers(ptyId: string): boolean {
|
||||
@@ -8118,19 +8181,9 @@ export class OrcaRuntimeService {
|
||||
return viewers !== undefined && viewers.size > 0
|
||||
}
|
||||
|
||||
// Why: the smallest attached viewer wins (tmux model). A wider viewer showing
|
||||
// a few unused columns is fine; a narrower viewer receiving host-width frames
|
||||
// garbles, so the PTY must never exceed the narrowest grid.
|
||||
private minRemoteDesktopViewport(
|
||||
viewers: Map<string, { cols: number; rows: number }>
|
||||
): { cols: number; rows: number } | null {
|
||||
let cols = Infinity
|
||||
let rows = Infinity
|
||||
for (const viewport of viewers.values()) {
|
||||
cols = Math.min(cols, viewport.cols)
|
||||
rows = Math.min(rows, viewport.rows)
|
||||
}
|
||||
return Number.isFinite(cols) && Number.isFinite(rows) ? { cols, rows } : null
|
||||
private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null {
|
||||
const owner = this.remoteDesktopOwners.get(ptyId)
|
||||
return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null
|
||||
}
|
||||
|
||||
private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } {
|
||||
@@ -8138,15 +8191,10 @@ export class OrcaRuntimeService {
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
const renderer = this.lastRendererSizes.get(ptyId)
|
||||
if (renderer) {
|
||||
return { cols: renderer.cols, rows: renderer.rows }
|
||||
}
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
if (size) {
|
||||
return { cols: size.cols, rows: size.rows }
|
||||
}
|
||||
return { cols: 80, rows: 24 }
|
||||
// Why: a viewer can join while a phone owns the actual PTY size. The
|
||||
// mobile restore chain retains the pre-phone desktop geometry; current
|
||||
// PTY size alone would incorrectly capture the phone grid as host truth.
|
||||
return this.resolveDesktopRestoreTarget(ptyId)
|
||||
}
|
||||
|
||||
private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void {
|
||||
@@ -8159,21 +8207,38 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
// Why: phone presence also suppresses host resize, but must not seed the
|
||||
// separate remote-viewer cache when no desktop stream owns a width floor.
|
||||
if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) {
|
||||
return
|
||||
}
|
||||
this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows })
|
||||
}
|
||||
|
||||
private hasRemoteDesktopLayoutState(ptyId: string): boolean {
|
||||
return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId)
|
||||
}
|
||||
|
||||
private bumpRemoteDesktopViewerRevision(ptyId: string): number {
|
||||
const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1
|
||||
this.remoteDesktopViewerRevisions.set(ptyId, revision)
|
||||
return revision
|
||||
}
|
||||
|
||||
async applyRemoteDesktopLayout(ptyId: string): Promise<boolean> {
|
||||
if (this.getDriver(ptyId).kind === 'mobile') {
|
||||
return true
|
||||
}
|
||||
const viewers = this.remoteDesktopViewers.get(ptyId)
|
||||
const target = viewers ? this.minRemoteDesktopViewport(viewers) : null
|
||||
const target = this.activeRemoteDesktopViewport(ptyId)
|
||||
const reclaimingHost = !target
|
||||
const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0
|
||||
const layoutTarget: PtyLayoutTarget = target
|
||||
? { kind: 'remote-desktop', cols: target.cols, rows: target.rows }
|
||||
? {
|
||||
kind: 'remote-desktop',
|
||||
cols: target.cols,
|
||||
rows: target.rows,
|
||||
ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)!
|
||||
}
|
||||
: { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) }
|
||||
this.freshSubscribeGuard.add(ptyId)
|
||||
try {
|
||||
@@ -8182,7 +8247,12 @@ export class OrcaRuntimeService {
|
||||
// landed. If it failed, the PTY is still at the remote-viewer width, so
|
||||
// keep the target for the next reclaim (otherwise it resolves via the
|
||||
// stale remote width and never restores true host geometry).
|
||||
if (reclaimingHost && result.ok) {
|
||||
if (
|
||||
reclaimingHost &&
|
||||
result.ok &&
|
||||
!this.remoteDesktopOwners.has(ptyId) &&
|
||||
this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision
|
||||
) {
|
||||
this.remoteDesktopHostReclaimTargets.delete(ptyId)
|
||||
}
|
||||
return result.ok
|
||||
@@ -8191,38 +8261,125 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a remote (relay/shared-control) desktop viewer reporting a viewport
|
||||
// takes/refreshes the width floor so the host's fit cascade stops fighting it.
|
||||
// The source PTY is sized to the SMALLEST attached viewer so no viewer ever
|
||||
// receives frames wider than its grid.
|
||||
// Why: attachment only records geometry. Passive hydration/reconnect must not
|
||||
// steal the shared PTY from the desktop where the user is actively working.
|
||||
async updateRemoteDesktopViewer(
|
||||
ptyId: string,
|
||||
subscriptionKey: string,
|
||||
clientId: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
rows: number,
|
||||
claim = true
|
||||
): Promise<boolean> {
|
||||
const viewport = clampTerminalViewport(cols, rows)
|
||||
this.ensureRemoteDesktopHostReclaimTarget(ptyId)
|
||||
if (claim) {
|
||||
this.ensureRemoteDesktopHostReclaimTarget(ptyId)
|
||||
}
|
||||
let viewers = this.remoteDesktopViewers.get(ptyId)
|
||||
if (!viewers) {
|
||||
viewers = new Map<string, { clientId: string; cols: number; rows: number }>()
|
||||
viewers = new Map<
|
||||
string,
|
||||
{ clientId: string; cols: number; rows: number; activity: number }
|
||||
>()
|
||||
this.remoteDesktopViewers.set(ptyId, viewers)
|
||||
}
|
||||
viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows })
|
||||
const prior = viewers.get(subscriptionKey)
|
||||
if (
|
||||
prior &&
|
||||
prior.cols === viewport.cols &&
|
||||
prior.rows === viewport.rows &&
|
||||
(!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey)
|
||||
) {
|
||||
if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) {
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
if (size?.cols !== viewport.cols || size.rows !== viewport.rows) {
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0)
|
||||
viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity })
|
||||
this.bumpRemoteDesktopViewerRevision(ptyId)
|
||||
if (claim) {
|
||||
this.remoteDesktopOwners.set(ptyId, subscriptionKey)
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
|
||||
const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey)
|
||||
if (!viewer) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) {
|
||||
const size = this.getTerminalSize(ptyId)
|
||||
return size?.cols === viewer.cols && size.rows === viewer.rows
|
||||
? Promise.resolve(true)
|
||||
: this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
this.ensureRemoteDesktopHostReclaimTarget(ptyId)
|
||||
viewer.activity = ++this.remoteDesktopActivity
|
||||
this.remoteDesktopOwners.set(ptyId, subscriptionKey)
|
||||
this.bumpRemoteDesktopViewerRevision(ptyId)
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
|
||||
claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise<boolean> {
|
||||
if (!this.remoteDesktopOwners.has(ptyId)) {
|
||||
// Why: disconnect can remove the owner before its queued host resize
|
||||
// lands. A host input in that window must join the reclaim, not pass it.
|
||||
return this.remoteDesktopHostReclaimTargets.has(ptyId)
|
||||
? this.applyRemoteDesktopLayout(ptyId)
|
||||
: Promise.resolve(true)
|
||||
}
|
||||
const viewport = clampTerminalViewport(cols, rows)
|
||||
this.remoteDesktopHostReclaimTargets.set(ptyId, viewport)
|
||||
this.remoteDesktopOwners.delete(ptyId)
|
||||
this.bumpRemoteDesktopViewerRevision(ptyId)
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
|
||||
unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
|
||||
return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey])
|
||||
}
|
||||
|
||||
unregisterRemoteDesktopViewers(
|
||||
ptyId: string,
|
||||
subscriptionKeys: Iterable<string>
|
||||
): Promise<boolean> {
|
||||
const viewers = this.remoteDesktopViewers.get(ptyId)
|
||||
if (!viewers) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
viewers.delete(subscriptionKey)
|
||||
let changed = false
|
||||
let removedOwner = false
|
||||
for (const subscriptionKey of subscriptionKeys) {
|
||||
removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner
|
||||
changed = viewers.delete(subscriptionKey) || changed
|
||||
}
|
||||
if (!changed) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (viewers.size === 0) {
|
||||
this.remoteDesktopViewers.delete(ptyId)
|
||||
}
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
if (removedOwner) {
|
||||
let fallback: { key: string; activity: number } | null = null
|
||||
for (const [key, viewer] of viewers) {
|
||||
if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) {
|
||||
fallback = { key, activity: viewer.activity }
|
||||
}
|
||||
}
|
||||
if (fallback) {
|
||||
this.remoteDesktopOwners.set(ptyId, fallback.key)
|
||||
} else {
|
||||
this.remoteDesktopOwners.delete(ptyId)
|
||||
}
|
||||
}
|
||||
this.bumpRemoteDesktopViewerRevision(ptyId)
|
||||
return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true)
|
||||
}
|
||||
|
||||
// Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so
|
||||
@@ -8237,24 +8394,42 @@ export class OrcaRuntimeService {
|
||||
ptyId: string,
|
||||
clientId: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
rows: number,
|
||||
claim = false
|
||||
): Promise<boolean> {
|
||||
const viewers = this.remoteDesktopViewers.get(ptyId)
|
||||
if (!viewers) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
const viewport = clampTerminalViewport(cols, rows)
|
||||
if (claim) {
|
||||
// Why: terminal.send may be the first activity while the stream is only
|
||||
// passively registered. Snapshot host truth before this refresh owns it.
|
||||
this.ensureRemoteDesktopHostReclaimTarget(ptyId)
|
||||
}
|
||||
let changed = false
|
||||
for (const [subscriptionKey, viewer] of viewers) {
|
||||
if (viewer.clientId === clientId) {
|
||||
viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows })
|
||||
const activity = claim ? ++this.remoteDesktopActivity : viewer.activity
|
||||
viewers.set(subscriptionKey, {
|
||||
...viewer,
|
||||
cols: viewport.cols,
|
||||
rows: viewport.rows,
|
||||
activity
|
||||
})
|
||||
if (claim) {
|
||||
this.remoteDesktopOwners.set(ptyId, subscriptionKey)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
this.bumpRemoteDesktopViewerRevision(ptyId)
|
||||
return this.remoteDesktopOwners.has(ptyId)
|
||||
? this.applyRemoteDesktopLayout(ptyId)
|
||||
: Promise.resolve(true)
|
||||
}
|
||||
|
||||
async updateDesktopViewport(
|
||||
@@ -8412,9 +8587,34 @@ export class OrcaRuntimeService {
|
||||
// the terminal tab on the phone) must default to phone-fit again, not stay
|
||||
// in passive desktop-watch mode.
|
||||
this.setMobileDisplayMode(ptyId, 'auto')
|
||||
if (this.hasRemoteDesktopLayoutState(ptyId)) {
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const heldOverride = this.terminalFitOverrides.get(ptyId)
|
||||
if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) {
|
||||
const pending = this.pendingRestoreTimers.get(ptyId)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer)
|
||||
this.pendingRestoreTimers.delete(ptyId)
|
||||
}
|
||||
const softLeaver = this.pendingSoftLeavers.get(ptyId)
|
||||
if (softLeaver) {
|
||||
clearTimeout(softLeaver.timer)
|
||||
this.pendingSoftLeavers.delete(ptyId)
|
||||
}
|
||||
const priorDriver = this.getDriver(ptyId)
|
||||
this.setDriver(ptyId, { kind: 'idle' })
|
||||
const converged = await this.applyRemoteDesktopLayout(ptyId)
|
||||
if (!converged) {
|
||||
this.setDriver(ptyId, priorDriver)
|
||||
return false
|
||||
}
|
||||
this.setDriver(ptyId, { kind: 'desktop' })
|
||||
this.setMobileDisplayMode(ptyId, 'auto')
|
||||
return true
|
||||
}
|
||||
if (heldOverride) {
|
||||
const pending = this.pendingRestoreTimers.get(ptyId)
|
||||
if (pending) {
|
||||
@@ -8631,6 +8831,11 @@ export class OrcaRuntimeService {
|
||||
if (prev.kind === 'phone' && next.kind === 'phone') {
|
||||
return prev.ownerClientId === next.ownerClientId
|
||||
}
|
||||
if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') {
|
||||
// Why: each owner's claim promise gates its following input. Sharing a
|
||||
// waiter across owners could release A's input only after B's grid lands.
|
||||
return prev.ownerSubscriptionKey === next.ownerSubscriptionKey
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8768,9 +8973,9 @@ export class OrcaRuntimeService {
|
||||
this.resizeHeadlessTerminal(ptyId, target.cols, target.rows)
|
||||
}
|
||||
|
||||
// Why: emit fit-override-changed when the *mode* flips. Layouts can
|
||||
// change dims without flipping mode (keyboard show/hide while phone),
|
||||
// and waking the renderer on every viewport tick is wasteful churn.
|
||||
// Why: remote desktop ownership is a fit hold for the host and passive
|
||||
// peer viewers. Emit every remote layout so owner changes at equal geometry
|
||||
// still park/release the correct clients without relying on resize deltas.
|
||||
// Defense-in-depth (#7588): also emit when the override's presence
|
||||
// changed even without a kind flip. applyLayout is the sole writer and
|
||||
// keeps override presence in lockstep with layout kind, so overrideChanged
|
||||
@@ -8778,7 +8983,7 @@ export class OrcaRuntimeService {
|
||||
// only if that invariant is ever violated, repairing the renderer instead
|
||||
// of stranding the held modal.
|
||||
const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone')
|
||||
if (target.kind !== 'remote-desktop' && (modeChanged || overrideChanged)) {
|
||||
if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) {
|
||||
// Why: phone→desktop arms the renderer-cascade suppress window
|
||||
// before the collateral safeFit IPCs arrive. See "Renderer cascade
|
||||
// suppression".
|
||||
@@ -8788,13 +8993,21 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
this.notifier?.terminalFitOverrideChanged(
|
||||
ptyId,
|
||||
target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit',
|
||||
target.kind === 'phone'
|
||||
? 'mobile-fit'
|
||||
: target.kind === 'remote-desktop'
|
||||
? 'remote-desktop-fit'
|
||||
: 'desktop-fit',
|
||||
target.cols,
|
||||
target.rows
|
||||
)
|
||||
this.notifyFitOverrideListeners(
|
||||
ptyId,
|
||||
target.kind === 'phone' ? 'mobile-fit' : 'desktop-fit',
|
||||
target.kind === 'phone'
|
||||
? 'mobile-fit'
|
||||
: target.kind === 'remote-desktop'
|
||||
? 'remote-desktop-fit'
|
||||
: 'desktop-fit',
|
||||
target.cols,
|
||||
target.rows
|
||||
)
|
||||
@@ -9169,7 +9382,7 @@ export class OrcaRuntimeService {
|
||||
if (this.isMobileSubscriberActive(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (this.hasRemoteDesktopViewers(ptyId)) {
|
||||
if (this.hasRemoteDesktopLayoutState(ptyId)) {
|
||||
void this.applyRemoteDesktopLayout(ptyId)
|
||||
return
|
||||
}
|
||||
@@ -9308,6 +9521,11 @@ export class OrcaRuntimeService {
|
||||
if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) {
|
||||
return
|
||||
}
|
||||
// Why: a successful host resize supersedes any target retained after a
|
||||
// failed viewer reclaim; a later viewer cycle must capture this new truth.
|
||||
if (!this.hasRemoteDesktopViewers(ptyId)) {
|
||||
this.remoteDesktopHostReclaimTargets.delete(ptyId)
|
||||
}
|
||||
this.resizeHeadlessTerminal(ptyId, cols, rows)
|
||||
this.refreshRendererGeometry(ptyId, cols, rows)
|
||||
}
|
||||
@@ -9328,6 +9546,11 @@ export class OrcaRuntimeService {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
return
|
||||
}
|
||||
// Why: a viewer may leave while phone-fit still owns the PTY. Keep its
|
||||
// deferred host reclaim cache aligned with later trusted pane measurements.
|
||||
if (this.remoteDesktopHostReclaimTargets.has(ptyId)) {
|
||||
this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows })
|
||||
}
|
||||
this.refreshRendererGeometry(ptyId, cols, rows)
|
||||
}
|
||||
|
||||
|
||||
@@ -68,17 +68,23 @@ const store = {
|
||||
})
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs })
|
||||
})
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>([
|
||||
['pty-1', { cols: 150, rows: 40 }]
|
||||
])
|
||||
const resizeCalls: { ptyId: string; cols: number; rows: number }[] = []
|
||||
const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = []
|
||||
const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = []
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
resizeCalls.push({ ptyId, cols, rows })
|
||||
ptySizes.set(ptyId, { cols, rows })
|
||||
return true
|
||||
},
|
||||
@@ -94,12 +100,19 @@ function createRuntime() {
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||||
fitOverrideEvents.push({ ptyId, mode, cols, rows })
|
||||
},
|
||||
terminalDriverChanged: (ptyId, driver) => {
|
||||
driverEvents.push({ ptyId, driver: { ...driver } })
|
||||
}
|
||||
})
|
||||
return { runtime, driverEvents }
|
||||
return {
|
||||
runtime,
|
||||
driverEvents,
|
||||
fitOverrideEvents,
|
||||
resizeCalls
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote desktop viewer width driver', () => {
|
||||
@@ -120,21 +133,20 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(driverEvents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sizes the PTY to the SMALLEST attached viewer (smallest client wins)', async () => {
|
||||
it('sizes the PTY to the latest active desktop viewer', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
|
||||
|
||||
// A narrower viewer joins — the PTY shrinks so its wider frames never
|
||||
// overflow the narrow grid.
|
||||
// Activity on the narrower viewer transfers ownership to it.
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 })
|
||||
|
||||
// The wide viewer growing does not widen the PTY past the narrow viewer.
|
||||
// Later activity on the wide viewer transfers ownership back.
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 140, 50)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 30 })
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 })
|
||||
|
||||
// The narrow viewer leaves — the PTY re-fits to the survivor's width.
|
||||
// A passive peer leaving cannot disturb the active owner.
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 50 })
|
||||
})
|
||||
@@ -155,7 +167,7 @@ describe('remote desktop viewer width driver', () => {
|
||||
})
|
||||
|
||||
it('coexists with a mobile driver and outlives it (host stays suppressed)', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
const { runtime, fitOverrideEvents } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
|
||||
|
||||
@@ -170,6 +182,16 @@ describe('remote desktop viewer width driver', () => {
|
||||
vi.advanceTimersByTime(10_000)
|
||||
expect(runtime.getDriver('pty-1').kind).not.toBe('mobile')
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 40 })
|
||||
expect(fitOverrideEvents.at(-1)).toMatchObject({
|
||||
ptyId: 'pty-1',
|
||||
mode: 'remote-desktop-fit',
|
||||
cols: 100,
|
||||
rows: 40
|
||||
})
|
||||
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('isPtyResizeDrivenRemotely is false for idle and desktop drivers', async () => {
|
||||
@@ -180,13 +202,52 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let passive attachment or hydration steal host ownership', async () => {
|
||||
const { runtime, resizeCalls } = createRuntime()
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24, false)
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 30, false)
|
||||
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(resizeCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lets host activity automatically reclaim from a remote owner', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
|
||||
await runtime.claimRemoteDesktopHost('pty-1', 132, 42)
|
||||
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 132, rows: 42 })
|
||||
expect(runtime.getRemoteDesktopFitHold('pty-1', 'sub-A')).toMatchObject({
|
||||
mode: 'remote-desktop-fit',
|
||||
cols: 132,
|
||||
rows: 42
|
||||
})
|
||||
})
|
||||
|
||||
it('does not emit resize churn for repeated activity from the current owner', async () => {
|
||||
const { runtime, resizeCalls, fitOverrideEvents } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
resizeCalls.splice(0)
|
||||
fitOverrideEvents.splice(0)
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
|
||||
expect(resizeCalls).toHaveLength(0)
|
||||
expect(fitOverrideEvents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reclaims the host width when the last viewer detaches', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
// The host renderer reports its own 120-wide geometry (pty:reportGeometry).
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
|
||||
// The viewer drives the source PTY to its own 80-wide viewport.
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 })
|
||||
// While the viewer owns width, the blocked host fit records the host's
|
||||
// latest reclaim geometry.
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
|
||||
|
||||
// Detaching the last viewer must actively resize the PTY back to the host's
|
||||
// OWN width (120), not the departed viewer's polluted 80.
|
||||
@@ -194,6 +255,137 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not retain a remote reclaim target when only a phone suppresses host resize', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
|
||||
// pty:resize is suppressed for mobile too, but this measurement must not
|
||||
// seed the separate remote-viewer cache when no desktop viewer exists.
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 35)
|
||||
runtime.onClientDisconnected('phone-A')
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('restores and consumes the host target when the last viewer leaves during phone-fit', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
// The host pane can change after remote ownership ends while phone-fit
|
||||
// remains active; its trusted measurement becomes the deferred target.
|
||||
runtime.recordRendererGeometry('pty-1', 140, 38)
|
||||
|
||||
runtime.onClientDisconnected('phone-A')
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 38 })
|
||||
|
||||
// A later viewer must capture the new host geometry, not reuse the prior
|
||||
// session's already-consumed 140-column reclaim target.
|
||||
runtime.onExternalPtyResize('pty-1', 130, 36)
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24)
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 130, rows: 36 })
|
||||
})
|
||||
|
||||
it('preserves indefinite phone-fit after the last desktop viewer leaves', async () => {
|
||||
const { runtime } = createRuntime(null)
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
|
||||
runtime.onClientDisconnected('phone-A')
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toMatchObject({ mode: 'mobile-fit' })
|
||||
|
||||
await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not let an older host reclaim consume a newer viewer cycle target', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
|
||||
// Do not await the reclaim before the next viewer joins. The serialized
|
||||
// host resize can finish after sub-B has established a newer viewer cycle.
|
||||
const firstReclaim = runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
const secondAttach = runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 90, 30)
|
||||
await Promise.all([firstReclaim, secondAttach])
|
||||
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not coalesce queued claims from different desktop owners', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24, false)
|
||||
const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
|
||||
string,
|
||||
{ running: Promise<unknown>; pending: { target: { ownerSubscriptionKey?: string } }[] }
|
||||
>
|
||||
layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
|
||||
|
||||
void runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 28)
|
||||
void runtime.claimRemoteDesktopViewer('pty-1', 'sub-B')
|
||||
|
||||
expect(
|
||||
layoutQueues.get('pty-1')?.pending.map(({ target }) => target.ownerSubscriptionKey)
|
||||
).toEqual(['sub-A', 'sub-B'])
|
||||
layoutQueues.delete('pty-1')
|
||||
})
|
||||
|
||||
it('makes a host claim join a pending disconnect reclaim', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
|
||||
string,
|
||||
{ running: Promise<unknown>; pending: { waiters: unknown[] }[] }
|
||||
>
|
||||
layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
|
||||
|
||||
void runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
void runtime.claimRemoteDesktopHost('pty-1', 150, 40)
|
||||
|
||||
expect(layoutQueues.get('pty-1')?.pending).toHaveLength(1)
|
||||
expect(layoutQueues.get('pty-1')?.pending[0]?.waiters).toHaveLength(2)
|
||||
layoutQueues.delete('pty-1')
|
||||
})
|
||||
|
||||
it('removes same-PTY viewer floors with one bounded reclaim', async () => {
|
||||
const { runtime, resizeCalls } = createRuntime()
|
||||
const subscriptionKeys: string[] = []
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const key = `sub-${index}`
|
||||
subscriptionKeys.push(key)
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', key, `viewer-${index}`, 100, 30)
|
||||
}
|
||||
resizeCalls.splice(0)
|
||||
|
||||
await runtime.unregisterRemoteDesktopViewers('pty-1', subscriptionKeys)
|
||||
|
||||
expect(resizeCalls).toEqual([{ ptyId: 'pty-1', cols: 150, rows: 40 }])
|
||||
})
|
||||
|
||||
it('applies the latest viewer width when the host takes back from a phone', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
|
||||
await expect(runtime.reclaimTerminalForDesktop('pty-1')).resolves.toBe(true)
|
||||
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('refresh never creates a floor (one-shot viewport cannot leak host suppression)', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
// A one-shot terminal.updateViewport (refresh) from a viewer with no stream
|
||||
@@ -223,6 +415,17 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('captures host geometry when a passive stream is first claimed by terminal.send', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'stream:1', 'viewer-A', 100, 30, false)
|
||||
|
||||
await runtime.refreshRemoteDesktopViewer('pty-1', 'viewer-A', 80, 24, true)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'stream:1')
|
||||
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('keeps the host reclaim target when the reclaim resize fails', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>([
|
||||
@@ -243,9 +446,10 @@ describe('remote desktop viewer width driver', () => {
|
||||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
|
||||
})
|
||||
|
||||
// Host reports its own 120-wide geometry; a viewer drives the PTY to 80.
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
|
||||
// A viewer drives the PTY to 80; while it owns width, the host reports its
|
||||
// own 120-wide reclaim geometry.
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 40 })
|
||||
|
||||
// The last viewer leaves but the reclaim resize fails: the PTY is stuck at
|
||||
@@ -262,6 +466,42 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 120, rows: 40 })
|
||||
})
|
||||
|
||||
it('supersedes a failed reclaim target when the host later resizes successfully', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
const ptySizes = new Map<string, { cols: number; rows: number }>([
|
||||
['pty-1', { cols: 150, rows: 40 }]
|
||||
])
|
||||
let resizeSucceeds = true
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
resize: (ptyId, cols, rows) => {
|
||||
if (!resizeSucceeds) {
|
||||
return false
|
||||
}
|
||||
ptySizes.set(ptyId, { cols, rows })
|
||||
return true
|
||||
},
|
||||
getSize: (ptyId) => ptySizes.get(ptyId) ?? null
|
||||
})
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 40)
|
||||
runtime.recordRemoteDesktopHostReclaimTarget('pty-1', 120, 40)
|
||||
resizeSucceeds = false
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
|
||||
|
||||
// pty:resize has already resized the provider before this runtime mirror
|
||||
// hook runs; that successful host geometry supersedes the retained 120.
|
||||
resizeSucceeds = true
|
||||
ptySizes.set('pty-1', { cols: 140, rows: 42 })
|
||||
runtime.onExternalPtyResize('pty-1', 140, 42)
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 30)
|
||||
await runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-B')
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 140, rows: 42 })
|
||||
})
|
||||
|
||||
it('PTY exit clears the remote-desktop registry', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
|
||||
|
||||
@@ -88,6 +88,8 @@ type TerminalMultiplexStream = {
|
||||
isMobile: boolean
|
||||
ackOutput: boolean
|
||||
ackInFlightBytes: number
|
||||
supportsDesktopViewportClaims: boolean
|
||||
desktopClaimTail: Promise<boolean>
|
||||
// Why: whether THIS stream registered a remote-desktop width driver, so
|
||||
// detach only unregisters what it registered — a passive (viewport-less)
|
||||
// stream sharing a client id must not release another stream's width floor.
|
||||
@@ -644,24 +646,31 @@ async function updateViewportForClient(
|
||||
// Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so
|
||||
// it must only refresh a floor the client already owns via its stream (never
|
||||
// create a leak-prone standalone one). Stream paths that own cleanup register.
|
||||
registration: 'register' | 'refresh' = 'register'
|
||||
registration: 'register' | 'refresh' = 'register',
|
||||
claim = false
|
||||
): Promise<{ updated: boolean; applied: boolean }> {
|
||||
const type = client.type ?? defaultType
|
||||
if (type === 'mobile') {
|
||||
return runtime.updateMobileViewport(ptyId, client.id, viewport)
|
||||
}
|
||||
// Why: a remote desktop viewer's viewport drives the source PTY to the
|
||||
// smallest attached viewer (tmux "smallest client wins") and registers it as
|
||||
// a width owner so the host's fit cascade stops fighting it.
|
||||
// Why: stream attachment observes geometry without taking control. Only a
|
||||
// later activity/claim frame may make this desktop authoritative.
|
||||
const updated =
|
||||
registration === 'refresh'
|
||||
? await runtime.refreshRemoteDesktopViewer(ptyId, client.id, viewport.cols, viewport.rows)
|
||||
? await runtime.refreshRemoteDesktopViewer(
|
||||
ptyId,
|
||||
client.id,
|
||||
viewport.cols,
|
||||
viewport.rows,
|
||||
claim
|
||||
)
|
||||
: await runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
subscriptionKey,
|
||||
client.id,
|
||||
viewport.cols,
|
||||
viewport.rows
|
||||
viewport.rows,
|
||||
claim
|
||||
)
|
||||
return { updated, applied: updated }
|
||||
}
|
||||
@@ -732,7 +741,14 @@ const TerminalSend = TerminalHandle.extend({
|
||||
id: requiredString('Missing client ID'),
|
||||
type: z.enum(['mobile', 'desktop']).default('desktop').optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
viewport: z
|
||||
.object({
|
||||
cols: z.number().int().min(1).max(1000),
|
||||
rows: z.number().int().min(1).max(500)
|
||||
})
|
||||
.optional(),
|
||||
claimViewport: z.literal(true).optional()
|
||||
})
|
||||
|
||||
const TerminalViewport = z.object({
|
||||
@@ -829,7 +845,8 @@ const TerminalSubscribe = TerminalHandle.extend({
|
||||
viewport: TerminalViewport.optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
terminalBinaryStream: z.literal(1).optional()
|
||||
terminalBinaryStream: z.literal(1).optional(),
|
||||
desktopViewportClaims: z.literal(1).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -847,7 +864,8 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({
|
||||
viewport: TerminalViewport.optional(),
|
||||
capabilities: z
|
||||
.object({
|
||||
ackOutput: z.literal(1).optional()
|
||||
ackOutput: z.literal(1).optional(),
|
||||
desktopViewportClaims: z.literal(1).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -918,7 +936,8 @@ const TerminalUpdateViewport = TerminalHandle.extend({
|
||||
viewport: z.object({
|
||||
cols: z.number().int().min(20).max(240),
|
||||
rows: z.number().int().min(8).max(120)
|
||||
})
|
||||
}),
|
||||
claim: z.boolean().optional()
|
||||
})
|
||||
|
||||
// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md). `null`
|
||||
@@ -1022,6 +1041,34 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
leaf?.ptyId &&
|
||||
params.client?.type === 'desktop' &&
|
||||
params.claimViewport === true &&
|
||||
params.viewport
|
||||
) {
|
||||
const claim = await updateViewportForClient(
|
||||
runtime,
|
||||
leaf.ptyId,
|
||||
`send:${params.client.id}`,
|
||||
params.client,
|
||||
params.viewport,
|
||||
'desktop',
|
||||
'refresh',
|
||||
true
|
||||
)
|
||||
// Why: a stream-less request has no lifecycle cleanup and cannot safely
|
||||
// create ownership. Never write at stale geometry if no stream exists.
|
||||
if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
|
||||
return {
|
||||
send: {
|
||||
handle: params.terminal,
|
||||
accepted: false,
|
||||
bytesWritten: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasText = typeof params.text === 'string' && params.text.length > 0
|
||||
const hasSuffix = params.enter === true || params.interrupt === true
|
||||
if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) {
|
||||
@@ -1309,7 +1356,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
'mobile',
|
||||
// Why: one-shot RPC with no disconnect hook — refresh the client's
|
||||
// existing stream-owned floor only, never create a leak-prone one.
|
||||
'refresh'
|
||||
'refresh',
|
||||
params.claim === true
|
||||
)
|
||||
return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq }
|
||||
}
|
||||
@@ -1508,7 +1556,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged)
|
||||
flushAllAckPendingOutput()
|
||||
}
|
||||
const detachStream = (streamId: number, emitEnd: boolean): void => {
|
||||
const detachStream = (
|
||||
streamId: number,
|
||||
emitEnd: boolean,
|
||||
releaseRemoteDesktopDriver = true
|
||||
): void => {
|
||||
const stream = streams.get(streamId)
|
||||
if (!stream) {
|
||||
return
|
||||
@@ -1533,7 +1585,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
stream.exitWaiterAbort.abort()
|
||||
if (stream.isMobile && stream.client?.id) {
|
||||
runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id)
|
||||
} else if (stream.registeredRemoteDesktopDriver && stream.client?.id) {
|
||||
} else if (
|
||||
releaseRemoteDesktopDriver &&
|
||||
stream.registeredRemoteDesktopDriver &&
|
||||
stream.client?.id
|
||||
) {
|
||||
// Why: release the remote-desktop width floor so the host can reclaim
|
||||
// its own width once the last remote viewer leaves — but only if THIS
|
||||
// stream took it (a passive stream must not release a peer's floor).
|
||||
@@ -1548,8 +1604,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
const remoteDesktopKeysByPty = new Map<string, string[]>()
|
||||
for (const streamId of Array.from(streams.keys())) {
|
||||
detachStream(streamId, false)
|
||||
const stream = streams.get(streamId)
|
||||
if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) {
|
||||
const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? []
|
||||
keys.push(stream.remoteDesktopSubscriptionKey)
|
||||
remoteDesktopKeysByPty.set(stream.ptyId, keys)
|
||||
}
|
||||
detachStream(streamId, false, false)
|
||||
}
|
||||
// Why: one connection can own many panes backed by the same PTY.
|
||||
// Remove those floors together so close scans each PTY registry once.
|
||||
for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) {
|
||||
void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys)
|
||||
}
|
||||
unregisterControlHandler()
|
||||
resolveMultiplex()
|
||||
@@ -1582,8 +1650,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
|
||||
return
|
||||
}
|
||||
void runtime
|
||||
.sendTerminal(stream.terminal, { text, enter: false, interrupt: false })
|
||||
void stream.desktopClaimTail
|
||||
.then((claimed) =>
|
||||
!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)
|
||||
? null
|
||||
: runtime.sendTerminal(stream.terminal, {
|
||||
text,
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
.then(async () => {
|
||||
if (stream.isMobile && stream.client?.id) {
|
||||
await runtime.mobileTookFloor(stream.ptyId, stream.client.id)
|
||||
@@ -1599,9 +1675,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
|
||||
return
|
||||
}
|
||||
// Why: a desktop viewport update registers this stream as a width
|
||||
// owner (even if it had no initial viewport at subscribe), so detach
|
||||
// must later release it.
|
||||
const cols = viewport.cols
|
||||
const rows = viewport.rows
|
||||
// Why: resize registers stream-scoped geometry so detach can release
|
||||
// it. Older clients lack explicit claims, so Resize remains control.
|
||||
if (!stream.isMobile && stream.client?.id) {
|
||||
stream.registeredRemoteDesktopDriver = true
|
||||
if (stream.buffering) {
|
||||
@@ -1609,14 +1686,61 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
}
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client,
|
||||
{ cols: viewport.cols, rows: viewport.rows },
|
||||
stream.isMobile ? 'mobile' : 'desktop'
|
||||
).catch(() => {})
|
||||
stream.desktopClaimTail = stream.desktopClaimTail
|
||||
.then(async (priorClaimed) => {
|
||||
const result = await updateViewportForClient(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!,
|
||||
{ cols, rows },
|
||||
stream.isMobile ? 'mobile' : 'desktop',
|
||||
'register',
|
||||
!stream.supportsDesktopViewportClaims
|
||||
)
|
||||
return stream.supportsDesktopViewportClaims
|
||||
? priorClaimed && result.applied
|
||||
: result.applied
|
||||
})
|
||||
.catch(() => false)
|
||||
return
|
||||
}
|
||||
if (
|
||||
frame.opcode === TerminalStreamOpcode.ClaimViewport &&
|
||||
stream.client &&
|
||||
!stream.isMobile
|
||||
) {
|
||||
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(
|
||||
frame.payload
|
||||
)
|
||||
if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') {
|
||||
return
|
||||
}
|
||||
const cols = viewport.cols
|
||||
const rows = viewport.rows
|
||||
stream.registeredRemoteDesktopDriver = true
|
||||
stream.desktopClaimTail = stream.desktopClaimTail
|
||||
.then(
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!.id,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
),
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!.id,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
)
|
||||
)
|
||||
.catch(() => false)
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) {
|
||||
@@ -1729,7 +1853,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client,
|
||||
viewport,
|
||||
'desktop'
|
||||
'desktop',
|
||||
'register',
|
||||
!stream.supportsDesktopViewportClaims
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -1793,6 +1919,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
isMobile,
|
||||
ackOutput: request.capabilities?.ackOutput === 1,
|
||||
ackInFlightBytes: 0,
|
||||
supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1,
|
||||
desktopClaimTail: Promise.resolve(true),
|
||||
registeredRemoteDesktopDriver: false,
|
||||
// Why: streamId is client-local, so two remote connections can both
|
||||
// use stream 1 for the same PTY. Scope the width-floor key by
|
||||
@@ -1859,11 +1987,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isMobile && request.client?.id) {
|
||||
await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport)
|
||||
} else if (request.client?.id && request.viewport) {
|
||||
// Why: a remote desktop viewer that reports a viewport is driving
|
||||
// the PTY width, so the runtime registers it as a
|
||||
// width owner (host fit cascade suppressed) and sizes the PTY to the
|
||||
// smallest attached viewer. A viewport-less subscriber is a passive
|
||||
// watcher and must NOT lock host resize.
|
||||
// Why: subscribe records this stream's geometry and cleanup key,
|
||||
// but does not claim ownership. Activity frames claim later.
|
||||
stream.registeredRemoteDesktopDriver = true
|
||||
stream.pendingRemoteDesktopViewport = request.viewport
|
||||
}
|
||||
@@ -1881,7 +2006,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
request.client,
|
||||
viewport,
|
||||
'desktop'
|
||||
'desktop',
|
||||
'register',
|
||||
!stream.supportsDesktopViewportClaims
|
||||
)
|
||||
}
|
||||
if (closed || streams.get(request.streamId) !== stream) {
|
||||
@@ -1890,10 +2017,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
|
||||
if (!isMobile) {
|
||||
stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
const mode =
|
||||
event.mode === 'mobile-fit'
|
||||
? event.mode
|
||||
: (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey)
|
||||
.mode ?? 'desktop-fit')
|
||||
emit({
|
||||
type: 'fit-override-changed',
|
||||
streamId: request.streamId,
|
||||
mode: event.mode,
|
||||
mode,
|
||||
cols: event.cols,
|
||||
rows: event.rows
|
||||
})
|
||||
@@ -1936,12 +2068,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
const snapshotOutputSeq = serialized?.seq
|
||||
if (!isMobile) {
|
||||
const fitOverride = runtime.getTerminalFitOverride(ptyId)
|
||||
const desktopHold = runtime.getRemoteDesktopFitHold?.(
|
||||
ptyId,
|
||||
stream.remoteDesktopSubscriptionKey
|
||||
) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 }
|
||||
emit({
|
||||
type: 'fit-override-changed',
|
||||
streamId: request.streamId,
|
||||
mode: fitOverride?.mode ?? 'desktop-fit',
|
||||
cols: fitOverride?.cols ?? size?.cols ?? 0,
|
||||
rows: fitOverride?.rows ?? size?.rows ?? 0
|
||||
mode: fitOverride?.mode ?? desktopHold.mode,
|
||||
cols: fitOverride?.cols ?? desktopHold.cols,
|
||||
rows: fitOverride?.rows ?? desktopHold.rows
|
||||
})
|
||||
emit({
|
||||
type: 'driver-changed',
|
||||
@@ -1993,24 +2129,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
stream.pendingOutputBytes = 0
|
||||
stream.pendingOutputOverflowed = false
|
||||
stream.outputBatcher.flush()
|
||||
if (
|
||||
!stream.isMobile &&
|
||||
stream.client?.id &&
|
||||
stream.registeredRemoteDesktopDriver &&
|
||||
stream.pendingRemoteDesktopViewport
|
||||
) {
|
||||
const viewport = stream.pendingRemoteDesktopViewport
|
||||
stream.pendingRemoteDesktopViewport = null
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client,
|
||||
viewport,
|
||||
'desktop'
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
|
||||
stream.outputBatcher.flush()
|
||||
const resizeGeneration = stream.resizeGeneration + 1
|
||||
@@ -2059,6 +2177,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
sendResizedFrame(stream, event)
|
||||
})
|
||||
// Install the resize listener before draining the parked viewport;
|
||||
// applyLayout emits synchronously and the stream must observe it.
|
||||
if (
|
||||
!stream.isMobile &&
|
||||
stream.client?.id &&
|
||||
stream.registeredRemoteDesktopDriver &&
|
||||
stream.pendingRemoteDesktopViewport
|
||||
) {
|
||||
const viewport = stream.pendingRemoteDesktopViewport
|
||||
stream.pendingRemoteDesktopViewport = null
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client,
|
||||
viewport,
|
||||
'desktop',
|
||||
'register',
|
||||
!stream.supportsDesktopViewportClaims
|
||||
).catch(() => {})
|
||||
}
|
||||
void runtime
|
||||
.waitForTerminal(request.terminal, {
|
||||
condition: 'exit',
|
||||
@@ -2151,43 +2290,86 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
|
||||
const ptyId = leaf.ptyId
|
||||
const clientId = params.client?.id
|
||||
const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1
|
||||
// Why: only unregister the width floor this subscription took (see the
|
||||
// multiplex stream's registeredRemoteDesktopDriver note).
|
||||
let registeredRemoteDesktopDriver = false
|
||||
if (!useBinaryStream) {
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false)
|
||||
// Why: legacy JSON streams register cleanup after snapshot awaits; if
|
||||
// the socket closed meanwhile, registering now would orphan listeners.
|
||||
if (signal?.aborted) {
|
||||
return
|
||||
}
|
||||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
const seq = runtime.getLayout(ptyId)?.seq
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
truncated: isTerminalReadPayloadIncomplete(read),
|
||||
serialized: serialized?.data,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
cwd: serialized?.cwd,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode,
|
||||
seq
|
||||
})
|
||||
|
||||
// Why: desktop can have both a hidden automation watcher and a visible
|
||||
// pane subscribed to the same terminal. Key by client when provided so
|
||||
// one stream cannot evict the other.
|
||||
const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal
|
||||
await new Promise<void>((resolve) => {
|
||||
const outputBatcher = createTerminalOutputBatcher((chunk) => {
|
||||
const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}`
|
||||
let closed = false
|
||||
let outputBatcher: ReturnType<typeof createTerminalOutputBatcher> | null = null
|
||||
let unsubscribeData = (): void => {}
|
||||
let unsubscribeFit = (): void => {}
|
||||
let resolveStream = (): void => {}
|
||||
const streamClosed = new Promise<void>((resolve) => {
|
||||
resolveStream = resolve
|
||||
})
|
||||
// Why: register before viewport/snapshot awaits so a socket close cannot
|
||||
// orphan either the stream listeners or its remote-desktop width floor.
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
closed = true
|
||||
outputBatcher?.flush()
|
||||
outputBatcher?.dispose()
|
||||
unsubscribeData()
|
||||
unsubscribeFit()
|
||||
if (registeredRemoteDesktopDriver && clientId) {
|
||||
runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey)
|
||||
}
|
||||
emit({ type: 'end' })
|
||||
resolveStream()
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
try {
|
||||
if (clientId && params.client && params.viewport) {
|
||||
registeredRemoteDesktopDriver = true
|
||||
await updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
params.viewport,
|
||||
'desktop',
|
||||
'register',
|
||||
!supportsDesktopViewportClaims
|
||||
)
|
||||
}
|
||||
if (closed || signal?.aborted) {
|
||||
runtime.cleanupSubscription(subscriptionId)
|
||||
return
|
||||
}
|
||||
const read = await runtime.readTerminal(params.terminal)
|
||||
const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false)
|
||||
if (closed || signal?.aborted) {
|
||||
runtime.cleanupSubscription(subscriptionId)
|
||||
return
|
||||
}
|
||||
const size = runtime.getTerminalSize(ptyId)
|
||||
const displayMode = runtime.getMobileDisplayMode(ptyId)
|
||||
const seq = runtime.getLayout(ptyId)?.seq
|
||||
emit({
|
||||
type: 'scrollback',
|
||||
lines: read.tail,
|
||||
truncated: isTerminalReadPayloadIncomplete(read),
|
||||
serialized: serialized?.data,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
cwd: serialized?.cwd,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
displayMode,
|
||||
seq
|
||||
})
|
||||
outputBatcher = createTerminalOutputBatcher((chunk) => {
|
||||
emit({ type: 'data', chunk })
|
||||
})
|
||||
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => {
|
||||
outputBatcher.push(data)
|
||||
outputBatcher?.push(data)
|
||||
})
|
||||
// Why: this legacy JSON stream can feed a live xterm view too
|
||||
// (older web/desktop subscribers), so it conservatively registers
|
||||
@@ -2195,38 +2377,35 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
// a withheld model reply — the pre-Phase-5 status quo — which is
|
||||
// strictly safer than a double reply under a view consumer.
|
||||
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
|
||||
const unsubscribeData = (): void => {
|
||||
unsubscribeData = () => {
|
||||
releaseViewSubscriber()
|
||||
unsubscribeStreamData()
|
||||
}
|
||||
const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
outputBatcher.flush()
|
||||
unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
outputBatcher?.flush()
|
||||
const mode =
|
||||
event.mode === 'mobile-fit'
|
||||
? event.mode
|
||||
: (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ??
|
||||
'desktop-fit')
|
||||
emit({
|
||||
type: 'fit-override-changed',
|
||||
mode: event.mode,
|
||||
mode,
|
||||
cols: event.cols,
|
||||
rows: event.rows
|
||||
})
|
||||
})
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
outputBatcher.flush()
|
||||
outputBatcher.dispose()
|
||||
unsubscribeData()
|
||||
unsubscribeFit()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
// Why: bind the exit-waiter to the connection dispatch signal so it is
|
||||
// removed on socket close/error instead of leaking until real exit.
|
||||
void runtime
|
||||
.waitForTerminal(params.terminal, { condition: 'exit', signal })
|
||||
.then(() => runtime.cleanupSubscription(subscriptionId))
|
||||
.catch(() => runtime.cleanupSubscription(subscriptionId))
|
||||
})
|
||||
await streamClosed
|
||||
} catch (error) {
|
||||
runtime.cleanupSubscription(subscriptionId)
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2241,6 +2420,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
let lastResizeCols: number | undefined
|
||||
let resizeGeneration = 0
|
||||
let pendingOutput: TerminalOutputChunk[] = []
|
||||
let desktopClaimTail: Promise<boolean> = Promise.resolve(true)
|
||||
let pendingOutputBytes = 0
|
||||
let pendingOutputOverflowed = false
|
||||
let unsubscribeData = (): void => {}
|
||||
@@ -2317,8 +2497,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
|
||||
return
|
||||
}
|
||||
void runtime
|
||||
.sendTerminal(params.terminal, { text, enter: false, interrupt: false })
|
||||
void desktopClaimTail
|
||||
.then((claimed) =>
|
||||
!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)
|
||||
? null
|
||||
: runtime.sendTerminal(params.terminal, {
|
||||
text,
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
.then(async () => {
|
||||
if (isMobile && clientId) {
|
||||
await runtime.mobileTookFloor(ptyId, clientId)
|
||||
@@ -2338,6 +2526,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
) {
|
||||
return
|
||||
}
|
||||
const cols = viewport.cols
|
||||
const rows = viewport.rows
|
||||
if (clientId) {
|
||||
registeredRemoteDesktopDriver = true
|
||||
if (buffering) {
|
||||
@@ -2345,14 +2535,66 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
return
|
||||
}
|
||||
}
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
{ cols: viewport.cols, rows: viewport.rows },
|
||||
'desktop'
|
||||
).catch(() => {})
|
||||
desktopClaimTail = desktopClaimTail
|
||||
.then(async (priorClaimed) => {
|
||||
const result = await updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client!,
|
||||
{ cols, rows },
|
||||
'desktop',
|
||||
'register',
|
||||
!supportsDesktopViewportClaims
|
||||
)
|
||||
return supportsDesktopViewportClaims
|
||||
? priorClaimed && result.applied
|
||||
: result.applied
|
||||
})
|
||||
.catch(() => false)
|
||||
return
|
||||
}
|
||||
if (
|
||||
frame.opcode === TerminalStreamOpcode.ClaimViewport &&
|
||||
params.client &&
|
||||
clientId &&
|
||||
!isMobile
|
||||
) {
|
||||
const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(
|
||||
frame.payload
|
||||
)
|
||||
if (
|
||||
!viewport ||
|
||||
typeof viewport.cols !== 'number' ||
|
||||
typeof viewport.rows !== 'number'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const cols = viewport.cols
|
||||
const rows = viewport.rows
|
||||
registeredRemoteDesktopDriver = true
|
||||
desktopClaimTail = desktopClaimTail
|
||||
.then(
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
clientId,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
),
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
clientId,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
)
|
||||
)
|
||||
.catch(() => false)
|
||||
}
|
||||
}) ?? (() => {})
|
||||
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
|
||||
@@ -2360,9 +2602,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isMobile && clientId) {
|
||||
await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport)
|
||||
} else if (clientId && params.viewport) {
|
||||
// Why: legacy remote desktop viewers that report a viewport also take
|
||||
// the width floor (host fit cascade suppressed) and size the PTY to
|
||||
// the smallest attached viewer. Viewport-less watchers do not lock.
|
||||
// Why: legacy subscribe records geometry without taking ownership;
|
||||
// only an explicit activity/claim frame may suppress the host.
|
||||
registeredRemoteDesktopDriver = true
|
||||
pendingRemoteDesktopViewport = params.viewport
|
||||
}
|
||||
@@ -2414,7 +2655,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
viewport,
|
||||
'desktop'
|
||||
'desktop',
|
||||
'register',
|
||||
!supportsDesktopViewportClaims
|
||||
)
|
||||
}
|
||||
if (closed) {
|
||||
@@ -2551,24 +2794,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
pendingOutputBytes = 0
|
||||
outputBatcher.flush()
|
||||
if (
|
||||
clientId &&
|
||||
params.client &&
|
||||
registeredRemoteDesktopDriver &&
|
||||
pendingRemoteDesktopViewport
|
||||
) {
|
||||
const viewport = pendingRemoteDesktopViewport
|
||||
pendingRemoteDesktopViewport = null
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
viewport,
|
||||
'desktop'
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
const sendResizedFrame = (event: {
|
||||
cols: number
|
||||
rows: number
|
||||
@@ -2628,12 +2853,39 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
sendResizedFrame(event)
|
||||
})
|
||||
|
||||
// Install the resize listener before draining the parked viewport;
|
||||
// applyLayout emits synchronously and the stream must observe it.
|
||||
if (
|
||||
clientId &&
|
||||
params.client &&
|
||||
registeredRemoteDesktopDriver &&
|
||||
pendingRemoteDesktopViewport
|
||||
) {
|
||||
const viewport = pendingRemoteDesktopViewport
|
||||
pendingRemoteDesktopViewport = null
|
||||
void updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
params.client,
|
||||
viewport,
|
||||
'desktop',
|
||||
'register',
|
||||
!supportsDesktopViewportClaims
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
// Legacy fit-override-changed for non-mobile (desktop) subscribers
|
||||
unsubscribeFit = !isMobile
|
||||
? runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
|
||||
const mode =
|
||||
event.mode === 'mobile-fit'
|
||||
? event.mode
|
||||
: (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ??
|
||||
'desktop-fit')
|
||||
emit({
|
||||
type: 'fit-override-changed',
|
||||
mode: event.mode,
|
||||
mode,
|
||||
cols: event.cols,
|
||||
rows: event.rows
|
||||
})
|
||||
|
||||
@@ -315,6 +315,9 @@ describe('RpcDispatcher streaming', () => {
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(cleanups.has('terminal-1:desktop-1')).toBe(true))
|
||||
// Cleanup now registers before snapshot work so a disconnect cannot orphan
|
||||
// a desktop width floor; wait for the actual exit waiter before resolving it.
|
||||
await vi.waitFor(() => expect(runtime.waitForTerminal).toHaveBeenCalled())
|
||||
resolveExit()
|
||||
await dispatchPromise
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true),
|
||||
isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false),
|
||||
getRemoteDesktopFitHold: vi.fn().mockReturnValue({ mode: 'desktop-fit', cols: 120, rows: 40 }),
|
||||
isRemoteDesktopViewerOwner: vi.fn().mockReturnValue(false),
|
||||
...overrides
|
||||
} as OrcaRuntimeService
|
||||
}
|
||||
@@ -122,7 +125,8 @@ describe('terminal multiplex RPC', () => {
|
||||
streamId: 5,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 300, rows: 150 }
|
||||
viewport: { cols: 300, rows: 150 },
|
||||
capabilities: { desktopViewportClaims: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
@@ -150,10 +154,129 @@ describe('terminal multiplex RPC', () => {
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
300,
|
||||
150
|
||||
150,
|
||||
false
|
||||
)
|
||||
expect(handlers.has(5)).toBe(true)
|
||||
|
||||
let releaseClaim = (): void => {}
|
||||
vi.mocked(runtime.updateRemoteDesktopViewer).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => resolve(true)
|
||||
})
|
||||
)
|
||||
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.ClaimViewport,
|
||||
streamId: 5,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamJson({ cols: 96, rows: 32 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith(
|
||||
'pty-1',
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
96,
|
||||
32,
|
||||
true
|
||||
)
|
||||
)
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Resize,
|
||||
streamId: 5,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({ cols: 96, rows: 32 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 5,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('ls\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
expect(runtime.updateRemoteDesktopViewer).not.toHaveBeenLastCalledWith(
|
||||
'pty-1',
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
96,
|
||||
32,
|
||||
false
|
||||
)
|
||||
releaseClaim()
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith(
|
||||
'pty-1',
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
96,
|
||||
32,
|
||||
false
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'ls\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length
|
||||
vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false)
|
||||
for (const [opcode, seq, payload] of [
|
||||
[TerminalStreamOpcode.ClaimViewport, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
[TerminalStreamOpcode.Resize, 4, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
[TerminalStreamOpcode.Input, 5, encodeTerminalStreamText('blocked')]
|
||||
] as const) {
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload })
|
||||
)!
|
||||
)
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.updateRemoteDesktopViewer).toHaveBeenLastCalledWith(
|
||||
'pty-1',
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
88,
|
||||
28,
|
||||
false
|
||||
)
|
||||
)
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledTimes(sentAfterSuccessfulClaim)
|
||||
for (const [opcode, seq, payload] of [
|
||||
[TerminalStreamOpcode.ClaimViewport, 6, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
[TerminalStreamOpcode.Resize, 7, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
[TerminalStreamOpcode.Input, 8, encodeTerminalStreamText('retry')]
|
||||
] as const) {
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({ opcode, streamId: 5, seq, payload })
|
||||
)!
|
||||
)
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenLastCalledWith('terminal-1', {
|
||||
text: 'retry',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
dataListenerRef.current?.('a')
|
||||
dataListenerRef.current?.('b')
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
@@ -165,24 +288,6 @@ describe('terminal multiplex RPC', () => {
|
||||
expect(outputFrames[0]?.streamId).toBe(5)
|
||||
expect(outputFrames[0] ? decodeTerminalStreamText(outputFrames[0].payload) : '').toBe('ab')
|
||||
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId: 5,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamText('ls\r')
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', {
|
||||
text: 'ls\r',
|
||||
enter: false,
|
||||
interrupt: false
|
||||
})
|
||||
)
|
||||
|
||||
handlers.get(5)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
@@ -199,7 +304,8 @@ describe('terminal multiplex RPC', () => {
|
||||
'multiplex:conn-1:5',
|
||||
'desktop-1',
|
||||
100,
|
||||
30
|
||||
30,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
@@ -256,8 +362,67 @@ describe('terminal multiplex RPC', () => {
|
||||
.join('')
|
||||
).toBe('snapshot')
|
||||
|
||||
// A viewport-less stream is passive: it must neither register nor later
|
||||
// release the active stream's width floor when the connection closes.
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 5,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 6,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages.some(
|
||||
(msg) =>
|
||||
JSON.parse(msg).result?.type === 'subscribed' &&
|
||||
JSON.parse(msg).result?.streamId === 6
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
// A second active floor on the same PTY is released in the same batch,
|
||||
// keeping connection teardown to one registry recomputation per PTY.
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 6,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 7,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-2', type: 'desktop' },
|
||||
viewport: { cols: 90, rows: 30 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
messages.some(
|
||||
(msg) =>
|
||||
JSON.parse(msg).result?.type === 'subscribed' &&
|
||||
JSON.parse(msg).result?.streamId === 7
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-1')
|
||||
await dispatchPromise
|
||||
expect(runtime.unregisterRemoteDesktopViewer).not.toHaveBeenCalled()
|
||||
expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.unregisterRemoteDesktopViewers).toHaveBeenCalledWith('pty-1', [
|
||||
'multiplex:conn-1:5',
|
||||
'multiplex:conn-1:7'
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -274,7 +439,7 @@ describe('terminal multiplex RPC', () => {
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
@@ -306,7 +471,9 @@ describe('terminal multiplex RPC', () => {
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-snap',
|
||||
sendBinary: (bytes) => binaryFrames.push(bytes),
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
@@ -327,7 +494,8 @@ describe('terminal multiplex RPC', () => {
|
||||
streamId: 9,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 300, rows: 150 }
|
||||
viewport: { cols: 300, rows: 150 },
|
||||
capabilities: { desktopViewportClaims: 1 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
@@ -371,7 +539,8 @@ describe('terminal multiplex RPC', () => {
|
||||
'multiplex:conn-snap:9',
|
||||
'desktop-1',
|
||||
88,
|
||||
33
|
||||
33,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
@@ -382,6 +551,129 @@ describe('terminal multiplex RPC', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('emits a resize drained after the initial snapshot', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const cleanups = new Map<string, () => void>()
|
||||
let resolveSnapshot = (_value: { data: string; cols: number; rows: number }): void => {}
|
||||
let resizeListener:
|
||||
| ((event: {
|
||||
cols: number
|
||||
rows: number
|
||||
displayMode: string
|
||||
reason: string
|
||||
seq?: number
|
||||
}) => void)
|
||||
| undefined
|
||||
const updateRemoteDesktopViewer = vi.fn(
|
||||
async (_ptyId: string, _key: string, _clientId: string, cols: number, rows: number) => {
|
||||
if (updateRemoteDesktopViewer.mock.calls.length > 1) {
|
||||
resizeListener?.({ cols, rows, displayMode: 'desktop', reason: 'apply-layout', seq: 2 })
|
||||
}
|
||||
return true
|
||||
}
|
||||
)
|
||||
const runtime = stubRuntime({
|
||||
updateRemoteDesktopViewer,
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn(
|
||||
() =>
|
||||
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn((_ptyId, listener) => {
|
||||
resizeListener = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true })
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.multiplex', {}),
|
||||
(msg) => messages.push(msg),
|
||||
{
|
||||
connectionId: 'conn-initial-resize',
|
||||
sendBinary: (bytes) => {
|
||||
binaryFrames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
|
||||
)
|
||||
handlers.get(0)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Subscribe,
|
||||
streamId: 0,
|
||||
seq: 1,
|
||||
payload: encodeTerminalStreamJson({
|
||||
streamId: 9,
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 80, rows: 24 }
|
||||
})
|
||||
})
|
||||
)!
|
||||
)
|
||||
await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled())
|
||||
handlers.get(9)?.(
|
||||
decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Resize,
|
||||
streamId: 9,
|
||||
seq: 2,
|
||||
payload: encodeTerminalStreamJson({ cols: 132, rows: 43 })
|
||||
})
|
||||
)!
|
||||
)
|
||||
expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSnapshot({ data: 'snapshot', cols: 80, rows: 24 })
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
binaryFrames.some(
|
||||
(bytes) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Resized
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
const opcodes = binaryFrames.map((bytes) => decodeTerminalStreamFrame(bytes)?.opcode)
|
||||
expect(opcodes.indexOf(TerminalStreamOpcode.Resized)).toBeGreaterThan(
|
||||
opcodes.indexOf(TerminalStreamOpcode.SnapshotEnd)
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-multiplex:conn-initial-resize')
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('drops stale mobile resize re-stream completions for multiplex streams', async () => {
|
||||
const messages: string[] = []
|
||||
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
|
||||
@@ -1839,6 +2131,58 @@ describe('terminal multiplex RPC', () => {
|
||||
await dispatchPromise
|
||||
})
|
||||
|
||||
it('owns and releases a viewport floor for legacy JSON desktop streams', async () => {
|
||||
const messages: string[] = []
|
||||
const cleanups = new Map<string, () => void>()
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue(null),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
|
||||
cleanups.set(id, cleanup)
|
||||
}),
|
||||
cleanupSubscription: vi.fn((id: string) => {
|
||||
const cleanup = cleanups.get(id)
|
||||
cleanups.delete(id)
|
||||
cleanup?.()
|
||||
}),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-json-1', type: 'desktop' },
|
||||
viewport: { cols: 88, rows: 30 }
|
||||
}),
|
||||
(msg) => messages.push(msg),
|
||||
{ connectionId: 'conn-json-1' }
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'scrollback')).toBe(true)
|
||||
)
|
||||
const subscriptionKey = vi.mocked(runtime.updateRemoteDesktopViewer).mock.calls[0]?.[1]
|
||||
expect(runtime.updateRemoteDesktopViewer).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
expect.stringMatching(/^json:/),
|
||||
'desktop-json-1',
|
||||
88,
|
||||
30,
|
||||
true
|
||||
)
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-json-1')
|
||||
await dispatchPromise
|
||||
expect(runtime.unregisterRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', subscriptionKey)
|
||||
})
|
||||
|
||||
it('bounds oversized live output frames for subscribed binary streams', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -132,6 +132,46 @@ describe('terminal send RPC', () => {
|
||||
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits a desktop viewport claim before acknowledged input', async () => {
|
||||
let releaseClaim = (): void => {}
|
||||
const refreshRemoteDesktopViewer = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => resolve(true)
|
||||
})
|
||||
)
|
||||
const sendTerminal = vi.fn().mockResolvedValue({
|
||||
handle: 'terminal-1',
|
||||
accepted: true,
|
||||
bytesWritten: 1
|
||||
})
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
refreshRemoteDesktopViewer,
|
||||
sendTerminal
|
||||
})
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
|
||||
const response = dispatcher.dispatch(
|
||||
makeRequest('terminal.send', {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x03',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 96, rows: 32 },
|
||||
claimViewport: true
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(refreshRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', 'desktop-1', 96, 32, true)
|
||||
)
|
||||
expect(sendTerminal).not.toHaveBeenCalled()
|
||||
releaseClaim()
|
||||
await expect(response).resolves.toMatchObject({ ok: true })
|
||||
expect(sendTerminal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts legacy clientless mobile input when the current driver is mobile', async () => {
|
||||
const runtime = stubRuntime({
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
|
||||
@@ -214,7 +214,14 @@ describe('terminal subscribe buffering', () => {
|
||||
expect(await outcomePromise).toBe('settled')
|
||||
expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled()
|
||||
expect(runtime.subscribeToFitOverrideChanges).not.toHaveBeenCalled()
|
||||
expect(runtime.registerSubscriptionCleanup).not.toHaveBeenCalled()
|
||||
// Cleanup must exist before snapshot work so an abort cannot orphan a
|
||||
// desktop width floor, but the abort must consume it before listeners start.
|
||||
expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith(
|
||||
'terminal-1:desktop-1',
|
||||
expect.any(Function),
|
||||
'conn-legacy-json'
|
||||
)
|
||||
expect(runtime.cleanupSubscription).toHaveBeenCalledWith('terminal-1:desktop-1')
|
||||
expect(runtime.waitForTerminal).not.toHaveBeenCalled()
|
||||
expect(messages).toEqual([])
|
||||
} finally {
|
||||
|
||||
@@ -1253,6 +1253,7 @@ export type PreloadApi = {
|
||||
write: (id: string, data: string) => void
|
||||
writeAccepted: (id: string, data: string) => Promise<boolean>
|
||||
resize: (id: string, cols: number, rows: number) => void
|
||||
claimViewport: (id: string, cols: number, rows: number) => void
|
||||
reportGeometry: (id: string, cols: number, rows: number) => void
|
||||
signal: (id: string, signal: string) => void
|
||||
clearBuffer: (id: string) => void
|
||||
@@ -2907,7 +2908,7 @@ export type PreloadApi = {
|
||||
getStatus: () => Promise<RuntimeStatus>
|
||||
call: (args: { method: string; params?: unknown }) => Promise<RuntimeRpcResponse<unknown>>
|
||||
getTerminalFitOverrides: () => Promise<
|
||||
{ ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[]
|
||||
{ ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[]
|
||||
>
|
||||
getTerminalDrivers: () => Promise<
|
||||
{
|
||||
@@ -2926,7 +2927,7 @@ export type PreloadApi = {
|
||||
onTerminalFitOverrideChanged: (
|
||||
callback: (event: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
|
||||
+11
-3
@@ -846,6 +846,9 @@ const api = {
|
||||
resize: (id: string, cols: number, rows: number): void => {
|
||||
ipcRenderer.send('pty:resize', { id, cols, rows })
|
||||
},
|
||||
claimViewport: (id: string, cols: number, rows: number): void => {
|
||||
ipcRenderer.send('pty:claimViewport', { id, cols, rows })
|
||||
},
|
||||
|
||||
/** Why: measurement-only sibling of resize. Fires when a desktop pane
|
||||
* container measures real geometry (e.g. previously hidden tab becomes
|
||||
@@ -3878,7 +3881,7 @@ const api = {
|
||||
call: (args: { method: string; params?: unknown }): Promise<RuntimeRpcResponse<unknown>> =>
|
||||
ipcRenderer.invoke('runtime:call', args),
|
||||
getTerminalFitOverrides: (): Promise<
|
||||
{ ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[]
|
||||
{ ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[]
|
||||
> => ipcRenderer.invoke('runtime:getTerminalFitOverrides'),
|
||||
getTerminalDrivers: (): Promise<
|
||||
{
|
||||
@@ -3899,14 +3902,19 @@ const api = {
|
||||
onTerminalFitOverrideChanged: (
|
||||
callback: (event: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { ptyId: string; mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }
|
||||
data: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('runtime:terminalFitOverrideChanged', listener)
|
||||
return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener)
|
||||
|
||||
@@ -88,10 +88,11 @@ import { connectPanePty } from './pty-connection'
|
||||
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
|
||||
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
|
||||
import {
|
||||
getAllOverrides,
|
||||
getMobileFitOverridePtyIds,
|
||||
getFitOverrideForPty,
|
||||
onOverrideChange
|
||||
} from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { shouldShowMobileDriverOverlay } from './mobile-driver-overlay-visibility'
|
||||
import {
|
||||
getAllDrivers,
|
||||
getDriverForPty,
|
||||
@@ -441,7 +442,7 @@ export default function TerminalPane({
|
||||
(paneId) => paneTransportsRef.current.get(paneId)?.getPtyId(),
|
||||
event.ptyId
|
||||
)
|
||||
if (event.mode === 'mobile-fit') {
|
||||
if (event.mode === 'mobile-fit' || event.mode === 'remote-desktop-fit') {
|
||||
// Why: when mobile starts driving, the agent re-renders its output at
|
||||
// phone width and that phone-wrapped byte stream flows live into this
|
||||
// passive watcher's xterm. xterm must shrink to the phone dims now or
|
||||
@@ -2659,7 +2660,7 @@ export default function TerminalPane({
|
||||
}, [getContextMenuLeafId, toggleNativeChatForLeaf])
|
||||
|
||||
const getMobileOwnedTerminalPtyIds = useCallback((): string[] => {
|
||||
const ptyIds = new Set(getAllOverrides().keys())
|
||||
const ptyIds = new Set(getMobileFitOverridePtyIds())
|
||||
for (const [ptyId, driver] of getAllDrivers()) {
|
||||
if (driver.kind === 'mobile') {
|
||||
ptyIds.add(ptyId)
|
||||
@@ -3183,9 +3184,9 @@ export default function TerminalPane({
|
||||
// treatment and collapse-to-chip state; both branches share the
|
||||
// same local/remote desktop-restore route.
|
||||
const driver = getDriverForPty(ptyId)
|
||||
const isMobileDriving = driver.kind === 'mobile'
|
||||
const hasFitOverride = getFitOverrideForPty(ptyId) !== null
|
||||
if (!isMobileDriving && !hasFitOverride) {
|
||||
const fitMode = getFitOverrideForPty(ptyId)?.mode ?? null
|
||||
const hasFitOverride = fitMode === 'mobile-fit'
|
||||
if (!shouldShowMobileDriverOverlay(driver.kind, fitMode)) {
|
||||
return null
|
||||
}
|
||||
// Why: only the pane replaced by native chat should hide terminal-owned
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowMobileDriverOverlay } from './mobile-driver-overlay-visibility'
|
||||
|
||||
describe('shouldShowMobileDriverOverlay', () => {
|
||||
it('shows only mobile ownership and phone-fit holds', () => {
|
||||
expect(shouldShowMobileDriverOverlay('mobile', null)).toBe(true)
|
||||
expect(shouldShowMobileDriverOverlay('idle', 'mobile-fit')).toBe(true)
|
||||
expect(shouldShowMobileDriverOverlay('idle', 'remote-desktop-fit')).toBe(false)
|
||||
expect(shouldShowMobileDriverOverlay('desktop', 'desktop-fit')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { FitHoldMode } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
|
||||
export function shouldShowMobileDriverOverlay(
|
||||
driverKind: 'idle' | 'desktop' | 'mobile',
|
||||
fitMode: FitHoldMode | null
|
||||
): boolean {
|
||||
return driverKind === 'mobile' || fitMode === 'mobile-fit'
|
||||
}
|
||||
@@ -242,6 +242,7 @@ type MockTransport = {
|
||||
sendInput: ReturnType<typeof vi.fn>
|
||||
sendInputImmediate: ReturnType<typeof vi.fn>
|
||||
sendInputAccepted?: ReturnType<typeof vi.fn>
|
||||
claimViewport: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
getPtyId: ReturnType<typeof vi.fn>
|
||||
getConnectionId: ReturnType<typeof vi.fn>
|
||||
@@ -383,6 +384,7 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
|
||||
return ptyId
|
||||
}),
|
||||
sendInput: vi.fn(() => true),
|
||||
claimViewport: vi.fn(() => true),
|
||||
resize: vi.fn(() => true),
|
||||
getPtyId: vi.fn(() => ptyId),
|
||||
getConnectionId: vi.fn(() => null),
|
||||
@@ -4000,6 +4002,7 @@ describe('connectPanePty', () => {
|
||||
const manager = createManager(1)
|
||||
const replayingPanesRef = { current: new Map<number, number>([[1, 1]]) }
|
||||
const deps = createDeps({ replayingPanesRef })
|
||||
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
|
||||
@@ -4010,11 +4013,19 @@ describe('connectPanePty', () => {
|
||||
// Simulate xterm emitting a DA1 auto-reply during replay parse.
|
||||
;(onDataHandler as (data: string) => void)('\x1b[?1;2c')
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.claimViewport).not.toHaveBeenCalled()
|
||||
|
||||
// Once replay completes (guard cleared), real keystrokes flow through.
|
||||
replayingPanesRef.current.delete(1)
|
||||
setFitOverride('pty-live', 'remote-desktop-fit', 80, 24)
|
||||
;(onDataHandler as (data: string) => void)('a')
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('a')
|
||||
expect(transport.claimViewport).toHaveBeenCalledTimes(1)
|
||||
;(onDataHandler as (data: string) => void)('b')
|
||||
// The renderer stays parked until runtime convergence is acknowledged, so
|
||||
// a second keystroke can retry a transient failed resize.
|
||||
expect(transport.claimViewport).toHaveBeenCalledTimes(2)
|
||||
setFitOverride('pty-live', 'desktop-fit', 0, 0)
|
||||
})
|
||||
|
||||
it('does not enumerate every worktree tab for ordinary input without Codex restart notices', async () => {
|
||||
@@ -11683,7 +11694,7 @@ describe('connectPanePty', () => {
|
||||
isVisibleRef.current = true
|
||||
resizeHandler({ cols: 122, rows: 42 })
|
||||
|
||||
expect(transport.resize).toHaveBeenCalledWith(122, 42)
|
||||
expect(transport.resize).toHaveBeenCalledWith(122, 42, { claim: true })
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
@@ -18228,7 +18239,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2')
|
||||
expect(transport.resize).toHaveBeenCalledWith(82, 40)
|
||||
expect(transport.resize).toHaveBeenCalledWith(82, 40, { claim: true })
|
||||
} finally {
|
||||
observer.restore()
|
||||
}
|
||||
@@ -18277,7 +18288,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalled()
|
||||
expect(window.api.pty.getSize).toHaveBeenCalledWith('pty-pane-2')
|
||||
expect(transport.resize).toHaveBeenCalledWith(65, 63)
|
||||
expect(transport.resize).toHaveBeenCalledWith(65, 63, { claim: true })
|
||||
})
|
||||
|
||||
it('skips foreground grid drift repair while mobile owns the PTY without a fit override', async () => {
|
||||
@@ -18368,6 +18379,56 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('updates the claiming desktop xterm before forwarding an observed viewport claim', async () => {
|
||||
const originalDocument = globalThis.document
|
||||
;(globalThis as { document?: Document }).document = {
|
||||
visibilityState: 'visible',
|
||||
hasFocus: vi.fn(() => true)
|
||||
} as unknown as Document
|
||||
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
queueMicrotask(() => callback(0))
|
||||
return 1
|
||||
})
|
||||
const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides')
|
||||
const pane = createPane(2)
|
||||
const observer = installObservedPane(pane)
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-pane-2')
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = createManager(2)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_2,
|
||||
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
|
||||
})
|
||||
let proposedGrid = { cols: 120, rows: 40 }
|
||||
pane.fitAddon = {
|
||||
...pane.fitAddon,
|
||||
proposeDimensions: vi.fn(() => proposedGrid)
|
||||
} as never
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks()
|
||||
observer.trigger()
|
||||
await flushAsyncTicks()
|
||||
setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24)
|
||||
proposedGrid = { cols: 70, rows: 30 }
|
||||
vi.mocked(pane.terminal.resize).mockClear()
|
||||
transport.resize.mockClear()
|
||||
|
||||
observer.trigger()
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30)
|
||||
expect(transport.resize).toHaveBeenCalledTimes(1)
|
||||
expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true })
|
||||
} finally {
|
||||
setFitOverride('pty-pane-2', 'desktop-fit', 0, 0)
|
||||
observer.restore()
|
||||
globalThis.document = originalDocument
|
||||
}
|
||||
})
|
||||
|
||||
it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => {
|
||||
const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state')
|
||||
const pane = createPane(2)
|
||||
@@ -18428,7 +18489,7 @@ describe('connectPanePty', () => {
|
||||
binding.noteVisibilityResume()
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true })
|
||||
})
|
||||
|
||||
it('queues re-asserted resizes while pane resize holds are active', async () => {
|
||||
@@ -18470,7 +18531,7 @@ describe('connectPanePty', () => {
|
||||
release.flush()
|
||||
|
||||
expect(transport.resize).toHaveBeenCalledTimes(1)
|
||||
expect(transport.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true })
|
||||
} finally {
|
||||
globalThis.CustomEvent = originalCustomEvent
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observe
|
||||
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile'
|
||||
import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim'
|
||||
import { createPtySizeReassertion } from './pty-size-reassertion'
|
||||
import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard'
|
||||
import {
|
||||
@@ -981,6 +982,7 @@ export function connectPanePty(
|
||||
// flips, exit, dispose) run before/after it exists, so start with no-ops.
|
||||
let syncHiddenRendererPtyDelivery: () => void = () => {}
|
||||
let releaseHiddenRendererPtyDelivery: () => void = () => {}
|
||||
let suppressViewportClaimTerminalResize = false
|
||||
// Why: idle callbacks are registered before the deferred PTY output plumbing
|
||||
// exists. Start with the shared scheduler, then switch to the PTY writer
|
||||
// below so hidden-tab resets keep backlog-recovery callbacks and byte order.
|
||||
@@ -3294,6 +3296,26 @@ export function connectPanePty(
|
||||
(data) => transport.sendInputImmediate(data)
|
||||
)
|
||||
|
||||
const claimViewportForUserActivity = (): void => {
|
||||
const currentPtyId = transport.getPtyId()
|
||||
if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') {
|
||||
return
|
||||
}
|
||||
let proposed: { cols: number; rows: number } | undefined
|
||||
try {
|
||||
proposed = pane.fitAddon.proposeDimensions()
|
||||
} catch {
|
||||
proposed = undefined
|
||||
}
|
||||
const cols = proposed?.cols ?? pane.terminal.cols
|
||||
const rows = proposed?.rows ?? pane.terminal.rows
|
||||
if (cols > 0 && rows > 0) {
|
||||
// Why: queuing a claim is not convergence. Keep the pane parked until the
|
||||
// runtime confirms desktop-fit so a transient resize failure retries.
|
||||
transport.claimViewport?.(cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
const onDataDisposable = pane.terminal.onData((data) => {
|
||||
// Why: xterm auto-replies to embedded query sequences (DA1, DECRQM,
|
||||
// OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes
|
||||
@@ -3349,6 +3371,7 @@ export function connectPanePty(
|
||||
// excluded because those transports do not expose sendInputAccepted.
|
||||
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
|
||||
if (acknowledgedIntent && transport.sendInputAccepted) {
|
||||
claimViewportForUserActivity()
|
||||
if (acknowledgedIntent === 'ctrl-c') {
|
||||
// Why: the accepted-write callback is async; let the next command be
|
||||
// inferred if the user cancelled an oversized line and immediately typed.
|
||||
@@ -3374,6 +3397,7 @@ export function connectPanePty(
|
||||
return
|
||||
}
|
||||
if (intent) {
|
||||
claimViewportForUserActivity()
|
||||
if (transport.sendInput(data)) {
|
||||
markAcceptedTerminalInputSent()
|
||||
observeAcceptedShellCommandInput(data)
|
||||
@@ -3382,6 +3406,7 @@ export function connectPanePty(
|
||||
clearPendingTerminalInputIntent()
|
||||
return
|
||||
}
|
||||
claimViewportForUserActivity()
|
||||
if (transport.sendInput(data)) {
|
||||
markAcceptedTerminalInputSent()
|
||||
observeAcceptedShellCommandInput(data)
|
||||
@@ -3427,7 +3452,7 @@ export function connectPanePty(
|
||||
if (queuePanePtyResizeIfHeld(pane.container, cols, rows)) {
|
||||
return
|
||||
}
|
||||
transport.resize(cols, rows)
|
||||
transport.resize(cols, rows, { claim: true })
|
||||
}
|
||||
|
||||
const onHeldPtyResizeFlush = (event: Event): void => {
|
||||
@@ -3440,7 +3465,7 @@ export function connectPanePty(
|
||||
pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush)
|
||||
|
||||
const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => {
|
||||
if (suppressSnapshotReplayPtyResize) {
|
||||
if (suppressSnapshotReplayPtyResize || suppressViewportClaimTerminalResize) {
|
||||
return
|
||||
}
|
||||
forwardPtyResize(cols, rows)
|
||||
@@ -3526,14 +3551,41 @@ export function connectPanePty(
|
||||
// the PTY's applied size; mobile-fit panes only report desktop geometry so
|
||||
// the parked phone-sized PTY is not resized. See docs/mobile-fit-hold.md.
|
||||
let pendingGeometryReportRaf: number | null = null
|
||||
let lastObservedDesktopGrid: { cols: number; rows: number } | null = null
|
||||
const readPaneSize = (): { width: number; height: number } | null => {
|
||||
if (typeof pane.container.getBoundingClientRect !== 'function') {
|
||||
return null
|
||||
}
|
||||
const rect = pane.container.getBoundingClientRect()
|
||||
return { width: rect.width, height: rect.height }
|
||||
}
|
||||
let lastObservedPaneSize = readPaneSize()
|
||||
let pendingPaneGeometryChanged = false
|
||||
const handleObservedPaneGeometry = (): void => {
|
||||
pendingGeometryReportRaf = null
|
||||
const paneGeometryChanged = pendingPaneGeometryChanged
|
||||
pendingPaneGeometryChanged = false
|
||||
const currentPtyId = transport.getPtyId()
|
||||
if (!currentPtyId) {
|
||||
// Why: ResizeObserver may deliver its initial measurement before the
|
||||
// remote binding completes; retain that passive baseline for the first
|
||||
// real focused resize instead of swallowing the user's first claim.
|
||||
const proposed = readProposedTerminalGrid()
|
||||
if (proposed) {
|
||||
lastObservedDesktopGrid = proposed
|
||||
}
|
||||
return
|
||||
}
|
||||
const fitOverride = getFitOverrideForPty(currentPtyId)
|
||||
if (!fitOverride) {
|
||||
if (pane.terminal.cols > 0 && pane.terminal.rows > 0) {
|
||||
// Why: record the local grid before a later remote hold parks xterm;
|
||||
// the first real window/split resize can then claim immediately.
|
||||
lastObservedDesktopGrid = {
|
||||
cols: pane.terminal.cols,
|
||||
rows: pane.terminal.rows
|
||||
}
|
||||
}
|
||||
if (shouldSuppressDesktopPtyResize()) {
|
||||
return
|
||||
}
|
||||
@@ -3551,6 +3603,33 @@ export function connectPanePty(
|
||||
if (!proposed || proposed.cols <= 0 || proposed.rows <= 0) {
|
||||
return
|
||||
}
|
||||
const priorProposed = lastObservedDesktopGrid
|
||||
lastObservedDesktopGrid = proposed
|
||||
if (fitOverride.mode === 'remote-desktop-fit') {
|
||||
if (
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: fitOverride.mode,
|
||||
prior: priorProposed,
|
||||
current: proposed,
|
||||
paneGeometryChanged,
|
||||
paneVisible: deps.isVisibleRef.current,
|
||||
documentVisible: document.visibilityState !== 'hidden',
|
||||
documentFocused: document.hasFocus()
|
||||
})
|
||||
) {
|
||||
// Why: a focused, visible layout change is genuine activity; release
|
||||
// the park and update xterm before claiming so the owner does not keep
|
||||
// rendering the prior owner's stale grid.
|
||||
suppressViewportClaimTerminalResize = true
|
||||
try {
|
||||
pane.terminal.resize(proposed.cols, proposed.rows)
|
||||
} finally {
|
||||
suppressViewportClaimTerminalResize = false
|
||||
}
|
||||
transport.resize(proposed.cols, proposed.rows, { claim: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isRemoteRuntimePtyId(currentPtyId)) {
|
||||
transport.resize(proposed.cols, proposed.rows)
|
||||
} else {
|
||||
@@ -3561,6 +3640,16 @@ export function connectPanePty(
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
const paneSize = readPaneSize()
|
||||
if (
|
||||
paneSize &&
|
||||
lastObservedPaneSize &&
|
||||
(paneSize.width !== lastObservedPaneSize.width ||
|
||||
paneSize.height !== lastObservedPaneSize.height)
|
||||
) {
|
||||
pendingPaneGeometryChanged = true
|
||||
}
|
||||
lastObservedPaneSize = paneSize
|
||||
if (pendingGeometryReportRaf !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,10 +101,17 @@ export type PtyTransport = {
|
||||
// (preserving order) and sends the reply immediately.
|
||||
sendInputImmediate: (data: string) => boolean
|
||||
sendInputAccepted?: (data: string) => Promise<boolean>
|
||||
claimViewport?: (cols: number, rows: number) => boolean
|
||||
resize: (
|
||||
cols: number,
|
||||
rows: number,
|
||||
meta?: { widthPx?: number; heightPx?: number; cellW?: number; cellH?: number }
|
||||
meta?: {
|
||||
widthPx?: number
|
||||
heightPx?: number
|
||||
cellW?: number
|
||||
cellH?: number
|
||||
claim?: boolean
|
||||
}
|
||||
) => boolean
|
||||
isConnected: () => boolean
|
||||
getPtyId: () => string | null
|
||||
|
||||
@@ -976,11 +976,24 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
}),
|
||||
|
||||
resize(cols: number, rows: number): boolean {
|
||||
claimViewport(cols: number, rows: number): boolean {
|
||||
if (!connected || !ptyId) {
|
||||
return false
|
||||
}
|
||||
window.api.pty.resize(ptyId, cols, rows)
|
||||
window.api.pty.claimViewport(ptyId, cols, rows)
|
||||
return true
|
||||
},
|
||||
|
||||
resize(cols: number, rows: number, meta): boolean {
|
||||
if (!connected || !ptyId) {
|
||||
return false
|
||||
}
|
||||
if (meta?.claim) {
|
||||
window.api.pty.resize(ptyId, cols, rows)
|
||||
window.api.pty.claimViewport(ptyId, cols, rows)
|
||||
} else {
|
||||
window.api.pty.resize(ptyId, cols, rows)
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim'
|
||||
|
||||
describe('shouldClaimRemoteDesktopViewport', () => {
|
||||
it('requires a second, changed measurement from a focused visible pane', () => {
|
||||
const current = { cols: 100, rows: 30 }
|
||||
expect(
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: 'remote-desktop-fit',
|
||||
prior: null,
|
||||
current,
|
||||
paneGeometryChanged: false,
|
||||
paneVisible: true,
|
||||
documentVisible: true,
|
||||
documentFocused: true
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: 'remote-desktop-fit',
|
||||
prior: { cols: 90, rows: 30 },
|
||||
current,
|
||||
paneGeometryChanged: false,
|
||||
paneVisible: true,
|
||||
documentVisible: true,
|
||||
documentFocused: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ paneVisible: false, documentVisible: true, documentFocused: true },
|
||||
{ paneVisible: true, documentVisible: false, documentFocused: true },
|
||||
{ paneVisible: true, documentVisible: true, documentFocused: false }
|
||||
])('rejects passive background geometry: %o', (visibility) => {
|
||||
expect(
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: 'remote-desktop-fit',
|
||||
prior: { cols: 90, rows: 30 },
|
||||
current: { cols: 100, rows: 30 },
|
||||
paneGeometryChanged: false,
|
||||
...visibility
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the first focused measurement after the observed pane box changed', () => {
|
||||
expect(
|
||||
shouldClaimRemoteDesktopViewport({
|
||||
holdMode: 'remote-desktop-fit',
|
||||
prior: null,
|
||||
current: { cols: 70, rows: 30 },
|
||||
paneGeometryChanged: true,
|
||||
paneVisible: true,
|
||||
documentVisible: true,
|
||||
documentFocused: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { FitHoldMode } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
|
||||
type TerminalGrid = { cols: number; rows: number }
|
||||
|
||||
export function shouldClaimRemoteDesktopViewport(args: {
|
||||
holdMode: FitHoldMode
|
||||
prior: TerminalGrid | null
|
||||
current: TerminalGrid
|
||||
paneGeometryChanged: boolean
|
||||
paneVisible: boolean
|
||||
documentVisible: boolean
|
||||
documentFocused: boolean
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
args.holdMode === 'remote-desktop-fit' &&
|
||||
(args.paneGeometryChanged ||
|
||||
(args.prior &&
|
||||
(args.prior.cols !== args.current.cols || args.prior.rows !== args.current.rows))) &&
|
||||
args.paneVisible &&
|
||||
args.documentVisible &&
|
||||
args.documentFocused
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
terminal: string
|
||||
client: { id: string; type: string }
|
||||
viewport?: { cols: number; rows: number }
|
||||
capabilities?: { desktopViewportClaims?: 1 }
|
||||
} {
|
||||
const frames = subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
@@ -51,6 +52,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
terminal: string
|
||||
client: { id: string; type: string }
|
||||
viewport?: { cols: number; rows: number }
|
||||
capabilities?: { desktopViewportClaims?: 1 }
|
||||
}>(frame.payload)
|
||||
if (!payload) {
|
||||
throw new Error('invalid terminal subscribe payload')
|
||||
@@ -166,6 +168,12 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBe('remote:terminal-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(latestSubscribePayload().capabilities).toEqual({
|
||||
ackOutput: 1,
|
||||
desktopViewportClaims: 1
|
||||
})
|
||||
)
|
||||
expect(runtimeSubscribe).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
@@ -177,11 +185,78 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
expect(latestSubscribePayload()).toMatchObject({
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' },
|
||||
client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 }
|
||||
})
|
||||
})
|
||||
|
||||
it('parks passive peers when another remote desktop owns the grid', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const { getFitOverrideForPty, setFitOverride } =
|
||||
await import('@/lib/pane-manager/mobile-fit-overrides')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
|
||||
await transport.connect({ url: '', cols: 120, rows: 40, callbacks: {} })
|
||||
const { streamId } = latestSubscribePayload()
|
||||
const ptyId = transport.getPtyId()
|
||||
expect(ptyId).not.toBeNull()
|
||||
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'fit-override-changed',
|
||||
streamId,
|
||||
mode: 'remote-desktop-fit',
|
||||
cols: 96,
|
||||
rows: 32
|
||||
}
|
||||
})
|
||||
|
||||
expect(ptyId ? getFitOverrideForPty(ptyId) : null).toEqual({
|
||||
mode: 'remote-desktop-fit',
|
||||
cols: 96,
|
||||
rows: 32
|
||||
})
|
||||
if (ptyId) {
|
||||
setFitOverride(ptyId, 'desktop-fit', 0, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives separate paired viewers of the same host pane distinct refresh identities', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const first = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
const second = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
|
||||
first.attach({ existingPtyId: 'remote:terminal-1', cols: 80, rows: 24, callbacks: {} })
|
||||
second.attach({ existingPtyId: 'remote:terminal-1', cols: 120, rows: 40, callbacks: {} })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const subscribeFrames = subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0]))
|
||||
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe)
|
||||
expect(subscribeFrames).toHaveLength(2)
|
||||
const clientIds = subscribeFrames.map((frame) => {
|
||||
const payload = frame
|
||||
? decodeTerminalStreamJson<{ client: { id: string } }>(frame.payload)
|
||||
: null
|
||||
return payload?.client.id
|
||||
})
|
||||
expect(clientIds[0]).toMatch(/^desktop:tab-1:pane:1:/)
|
||||
expect(clientIds[1]).toMatch(/^desktop:tab-1:pane:1:/)
|
||||
expect(clientIds[0]).not.toBe(clientIds[1])
|
||||
})
|
||||
|
||||
first.destroy?.()
|
||||
second.destroy?.()
|
||||
})
|
||||
|
||||
it('routes encoded restored terminal ids to their owning runtime environment', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-2', {
|
||||
@@ -593,7 +668,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
params: {
|
||||
terminal: 'terminal-new',
|
||||
text: 'x',
|
||||
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
|
||||
client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' },
|
||||
viewport: { cols: 80, rows: 24 },
|
||||
claimViewport: true
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
@@ -1363,6 +1440,63 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('releases pending claimed input when reconnect subscription fails', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onError = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
await transport.connect({ url: '', callbacks: { onError } })
|
||||
let rejectReconnect = (_error: Error): void => {}
|
||||
runtimeSubscribe.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
rejectReconnect = reject
|
||||
})
|
||||
)
|
||||
|
||||
subscriptionCallbacks?.onClose?.()
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
|
||||
expect(transport.claimViewport?.(101, 33)).toBe(true)
|
||||
const accepted = transport.sendInputAccepted?.('\x03')
|
||||
await Promise.resolve()
|
||||
rejectReconnect(new Error('reconnect failed'))
|
||||
|
||||
await expect(accepted).resolves.toBe(false)
|
||||
expect(onError).toHaveBeenCalledWith('reconnect failed')
|
||||
})
|
||||
|
||||
it('retries when a replacement transport closes before its stream installs', async () => {
|
||||
const transportCallbacks: NonNullable<typeof subscriptionCallbacks>[] = []
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
|
||||
transportCallbacks.push(callbacks)
|
||||
subscriptionCallbacks = callbacks
|
||||
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
|
||||
}
|
||||
)
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
const connected = transport.connect({ url: '', callbacks: {} })
|
||||
await vi.waitFor(() => expect(transportCallbacks).toHaveLength(1))
|
||||
transportCallbacks[0].onResponse({ ok: true, result: { type: 'ready' } })
|
||||
await connected
|
||||
|
||||
transportCallbacks[0].onClose?.()
|
||||
await vi.waitFor(() => expect(transportCallbacks).toHaveLength(2))
|
||||
transportCallbacks[1].onResponse({ ok: true, result: { type: 'ready' } })
|
||||
transportCallbacks[1].onClose?.()
|
||||
|
||||
await vi.waitFor(() => expect(transportCallbacks).toHaveLength(3))
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('resubscribes with the latest pane viewport after the remote stream closes', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
@@ -1440,6 +1574,54 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
transport.destroy?.()
|
||||
})
|
||||
|
||||
it('replays a claim before input typed during the subscribe round-trip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
runtimeSubscribe.mockImplementation(
|
||||
async (_args: unknown, callbacks: typeof subscriptionCallbacks) => {
|
||||
subscriptionCallbacks = callbacks
|
||||
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
|
||||
}
|
||||
)
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:terminal-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalled())
|
||||
expect(transport.claimViewport?.(101, 33)).toBe(true)
|
||||
expect(transport.sendInput('x')).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(8)
|
||||
expect(runtimeCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'terminal.send' })
|
||||
)
|
||||
|
||||
emitMultiplexReady()
|
||||
await vi.waitFor(() => {
|
||||
const opcodes = subscriptionSendBinary.mock.calls
|
||||
.map((call) => decodeTerminalStreamFrame(call[0])?.opcode)
|
||||
.filter((opcode) => opcode !== undefined)
|
||||
expect(opcodes).toEqual([
|
||||
TerminalStreamOpcode.Subscribe,
|
||||
TerminalStreamOpcode.ClaimViewport,
|
||||
TerminalStreamOpcode.Resize,
|
||||
TerminalStreamOpcode.Input
|
||||
])
|
||||
})
|
||||
transport.destroy?.()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces rapid remote terminal input before sending it to the runtime', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -1568,7 +1750,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
params: {
|
||||
terminal: 'terminal-1',
|
||||
text: '\x03',
|
||||
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
|
||||
client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' },
|
||||
viewport: { cols: 80, rows: 24 },
|
||||
claimViewport: true
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
@@ -1609,7 +1793,9 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
params: {
|
||||
terminal: 'terminal-1',
|
||||
text: 'a\x03',
|
||||
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
|
||||
client: { id: expect.stringMatching(/^desktop:tab-1:pane:1:/), type: 'desktop' },
|
||||
viewport: { cols: 80, rows: 24 },
|
||||
claimViewport: true
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
@@ -1857,6 +2043,42 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('sends an activity claim before the user input it sizes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1'
|
||||
})
|
||||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
const { streamId } = latestSubscribePayload()
|
||||
subscriptionSendBinary.mockClear()
|
||||
|
||||
expect(transport.claimViewport?.(101, 33)).toBe(true)
|
||||
expect(transport.sendInput('x')).toBe(true)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
const frames = subscriptionSendBinary.mock.calls.map((call) =>
|
||||
decodeTerminalStreamFrame(call[0])
|
||||
)
|
||||
expect(frames.map((frame) => frame?.opcode)).toEqual([
|
||||
TerminalStreamOpcode.ClaimViewport,
|
||||
TerminalStreamOpcode.Resize,
|
||||
TerminalStreamOpcode.Input
|
||||
])
|
||||
expect(frames[0]?.streamId).toBe(streamId)
|
||||
expect(frames[0] ? decodeTerminalStreamJson(frames[0].payload) : null).toEqual({
|
||||
cols: 101,
|
||||
rows: 33
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays remote scrollback through the parser without firing stale attention events', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onReplayData = vi.fn()
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createRemoteRuntimePtyTextBatcher,
|
||||
createRemoteRuntimeViewportBatcher
|
||||
} from './remote-runtime-pty-batching'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-terminal-surface-id'
|
||||
@@ -89,7 +90,22 @@ export function createRemoteRuntimePtyTransport(
|
||||
let desiredViewport: { cols: number; rows: number } | null = null
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
let resubscribing = false
|
||||
const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}`
|
||||
let resubscribeRequested = false
|
||||
let subscriptionGeneration = 0
|
||||
let pendingViewportClaim = false
|
||||
let pendingClaimInput = ''
|
||||
const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>()
|
||||
const clearPendingViewportClaim = (): void => {
|
||||
pendingViewportClaim = false
|
||||
pendingClaimInput = ''
|
||||
for (const resolve of viewportClaimReadyWaiters) {
|
||||
resolve(false)
|
||||
}
|
||||
viewportClaimReadyWaiters.clear()
|
||||
}
|
||||
// Why: tab/leaf ids identify the mirrored host pane, so every paired viewer
|
||||
// shares them. The instance suffix keeps one viewer's refresh off peer records.
|
||||
const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}`
|
||||
const outputProcessor = createPtyOutputProcessor({
|
||||
onTitleChange,
|
||||
onBell,
|
||||
@@ -262,6 +278,14 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!connected || handle !== targetHandle) {
|
||||
return false
|
||||
}
|
||||
if (pendingViewportClaim && !getCurrentMultiplexedStream(targetHandle)) {
|
||||
const ready = await new Promise<boolean>((resolve) => {
|
||||
viewportClaimReadyWaiters.add(resolve)
|
||||
})
|
||||
if (!ready || !connected || handle !== targetHandle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Why: normal remote sendInput may be waiting on yielded size validation;
|
||||
// drain it before acknowledged writes so terminal bytes stay ordered.
|
||||
const text = `${inputBatcher.takePending()}${data}`
|
||||
@@ -283,7 +307,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text: chunk,
|
||||
client: { id: clientId, type: 'desktop' }
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
|
||||
})
|
||||
if (result.send.accepted !== true) {
|
||||
return false
|
||||
@@ -303,30 +328,46 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (!connected || !targetHandle) {
|
||||
return
|
||||
}
|
||||
if (getCurrentMultiplexedStream(targetHandle)?.sendInput(text)) {
|
||||
const stream = getCurrentMultiplexedStream(targetHandle)
|
||||
if (stream?.sendInput(text)) {
|
||||
return
|
||||
}
|
||||
if (pendingViewportClaim) {
|
||||
// Why: a claim during subscribe/reconnect has no stream record to own
|
||||
// yet. Hold its input until the stream can emit claim+input in one order.
|
||||
pendingClaimInput += text
|
||||
return
|
||||
}
|
||||
void callRuntime('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text,
|
||||
client: { id: clientId, type: 'desktop' }
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
|
||||
}).catch((error) => {
|
||||
handleRemoteTerminalError(error)
|
||||
})
|
||||
})
|
||||
|
||||
function sendViewportUpdate(cols: number, rows: number): void {
|
||||
function sendViewportUpdate(cols: number, rows: number, claim = false): void {
|
||||
const targetHandle = handle
|
||||
if (!connected || !targetHandle) {
|
||||
return
|
||||
}
|
||||
if (getCurrentMultiplexedStream(targetHandle)?.resize(cols, rows)) {
|
||||
const stream = getCurrentMultiplexedStream(targetHandle)
|
||||
if (claim ? stream?.claimViewport(cols, rows) : stream?.resize(cols, rows)) {
|
||||
if (claim) {
|
||||
pendingViewportClaim = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (claim) {
|
||||
pendingViewportClaim = true
|
||||
}
|
||||
void callRuntime('terminal.updateViewport', {
|
||||
terminal: targetHandle,
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
viewport: { cols, rows }
|
||||
viewport: { cols, rows },
|
||||
...(claim ? { claim: true } : {})
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -363,6 +404,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
|
||||
function retireRemoteTerminalId(): void {
|
||||
connected = false
|
||||
clearPendingViewportClaim()
|
||||
const stalePtyId = remotePtyId
|
||||
handle = null
|
||||
remotePtyId = null
|
||||
@@ -414,18 +456,48 @@ export function createRemoteRuntimePtyTransport(
|
||||
await subscribeToHandle()
|
||||
}
|
||||
|
||||
function scheduleResubscribeAfterTransportClose(): void {
|
||||
if (destroyed || !connected || !handle) {
|
||||
return
|
||||
}
|
||||
if (resubscribing) {
|
||||
resubscribeRequested = true
|
||||
return
|
||||
}
|
||||
resubscribing = true
|
||||
const resubscribeHandle = handle
|
||||
void resubscribeAfterTransportClose(resubscribeHandle)
|
||||
.catch((error) => {
|
||||
if (!destroyed && connected && handle) {
|
||||
clearPendingViewportClaim()
|
||||
handleRemoteTerminalError(error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resubscribing = false
|
||||
if (resubscribeRequested) {
|
||||
resubscribeRequested = false
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function subscribeToHandle(): Promise<void> {
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
const subscribedHandle = handle
|
||||
const subscribedPtyId = remotePtyId
|
||||
const generation = ++subscriptionGeneration
|
||||
let transportClosed = false
|
||||
// Why: the viewport we hand the subscribe request. A resize landing during
|
||||
// the round-trip falls back to the one-shot RPC, which is refresh-only (no
|
||||
// leak) and no-ops before the stream floor exists — so replay the latest
|
||||
// leak) and no-ops before the stream record exists — so replay the latest
|
||||
// remembered viewport through the stream once it's current (below).
|
||||
const subscribedViewport = desiredViewport
|
||||
const isCurrentSubscription = (): boolean =>
|
||||
!transportClosed &&
|
||||
generation === subscriptionGeneration &&
|
||||
isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId)
|
||||
const nextStream = await getRemoteRuntimeTerminalMultiplexer(
|
||||
currentRuntimeEnvironmentId
|
||||
@@ -491,29 +563,30 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
},
|
||||
onTransportClose: () => {
|
||||
if (!isCurrentSubscription()) {
|
||||
transportClosed = true
|
||||
if (generation !== subscriptionGeneration) {
|
||||
return
|
||||
}
|
||||
if (!isCurrentSubscription()) {
|
||||
// isCurrentSubscription excludes the just-closed stream by design.
|
||||
if (!isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
multiplexedStream = null
|
||||
multiplexedStreamHandle = null
|
||||
if (destroyed || !connected || !handle || resubscribing) {
|
||||
return
|
||||
}
|
||||
resubscribing = true
|
||||
const resubscribeHandle = handle
|
||||
void resubscribeAfterTransportClose(resubscribeHandle)
|
||||
.catch((error) => {
|
||||
if (!destroyed && connected && handle) {
|
||||
handleRemoteTerminalError(error)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
resubscribing = false
|
||||
})
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
}
|
||||
}
|
||||
})
|
||||
if (destroyed || !connected || handle !== subscribedHandle || remotePtyId !== subscribedPtyId) {
|
||||
if (
|
||||
transportClosed ||
|
||||
generation !== subscriptionGeneration ||
|
||||
destroyed ||
|
||||
!connected ||
|
||||
handle !== subscribedHandle ||
|
||||
remotePtyId !== subscribedPtyId
|
||||
) {
|
||||
nextStream.close()
|
||||
return
|
||||
}
|
||||
@@ -521,10 +594,22 @@ export function createRemoteRuntimePtyTransport(
|
||||
multiplexedStream = nextStream
|
||||
multiplexedStreamHandle = subscribedHandle
|
||||
// Why: a viewport change that landed during the subscribe round-trip took
|
||||
// the now-no-op one-shot fallback, so the stream floor is still at the
|
||||
// the now-no-op one-shot fallback, so the stream record is still at the
|
||||
// subscribe-time size. Replay the latest remembered viewport so the PTY
|
||||
// tracks the current width instead of stalling until the next resize.
|
||||
if (
|
||||
if (pendingViewportClaim && desiredViewport) {
|
||||
nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows)
|
||||
pendingViewportClaim = false
|
||||
const queuedInput = pendingClaimInput
|
||||
pendingClaimInput = ''
|
||||
if (queuedInput) {
|
||||
nextStream.sendInput(queuedInput)
|
||||
}
|
||||
for (const resolve of viewportClaimReadyWaiters) {
|
||||
resolve(true)
|
||||
}
|
||||
viewportClaimReadyWaiters.clear()
|
||||
} else if (
|
||||
desiredViewport &&
|
||||
(desiredViewport.cols !== subscribedViewport?.cols ||
|
||||
desiredViewport.rows !== subscribedViewport?.rows)
|
||||
@@ -634,6 +719,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (handle === targetHandle && multiplexedStreamHandle !== targetHandle) {
|
||||
closeMultiplexedStream()
|
||||
}
|
||||
clearPendingViewportClaim()
|
||||
handleRemoteTerminalError(error)
|
||||
})
|
||||
},
|
||||
@@ -647,6 +733,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
return
|
||||
}
|
||||
connected = false
|
||||
clearPendingViewportClaim()
|
||||
const id = remotePtyId
|
||||
closeMultiplexedStream()
|
||||
handle = null
|
||||
@@ -663,6 +750,7 @@ export function createRemoteRuntimePtyTransport(
|
||||
viewportBatcher.flush()
|
||||
outputProcessor.clearAccumulatedState()
|
||||
connected = false
|
||||
clearPendingViewportClaim()
|
||||
closeMultiplexedStream()
|
||||
storedCallbacks = {}
|
||||
},
|
||||
@@ -712,10 +800,15 @@ export function createRemoteRuntimePtyTransport(
|
||||
if (stream?.sendInput(text)) {
|
||||
return true
|
||||
}
|
||||
if (pendingViewportClaim) {
|
||||
pendingClaimInput += text
|
||||
return true
|
||||
}
|
||||
void callRuntime('terminal.send', {
|
||||
terminal: targetHandle,
|
||||
text,
|
||||
client: { id: clientId, type: 'desktop' }
|
||||
client: { id: clientId, type: 'desktop' },
|
||||
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
|
||||
}).catch((error) => {
|
||||
handleRemoteTerminalError(error)
|
||||
})
|
||||
@@ -724,11 +817,26 @@ export function createRemoteRuntimePtyTransport(
|
||||
|
||||
sendInputAccepted: sendInputAcceptedToRuntime,
|
||||
|
||||
resize(cols: number, rows: number): boolean {
|
||||
claimViewport(cols: number, rows: number): boolean {
|
||||
if (!connected || !handle) {
|
||||
return false
|
||||
}
|
||||
rememberViewport(cols, rows)
|
||||
viewportBatcher.clear()
|
||||
sendViewportUpdate(cols, rows, true)
|
||||
return true
|
||||
},
|
||||
|
||||
resize(cols: number, rows: number, meta): boolean {
|
||||
if (!connected || !handle) {
|
||||
return false
|
||||
}
|
||||
rememberViewport(cols, rows)
|
||||
if (meta?.claim) {
|
||||
viewportBatcher.clear()
|
||||
sendViewportUpdate(cols, rows, true)
|
||||
return true
|
||||
}
|
||||
// Why: xterm fit can emit resize bursts while the user drags panes or
|
||||
// restores layouts. Remote runtimes only need the last viewport in a frame.
|
||||
viewportBatcher.queue(cols, rows)
|
||||
|
||||
@@ -3228,7 +3228,7 @@ export function useIpcEvents(): void {
|
||||
kind: 'fit'
|
||||
event: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
getPaneIdsForPty,
|
||||
onOverrideChange,
|
||||
hydrateOverrides,
|
||||
getAllOverrides
|
||||
getAllOverrides,
|
||||
getMobileFitOverridePtyIds
|
||||
} from './mobile-fit-overrides'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -36,6 +37,18 @@ describe('setFitOverride / getFitOverrideForPty', () => {
|
||||
expect(override).toEqual({ mode: 'mobile-fit', cols: 49, rows: 20 })
|
||||
})
|
||||
|
||||
it('stores and releases a remote desktop fit hold', () => {
|
||||
setFitOverride('pty-remote', 'remote-desktop-fit', 96, 32)
|
||||
expect(getFitOverrideForPty('pty-remote')).toEqual({
|
||||
mode: 'remote-desktop-fit',
|
||||
cols: 96,
|
||||
rows: 32
|
||||
})
|
||||
|
||||
setFitOverride('pty-remote', 'desktop-fit', 120, 40)
|
||||
expect(getFitOverrideForPty('pty-remote')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes the override when mode is desktop-fit', () => {
|
||||
setFitOverride('pty-1', 'mobile-fit', 49, 20)
|
||||
setFitOverride('pty-1', 'desktop-fit', 120, 40)
|
||||
@@ -323,6 +336,13 @@ describe('getAllOverrides', () => {
|
||||
all.delete('pty-1')
|
||||
expect(getFitOverrideForPty('pty-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('excludes remote desktop holds from mobile bulk restore', () => {
|
||||
setFitOverride('pty-mobile', 'mobile-fit', 49, 20)
|
||||
setFitOverride('pty-remote', 'remote-desktop-fit', 100, 30)
|
||||
|
||||
expect(getMobileFitOverridePtyIds()).toEqual(['pty-mobile'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Why: mobile-fit overrides are runtime-owned state that the renderer must
|
||||
// respect. When a mobile client resizes a PTY to phone dimensions, the desktop
|
||||
// renderer must not auto-fit that PTY back to desktop size. This module stores
|
||||
// the override state and provides lookup for safeFit() and transport.resize().
|
||||
// Why: a phone or another desktop may own the shared PTY grid. Non-owners park
|
||||
// xterm at that authoritative size so passive fitting cannot start a resize war.
|
||||
|
||||
export type FitHoldMode = 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
|
||||
type FitOverride = {
|
||||
mode: 'mobile-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function fitBindingKey(tabId: string, paneId: number): string {
|
||||
// and trigger safeFit on affected panes.
|
||||
type OverrideChangeEvent = {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: FitHoldMode
|
||||
cols: number
|
||||
rows: number
|
||||
// Why: the dimensions the PTY was at *before* this event fired. For a
|
||||
@@ -49,14 +49,9 @@ function notifyChange(event: OverrideChangeEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function setFitOverride(
|
||||
ptyId: string,
|
||||
mode: 'mobile-fit' | 'desktop-fit',
|
||||
cols: number,
|
||||
rows: number
|
||||
): void {
|
||||
export function setFitOverride(ptyId: string, mode: FitHoldMode, cols: number, rows: number): void {
|
||||
const prior = overridesByPtyId.get(ptyId) ?? null
|
||||
if (mode === 'mobile-fit') {
|
||||
if (mode === 'mobile-fit' || mode === 'remote-desktop-fit') {
|
||||
overridesByPtyId.set(ptyId, { mode, cols, rows })
|
||||
} else {
|
||||
overridesByPtyId.delete(ptyId)
|
||||
@@ -117,7 +112,12 @@ export function unbindPane(paneId: number, tabId?: string): void {
|
||||
}
|
||||
|
||||
export function hydrateOverrides(
|
||||
overrides: { ptyId: string; mode: 'mobile-fit'; cols: number; rows: number }[]
|
||||
overrides: {
|
||||
ptyId: string
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}[]
|
||||
): void {
|
||||
const previous = new Map(overridesByPtyId)
|
||||
overridesByPtyId.clear()
|
||||
@@ -131,7 +131,7 @@ export function hydrateOverrides(
|
||||
const prior = previous.get(ptyId) ?? null
|
||||
notifyChange({
|
||||
ptyId,
|
||||
mode: 'mobile-fit',
|
||||
mode: override.mode,
|
||||
cols: override.cols,
|
||||
rows: override.rows,
|
||||
priorCols: prior?.cols ?? null,
|
||||
@@ -155,3 +155,9 @@ export function hydrateOverrides(
|
||||
export function getAllOverrides(): Map<string, FitOverride> {
|
||||
return new Map(overridesByPtyId)
|
||||
}
|
||||
|
||||
export function getMobileFitOverridePtyIds(): string[] {
|
||||
return [...overridesByPtyId].flatMap(([ptyId, override]) =>
|
||||
override.mode === 'mobile-fit' ? [ptyId] : []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,6 +252,22 @@ describe('safeFit', () => {
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(49, 20)
|
||||
})
|
||||
|
||||
it('parks xterm at a remote desktop owner grid', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 120,
|
||||
proposedRows: 40,
|
||||
terminalCols: 120,
|
||||
terminalRows: 40
|
||||
})
|
||||
pane.container.dataset.ptyId = 'pty-remote'
|
||||
setFitOverride('pty-remote', 'remote-desktop-fit', 96, 32)
|
||||
|
||||
safeFit(pane)
|
||||
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(96, 32)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips resize when terminal already matches override dimensions', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 120,
|
||||
|
||||
@@ -25,7 +25,7 @@ type TerminalMultiplexEvent =
|
||||
| {
|
||||
type: 'fit-override-changed'
|
||||
streamId: number
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
|
||||
onEnd?: () => void
|
||||
onError?: (message: string) => void
|
||||
onFitOverrideChanged?: (event: {
|
||||
mode: 'mobile-fit' | 'desktop-fit'
|
||||
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit'
|
||||
cols: number
|
||||
rows: number
|
||||
}) => void
|
||||
@@ -57,6 +57,7 @@ export type RemoteRuntimeMultiplexedTerminal = {
|
||||
streamId: number
|
||||
sendInput: (text: string) => boolean
|
||||
resize: (cols: number, rows: number) => boolean
|
||||
claimViewport: (cols: number, rows: number) => boolean
|
||||
serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
@@ -247,6 +248,22 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
TerminalStreamOpcode.Resize,
|
||||
encodeTerminalStreamJson({ cols, rows })
|
||||
),
|
||||
claimViewport: (cols, rows) => {
|
||||
const claimed = this.sendFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.ClaimViewport,
|
||||
encodeTerminalStreamJson({ cols, rows })
|
||||
)
|
||||
// Why: older runtimes ignore the claim opcode but still understand
|
||||
// Resize. Claim first keeps new-runtime ownership precise and leaves a
|
||||
// backwards-compatible resize immediately behind it.
|
||||
const resized = this.sendFrame(
|
||||
streamId,
|
||||
TerminalStreamOpcode.Resize,
|
||||
encodeTerminalStreamJson({ cols, rows })
|
||||
)
|
||||
return claimed && resized
|
||||
},
|
||||
serializeBuffer: (opts) => this.requestSnapshot(state, opts),
|
||||
close: () => {
|
||||
if (this.streams.get(streamId) === state) {
|
||||
@@ -271,7 +288,8 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
terminal: args.terminal,
|
||||
client: args.client,
|
||||
viewport: args.viewport,
|
||||
capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined
|
||||
capabilities:
|
||||
args.client.type === 'desktop' ? { ackOutput: 1, desktopViewportClaims: 1 } : undefined
|
||||
})
|
||||
)
|
||||
if (!sent) {
|
||||
@@ -389,7 +407,9 @@ class RemoteRuntimeTerminalMultiplexer {
|
||||
)
|
||||
} else if (event.type === 'fit-override-changed') {
|
||||
if (
|
||||
(event.mode !== 'mobile-fit' && event.mode !== 'desktop-fit') ||
|
||||
(event.mode !== 'mobile-fit' &&
|
||||
event.mode !== 'remote-desktop-fit' &&
|
||||
event.mode !== 'desktop-fit') ||
|
||||
typeof event.cols !== 'number' ||
|
||||
typeof event.rows !== 'number'
|
||||
) {
|
||||
|
||||
@@ -2713,6 +2713,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
write: () => {},
|
||||
writeAccepted: () => Promise.resolve(false),
|
||||
resize: () => {},
|
||||
claimViewport: () => {},
|
||||
reportGeometry: () => {},
|
||||
signal: () => {},
|
||||
// Web panes clear the host buffer via the terminal.clearBuffer runtime RPC.
|
||||
|
||||
@@ -17,7 +17,10 @@ export enum TerminalStreamOpcode {
|
||||
Metadata = 12,
|
||||
// Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only
|
||||
// remote-multiplex flow control) renumbers to stay wire-compatible.
|
||||
Ack = 13
|
||||
Ack = 13,
|
||||
// Why 14: Ack already occupies 13 on current clients; older runtimes ignore
|
||||
// this opcode and still receive the compatibility Resize frame behind it.
|
||||
ClaimViewport = 14
|
||||
}
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
@@ -98,6 +101,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
value === TerminalStreamOpcode.Unsubscribe ||
|
||||
value === TerminalStreamOpcode.SnapshotRequest ||
|
||||
value === TerminalStreamOpcode.Metadata ||
|
||||
value === TerminalStreamOpcode.Ack
|
||||
value === TerminalStreamOpcode.Ack ||
|
||||
value === TerminalStreamOpcode.ClaimViewport
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user