diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index cee4fcfe79f..292cc13a816 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -840,15 +840,80 @@ describe('mobile subscribe integration', () => { await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) runtime.handleMobileUnsubscribe('pty-1', 'client-a') - // No subscribers, indefinite hold — PTY stays at phone dims. - await vi.advanceTimersByTimeAsync(60_000) - expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) - expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) - - // Manual reclaim: PTY restored to desktop dims via the held branch. - const ok = await runtime.reclaimTerminalForDesktop('pty-1') - expect(ok).toBe(true) + await runtime.reclaimTerminalForDesktop('pty-1') expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('reclaim cancels the soft-leave timer in the remote-layout branch', async () => { + const { runtime } = createRuntime() + await runtime.updateRemoteDesktopViewer('pty-1', 'sub-a', 'viewer-a', 120, 32) + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + }) + + it('reclaim cancels pending restore timers on the active-subscriber branch', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + await runtime.handleMobileSubscribe('pty-1', 'client-b', { cols: 40, rows: 18 }) + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + pendingRestore.set('pty-1', { timer: setTimeout(() => {}, 60_000), clientId: 'client-b' }) + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + expect(pendingSoft.has('pty-1')).toBe(true) + await runtime.reclaimTerminalForDesktop('pty-1') + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim cancels pending restore timers on the orphan-driver branch', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1') + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + await runtime.reclaimTerminalForDesktop('pty-1') + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim cancels pending restore timers when no driver lock remains', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1') + ;(Reflect.get(runtime, 'currentDriver') as Map).set('pty-1', { + kind: 'idle' + }) + + const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map + const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + expect(pendingRestore.has('pty-1')).toBe(false) + expect(pendingSoft.has('pty-1')).toBe(false) + }) + + it('reclaim revokes soft-leave grace admission for mobile input', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'client-a') + + const claim = runtime.beginMobileInputFloor('pty-1', 'client-a') + expect(claim).not.toBeNull() + claim?.rollback() + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.beginMobileInputFloor('pty-1', 'client-a')).toBeNull() }) it('reclaimTerminalForDesktop prefers fresh desktop geometry for a held PTY', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a50cef258e6..db9be9e7aa0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15821,16 +15821,7 @@ export class OrcaRuntimeService { this.layouts.delete(ptyId) this.layoutQueues.delete(ptyId) this.freshSubscribeGuard.delete(ptyId) - const pendingRestore = this.pendingRestoreTimers.get(ptyId) - if (pendingRestore) { - clearTimeout(pendingRestore.timer) - this.pendingRestoreTimers.delete(ptyId) - } - const pendingSoft = this.pendingSoftLeavers.get(ptyId) - if (pendingSoft) { - clearTimeout(pendingSoft.timer) - this.pendingSoftLeavers.delete(ptyId) - } + this.cancelPendingDriverMutations(ptyId) // Why: a cold restore can respawn under the same session id within the // delayed-Enter window; the armed Enter would inject \r into the // replacement and stamp rows it never received. @@ -16487,6 +16478,7 @@ export class OrcaRuntimeService { // frame. Returns `true` whenever there was a lock to reclaim, `false` only // when there was nothing to reclaim. async reclaimTerminalForDesktop(ptyId: string): Promise { + this.cancelPendingDriverMutations(ptyId) if (this.isMobileSubscriberActive(ptyId)) { this.setMobileDisplayMode(ptyId, 'desktop') await this.applyMobileDisplayMode(ptyId) @@ -16506,16 +16498,6 @@ export class OrcaRuntimeService { } 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) - } // Why: applyRemoteDesktopLayout no-ops while the driver still reads mobile. this.setDriver(ptyId, { kind: 'idle' }) // Why: best-effort, like the local held branch below. A host whose resize @@ -16528,11 +16510,6 @@ export class OrcaRuntimeService { return true } if (heldOverride) { - const pending = this.pendingRestoreTimers.get(ptyId) - if (pending) { - clearTimeout(pending.timer) - this.pendingRestoreTimers.delete(ptyId) - } // Why: with no subscribers, resolveDesktopRestoreTarget can fall through // to current PTY size — which is at phone dims (wrong). Prefer a fresh // desktop renderer measurement when one exists; otherwise use the @@ -16557,6 +16534,21 @@ export class OrcaRuntimeService { return false } + // Why: teardown and desktop reclaim supersede delayed mobile mutations, + // revoking soft-leave grace admission for input floors. + private cancelPendingDriverMutations(ptyId: string): void { + const pendingRestore = this.pendingRestoreTimers.get(ptyId) + if (pendingRestore) { + clearTimeout(pendingRestore.timer) + this.pendingRestoreTimers.delete(ptyId) + } + const pendingSoft = this.pendingSoftLeavers.get(ptyId) + if (pendingSoft) { + clearTimeout(pendingSoft.timer) + this.pendingSoftLeavers.delete(ptyId) + } + } + // Why: the shared "banner must be gone now" step for an explicit desktop // take-back. Releases the presence lock (driver → desktop) and, if the // best-effort resize left a fit-override held (resize didn't converge),