perf(remote): coalesce session.tabs snapshot fan-out under title/status churn

Spinner-in-title agents (e.g. the Claude Code braille spinner) flip a PTY
title several times per second. Each flip called touchMobileSessionSnapshotsForPty,
which bumped every PTY-backed snapshot's version and immediately fanned a fresh
session.tabs emit out to every subscriber — where the ws layer JSON.stringifies
it per client. That is O(clients × snapshot size) of churn work with no debounce,
while files.watch batches at 150ms and native-chat debounces at 40ms.

Route the title/status churn path through a small per-worktree trailing-edge
coalescer (50ms window, 250ms max-wait cap). Clients gate on snapshotVersion
freshness, so collapsing intermediate versions is safe — the emit re-reads the
latest snapshot, so only the freshest version ships. Structural changes
(tab add/remove/activate) still emit immediately and cancel any pending
coalesced notify. Pending notifies flush on subscription close and are dropped
on worktree removal so no final state is lost or lands stale.
This commit is contained in:
Jinwoo-H
2026-07-10 05:36:12 -04:00
parent e4c7aab4a2
commit d5809ee3b4
4 changed files with 364 additions and 1 deletions
@@ -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()
})
})
@@ -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<typeof setTimeout>
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<string, PendingNotify>()
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<typeof setTimeout> => {
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()
}
}
}
@@ -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<string, RuntimeMobileSessionTabsSnapshot>
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)
})
})
})
+29 -1
View File
@@ -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<string, RuntimeLeafRecord>()
// 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
}