From 2d9da847cb4f3775ddc50adea33352631e144621 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:30:57 -0700 Subject: [PATCH] fix(runtime): keep desktop take-back from stale phone input Invalidate unfinished phone input claims after desktop takeback or disconnect. Recheck presence after delayed layout and preserve fresh phone input. Closes #23199. Adapted from #23200. Co-authored-by: Tauri-EPO --- src/main/runtime/mobile-presence-lock.test.ts | 85 +++++++++++++++++++ .../runtime-terminal-driver-controller.ts | 7 +- .../layout/mobile-terminal-activity.ts | 15 +++- 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index c81779963fb..eab56b441f9 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -280,6 +280,60 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) }) + it.each(['before commit', 'during commit', 'direct phone-fit'] as const)( + 'desktop take-back supersedes mobile input %s', + async (timing) => { + const { runtime, driverEvents, ptySizes } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + const claim = + timing === 'direct phone-fit' ? null : runtime.beginMobileInputFloor('pty-1', 'phone-A')! + let finishLayout = () => {} + let committing: Promise | undefined + if (timing !== 'before commit') { + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + finishLayout = () => resolve(true) + }) + ) + committing = claim ? claim.commit() : runtime.mobileTookFloor('pty-1', 'phone-A') + } + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + finishLayout() + await (committing ?? claim!.commit()) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'desktop' }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + + await runtime.beginMobileInputFloor('pty-1', 'phone-A')!.commit() + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + } + ) + + it.each(['commit', 'rollback'] as const)( + 'a superseded mobile input %s preserves a newer phone claim', + async (settle) => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 60, rows: 25 }) + await runtime.reclaimTerminalForDesktop('pty-1') + const stale = runtime.beginMobileInputFloor('pty-1', 'phone-A')! + await runtime.reclaimTerminalForDesktop('pty-1') + const fresh = runtime.beginMobileInputFloor('pty-1', 'phone-B')! + + await stale[settle]() + await fresh.commit() + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'mobile', clientId: 'phone-B' }) + } + ) + it('mobile input without an active subscriber cannot create an orphaned floor lock', async () => { const { runtime } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) @@ -291,6 +345,37 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) + it.each(['input', 'legacy'] as const)( + 'a %s phone-fit completion after disconnect cannot recreate the mobile lock', + async (entry) => { + const { runtime, driverEvents } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.reclaimTerminalForDesktop('pty-1') + + let finishLayout!: () => void + vi.spyOn(runtime, 'applyMobileDisplayMode').mockImplementationOnce( + () => + new Promise((resolve) => { + finishLayout = () => resolve(true) + }) + ) + const committing = + entry === 'input' + ? runtime.beginMobileInputFloor('pty-1', 'phone-A')!.commit() + : runtime.mobileTookFloor('pty-1', 'phone-A') + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(250) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + + finishLayout() + await committing + await vi.advanceTimersByTimeAsync(10 * 60_000) + + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(driverEvents.at(-1)?.driver).toEqual({ kind: 'idle' }) + } + ) + it('admits a soft-leaving client to reserve the input floor within the grace window', async () => { const { runtime } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/runtime-terminal-driver-controller.ts b/src/main/runtime/runtime-terminal-driver-controller.ts index 44525ad661d..f70532194d0 100644 --- a/src/main/runtime/runtime-terminal-driver-controller.ts +++ b/src/main/runtime/runtime-terminal-driver-controller.ts @@ -37,6 +37,10 @@ export class RuntimeTerminalDriverController { } set(ptyId: string, next: DriverState): void { + // A desktop reclaim or disconnect supersedes unfinished mobile input. + if (next.kind !== 'mobile') { + this.inputFloorClaims.delete(ptyId) + } const prev = this.get(ptyId) if (prev.kind === next.kind) { if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { @@ -59,6 +63,7 @@ export class RuntimeTerminalDriverController { } clear(ptyId: string): boolean { + this.inputFloorClaims.delete(ptyId) if (!this.current.delete(ptyId)) { return false } @@ -104,7 +109,7 @@ export class RuntimeTerminalDriverController { } settled = true state.pending.delete(token) - if (generation < state.committedGeneration) { + if (this.inputFloorClaims.get(ptyId) !== state || generation < state.committedGeneration) { this.deleteSettledClaim(ptyId, state) return } diff --git a/src/main/runtime/terminal/layout/mobile-terminal-activity.ts b/src/main/runtime/terminal/layout/mobile-terminal-activity.ts index 71c509b1071..cdd9ea39dbb 100644 --- a/src/main/runtime/terminal/layout/mobile-terminal-activity.ts +++ b/src/main/runtime/terminal/layout/mobile-terminal-activity.ts @@ -14,8 +14,12 @@ export abstract class OrcaRuntimeWithMobileTookFloor extends OrcaRuntimeWithMark ptyId: string, clientId: string, previousFloor?: DriverState, - isCurrent: () => boolean = () => true + isCurrent?: () => boolean ): Promise { + if (!isCurrent) { + await this.beginMobileInputFloor(ptyId, clientId)?.commit() + return + } const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) const softLeaver = this.pendingSoftLeavers.get(ptyId) @@ -39,9 +43,12 @@ export abstract class OrcaRuntimeWithMobileTookFloor extends OrcaRuntimeWithMark } await this.applyMobileDisplayMode(ptyId) } - // Why: display changes are async; a later PTY write must keep the floor - // when an older phone-fit operation eventually completes. - if (!isCurrent()) { + // Newer input or disconnect can supersede an asynchronous phone-fit. + if ( + !isCurrent() || + (!this.mobileSubscribers.get(ptyId)?.has(clientId) && + this.pendingSoftLeavers.get(ptyId)?.clientId !== clientId) + ) { return } this.setDriver(ptyId, { kind: 'mobile', clientId })