diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts new file mode 100644 index 00000000000..44216e64d73 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' + +describe('createMobileSessionTabsNotifyCoalescer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces a rapid burst of title/status flips into one trailing emit (repro)', () => { + // Repro of the churn bug: a spinner-in-title agent flips a PTY title many + // times a second. Without coalescing every flip fans out an emit; with it, + // the whole burst settles into a single trailing-edge emit per worktree. + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + coalescer.schedule('worktree-1') + // Each flip lands well inside the trailing window, resetting the timer. + vi.advanceTimersByTime(10) + } + expect(emit).not.toHaveBeenCalled() + + // Let the trailing window elapse after the last flip. + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('force-flushes under sustained churn so the emit is never starved', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + // Keep flipping faster than the trailing window forever; the max-wait cap + // (250ms) must force at least one emit within that budget. + for (let i = 0; i < 100; i++) { + coalescer.schedule('worktree-1') + vi.advanceTimersByTime(30) + } + + expect(emit).toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('keeps per-worktree windows independent', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenCalledWith('worktree-1') + expect(emit).toHaveBeenCalledWith('worktree-2') + }) + + it('cancel() drops a pending notify so a structural emit can supersede it', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + // A structural change (tab add/remove) cancels the coalesced notify because + // it emits immediately elsewhere. + coalescer.cancel('worktree-1') + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) + + it('flush() emits the pending notify immediately', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.flush('worktree-1') + + expect(emit).toHaveBeenCalledTimes(1) + // The timer must not double-fire afterward. + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + }) + + it('flush() is a no-op when nothing is pending', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.flush('worktree-1') + + expect(emit).not.toHaveBeenCalled() + }) + + it('flushAll() drains every pending worktree (subscription close)', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + coalescer.flushAll() + + expect(emit).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('dispose() drops pending timers without emitting', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.dispose() + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts new file mode 100644 index 00000000000..ac6608b71fb --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts @@ -0,0 +1,109 @@ +// Why: spinner-in-title agents (e.g. the Claude Code braille spinner) flip a +// PTY title several times per second. Each flip touched every session.tabs +// snapshot and fanned a fresh emit out to every subscriber, where the ws layer +// JSON.stringifies it per client — O(clients × snapshot size) of churn work +// with no debounce. Clients gate on snapshotVersion freshness, so only the +// newest version per worktree matters; coalescing the intermediate emits is +// safe. Structural changes (tab added/removed/activated) bypass this via an +// immediate flush so they still propagate promptly. + +// Trailing-edge window: title/status is latency-sensitive UI, so this is +// tighter than files.watch's 150ms but looser than native-chat's 40ms. +const SESSION_TABS_FLUSH_MS = 50 +// Force a flush after this long even under sustained churn, so a title that +// keeps spinning never starves the emit indefinitely. +const SESSION_TABS_MAX_WAIT_MS = 250 + +export type MobileSessionTabsNotifyCoalescer = { + // Schedule a coalesced (trailing-edge) notify for a worktree. + schedule: (worktreeId: string) => void + // Cancel any pending notify for a worktree without emitting. Use when an + // immediate emit has already superseded the pending state, or the worktree + // was removed and a stale notify must not fire. + cancel: (worktreeId: string) => void + // Flush a worktree's pending notify now (emit if one is pending). + flush: (worktreeId: string) => void + // Flush every pending worktree now. + flushAll: () => void + // Drop all pending state without emitting (runtime teardown). + dispose: () => void +} + +type PendingNotify = { + timer: ReturnType + firstScheduledAt: number +} + +/** + * Coalesces per-worktree session.tabs notifications on a short trailing-edge + * window. `emit` is invoked once per settled worktree and is expected to read + * the latest snapshot itself, so only the freshest snapshotVersion is ever + * published — dropped intermediate versions are exactly what clients discard. + */ +export function createMobileSessionTabsNotifyCoalescer( + emit: (worktreeId: string) => void +): MobileSessionTabsNotifyCoalescer { + const pending = new Map() + + const clear = (worktreeId: string): void => { + const entry = pending.get(worktreeId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pending.delete(worktreeId) + } + + const fire = (worktreeId: string): void => { + clear(worktreeId) + emit(worktreeId) + } + + const arm = (worktreeId: string): ReturnType => { + const timer = setTimeout(() => fire(worktreeId), SESSION_TABS_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + return timer + } + + return { + schedule(worktreeId: string): void { + const now = Date.now() + const existing = pending.get(worktreeId) + if (existing) { + // Cap total delay so sustained churn can't starve the emit forever. + if (now - existing.firstScheduledAt >= SESSION_TABS_MAX_WAIT_MS) { + fire(worktreeId) + return + } + clearTimeout(existing.timer) + existing.timer = arm(worktreeId) + return + } + pending.set(worktreeId, { timer: arm(worktreeId), firstScheduledAt: now }) + }, + cancel(worktreeId: string): void { + clear(worktreeId) + }, + flush(worktreeId: string): void { + if (pending.has(worktreeId)) { + fire(worktreeId) + } + }, + flushAll(): void { + // Snapshot keys first: fire() deletes from `pending`, and emit may + // schedule new work, so mutating the live map mid-iteration is unsafe. + const worktreeIds = Array.from(pending.keys()) + for (const worktreeId of worktreeIds) { + fire(worktreeId) + } + }, + dispose(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer) + } + pending.clear() + } + } +} diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 3d36716dd5e..6c319271088 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -983,4 +984,109 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) + + describe('session.tabs title/status churn coalescing', () => { + type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void + } + + function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) + } + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The pending coalesced notify was cancelled by the immediate emit, so no + // duplicate trailing emit fires. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state + // still reaches the listener before it is dropped. + unsubscribe() + expect(emits).toHaveLength(1) + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a16fcca9f85..4cb2cf3bc39 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -684,6 +684,10 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' +import { + createMobileSessionTabsNotifyCoalescer, + type MobileSessionTabsNotifyCoalescer +} from './mobile-session-tabs-notify-coalescer' import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -2059,6 +2063,13 @@ export class OrcaRuntimeService { { activate: boolean; selectIfNoActiveTab: boolean } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() + // Why: coalesces title/status-driven session.tabs emits so spinner churn + // doesn't fan out (and per-client JSON.stringify) a snapshot several times a + // second. Emit reads the latest snapshot, so only the freshest version ships. + private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = + createMobileSessionTabsNotifyCoalescer((worktreeId) => + this.notifyMobileSessionTabsChangedNow(worktreeId) + ) private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3545,7 +3556,10 @@ export class OrcaRuntimeService { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) - this.notifyMobileSessionTabsChanged(worktreeId) + // Why: title/status flips several times a second under spinner-in-title + // agents. Coalesce the emit (trailing-edge) instead of fanning out — and + // per-client JSON.stringifying — every intermediate version. + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } @@ -5207,6 +5221,9 @@ export class OrcaRuntimeService { ): () => void { this.mobileSessionTabListeners.add(listener) return () => { + // Why: flush pending coalesced notifies before dropping this listener so a + // subscriber closing mid-window still receives the latest settled state. + this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(listener) } } @@ -18876,6 +18893,9 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + // Why: drop any pending coalesced notify so a stale snapshot can't + // land after the removed frame. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } @@ -19050,6 +19070,14 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + // Why: structural changes (tab add/remove/activate) must propagate promptly, + // so cancel any pending coalesced title/status notify — this immediate emit + // already reflects the latest snapshot and supersedes it. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId) + } + + private notifyMobileSessionTabsChangedNow(worktreeId: string): void { if (this.mobileSessionTabListeners.size === 0) { return }