fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) (#12362)
* fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337) An empty scrollback frame with absent host dims was coerced to 80x24, which never equals a phone viewport, arming a zero-delay unsubscribe/resubscribe loop (~25/s) that broke long-press gestures and drained battery. - Absent host dims now hold the stream instead of resubscribing. - Fit resubscribes are budgeted per handle (3 attempts, escalating backoff) with an absence-gated refill mirroring the chat-side rearm bound; on exhaustion the view degrades visibly via toast instead of hot-looping. - A fresh post-measure match counts as convergence instead of resubscribing. - setTerminalModes keeps the Map identity when the mode is unchanged, so same-mode frames no longer re-render the session route. - Host emits the subscriber viewport as scrollback dims when the snapshot and PTY size are both unavailable, so current hosts converge immediately. * fix(mobile): cancel stale viewport retries after convergence
This commit is contained in:
parent
637c7e94c9
commit
0586bab4f9
|
|
@ -235,6 +235,11 @@ import {
|
|||
} from '../../../../src/session/mobile-terminal-prune-decision'
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
|
||||
import {
|
||||
TerminalViewportResubscribeBudget,
|
||||
readTerminalViewportDims,
|
||||
runTerminalViewportFitPass
|
||||
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
|
||||
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
|
||||
|
|
@ -1030,6 +1035,8 @@ export default function SessionScreen() {
|
|||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
|
||||
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
|
||||
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
|
||||
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
|
||||
const webReadyHandlesRef = useRef<Set<string>>(new Set())
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
|
|
@ -1376,6 +1383,7 @@ export default function SessionScreen() {
|
|||
subscribingHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
|
|
@ -1505,10 +1513,11 @@ export default function SessionScreen() {
|
|||
return
|
||||
}
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
const scrollbackRows = rows
|
||||
const { hostCols, hostRows } = readTerminalViewportDims(data)
|
||||
// Why: absent host dims must not be coerced into a comparable size — 80x24
|
||||
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
|
||||
const cols = hostCols ?? viewportRef.current?.cols ?? 80
|
||||
const rows = hostRows ?? viewportRef.current?.rows ?? 24
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
|
|
@ -1527,46 +1536,36 @@ export default function SessionScreen() {
|
|||
ref.init(cols, rows, initialData, false, oscLinks)
|
||||
initializedHandlesRef.current.add(handle)
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
|
||||
const needsResubscribe =
|
||||
!viewportMeasuredRef.current ||
|
||||
(viewportRef.current != null &&
|
||||
(scrollbackCols !== viewportRef.current.cols ||
|
||||
scrollbackRows !== viewportRef.current.rows))
|
||||
if (needsResubscribe) {
|
||||
void (async () => {
|
||||
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
|
||||
await getTerminalRef(handle)?.awaitReady()
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions(
|
||||
terminalFrameHeightRef.current || undefined
|
||||
)
|
||||
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
if (!getTerminalRef(handle)) {
|
||||
return
|
||||
}
|
||||
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
|
||||
if (dims) {
|
||||
diagnostics.streamResubscribing(handle, seq, dims)
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
unsubscribeTerminal(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
}
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
|
||||
// and resubscribe so the server can phone-fit — bounded per handle so a
|
||||
// non-converging host degrades visibly instead of hot-looping (STA-3337).
|
||||
runTerminalViewportFitPass({
|
||||
handle,
|
||||
seq,
|
||||
hostCols,
|
||||
hostRows,
|
||||
budget: viewportResubscribeBudgetRef.current,
|
||||
diagnostics,
|
||||
viewportRef,
|
||||
viewportMeasuredRef,
|
||||
subscribeSeqRef,
|
||||
initializedHandlesRef,
|
||||
terminalUnsubsRef,
|
||||
terminalFrameHeightRef,
|
||||
getTerminalRef,
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
scheduleDelayedAction,
|
||||
showToast
|
||||
})
|
||||
} else if (data.type === 'metadata') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
} else if (data.type === 'data') {
|
||||
|
|
@ -1591,8 +1590,12 @@ export default function SessionScreen() {
|
|||
} else if (data.type === 'resized') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
|
||||
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
|
||||
handle,
|
||||
data,
|
||||
viewport
|
||||
)
|
||||
const serialized = typeof data.serialized === 'string' ? data.serialized : null
|
||||
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
|
||||
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
|
||||
|
|
@ -1602,8 +1605,10 @@ export default function SessionScreen() {
|
|||
getTerminalRef(handle)?.resize(cols, rows)
|
||||
}
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
|
|
@ -1619,7 +1624,7 @@ export default function SessionScreen() {
|
|||
}
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
},
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
|
||||
)
|
||||
|
||||
const nativeChatStream = useMobileNativeChatTerminalStream({
|
||||
|
|
@ -1720,12 +1725,16 @@ export default function SessionScreen() {
|
|||
unsubscribeTerminal(handle)
|
||||
terminalRefs.current.delete(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
viewportResubscribeBudgetRef.current.forget(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
}
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
|
||||
// Why: a chat-covered handle the host reports again refills its rearm budget,
|
||||
// so an exhausted rearm can't lock the composer until leave-chat.
|
||||
nativeChatStream.notifyListedHandles(liveHandles)
|
||||
// Why: same absence-gated refill for the viewport-fit budget — a handle that
|
||||
// left the list and returned may converge now, so it earns fresh attempts.
|
||||
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
|
||||
lastKnownTerminalCountRef.current = result.terminals.length
|
||||
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
|
||||
const seen = new Set<string>()
|
||||
|
|
|
|||
|
|
@ -141,12 +141,35 @@ export class MobileTerminalDiagnostics {
|
|||
})
|
||||
}
|
||||
|
||||
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
|
||||
streamResubscribing(
|
||||
handle: string,
|
||||
seq: number,
|
||||
dims: { cols: number; rows: number },
|
||||
attempt: number,
|
||||
delayMs: number
|
||||
): void {
|
||||
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
cols: dims.cols,
|
||||
rows: dims.rows
|
||||
rows: dims.rows,
|
||||
attempt,
|
||||
delayMs
|
||||
})
|
||||
}
|
||||
|
||||
streamResubscribeHeld(handle: string, seq: number): void {
|
||||
logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq
|
||||
})
|
||||
}
|
||||
|
||||
streamResubscribeExhausted(handle: string, seq: number, attempts: number): void {
|
||||
logMobileTerminalDiagnostic('stream-resubscribe-exhausted', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
attempts
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS,
|
||||
TerminalViewportResubscribeBudget,
|
||||
readTerminalViewportDims,
|
||||
resolveTerminalViewportResubscribe,
|
||||
runTerminalViewportFitPass,
|
||||
shouldResubscribeAfterViewportMeasure,
|
||||
type TerminalViewportFitPassArgs
|
||||
} from './mobile-terminal-viewport-resubscribe'
|
||||
|
||||
const PHONE = { cols: 40, rows: 50 }
|
||||
|
||||
describe('readTerminalViewportDims', () => {
|
||||
it('accepts only usable numeric host dimensions', () => {
|
||||
expect(readTerminalViewportDims({ cols: 40, rows: 50 })).toEqual({
|
||||
hostCols: 40,
|
||||
hostRows: 50
|
||||
})
|
||||
expect(readTerminalViewportDims({ cols: Number.NaN, rows: 0 })).toEqual({
|
||||
hostCols: null,
|
||||
hostRows: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalViewportResubscribe', () => {
|
||||
it('resubscribes immediately on the first pass when the viewport is unmeasured', () => {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: false,
|
||||
viewport: null,
|
||||
attempts: 0
|
||||
})
|
||||
).toEqual({ kind: 'resubscribe', delayMs: 0 })
|
||||
})
|
||||
|
||||
it('caps even the unmeasured pass once the budget is spent', () => {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: false,
|
||||
viewport: null,
|
||||
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
|
||||
})
|
||||
).toEqual({ kind: 'exhausted' })
|
||||
})
|
||||
|
||||
it('holds on absent host dims instead of resubscribing (STA-3337 regression)', () => {
|
||||
for (const [hostCols, hostRows] of [
|
||||
[null, null],
|
||||
[80, null],
|
||||
[null, 24]
|
||||
] as const) {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols,
|
||||
hostRows,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts: 0
|
||||
})
|
||||
).toEqual({ kind: 'hold' })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps holding on absent dims across repeated frames without spending budget', () => {
|
||||
for (let frame = 0; frame < 50; frame += 1) {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: null,
|
||||
hostRows: null,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts: 0
|
||||
}).kind
|
||||
).toBe('hold')
|
||||
}
|
||||
})
|
||||
|
||||
it('converges when host dims match the measured viewport', () => {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: PHONE.cols,
|
||||
hostRows: PHONE.rows,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts: 2
|
||||
})
|
||||
).toEqual({ kind: 'converged' })
|
||||
})
|
||||
|
||||
it('backs off across mismatch attempts and then exhausts', () => {
|
||||
const delays = [0, 1, 2].map((attempts) => {
|
||||
const decision = resolveTerminalViewportResubscribe({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts
|
||||
})
|
||||
if (decision.kind !== 'resubscribe') {
|
||||
throw new Error(`expected resubscribe at attempt ${attempts}, got ${decision.kind}`)
|
||||
}
|
||||
return decision.delayMs
|
||||
})
|
||||
expect(delays[0]).toBe(0)
|
||||
expect(delays[1]).toBeGreaterThan(0)
|
||||
expect(delays[2]).toBeGreaterThan(delays[1])
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
|
||||
})
|
||||
).toEqual({ kind: 'exhausted' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldResubscribeAfterViewportMeasure', () => {
|
||||
it('always resubscribes when the viewport was never measured (server must learn it)', () => {
|
||||
expect(
|
||||
shouldResubscribeAfterViewportMeasure({
|
||||
hostCols: PHONE.cols,
|
||||
hostRows: PHONE.rows,
|
||||
measured: PHONE,
|
||||
viewportWasMeasured: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the resubscribe when the fresh measure already matches the host', () => {
|
||||
expect(
|
||||
shouldResubscribeAfterViewportMeasure({
|
||||
hostCols: PHONE.cols,
|
||||
hostRows: PHONE.rows,
|
||||
measured: PHONE,
|
||||
viewportWasMeasured: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('resubscribes when the host still disagrees with the fresh measure', () => {
|
||||
expect(
|
||||
shouldResubscribeAfterViewportMeasure({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
measured: PHONE,
|
||||
viewportWasMeasured: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TerminalViewportResubscribeBudget', () => {
|
||||
const exhaust = (budget: TerminalViewportResubscribeBudget, handle: string) => {
|
||||
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
|
||||
budget.chargeAttempt(handle)
|
||||
}
|
||||
}
|
||||
|
||||
it('starts at zero and counts charged attempts per handle', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
expect(budget.attempts('t1')).toBe(0)
|
||||
budget.chargeAttempt('t1')
|
||||
budget.chargeAttempt('t1')
|
||||
expect(budget.attempts('t1')).toBe(2)
|
||||
expect(budget.attempts('t2')).toBe(0)
|
||||
})
|
||||
|
||||
it('resets attempts and re-arms the announcement on convergence', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
exhaust(budget, 't1')
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
budget.markConverged('t1')
|
||||
expect(budget.attempts('t1')).toBe(0)
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
})
|
||||
|
||||
it('announces exhaustion exactly once', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
exhaust(budget, 't1')
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refill an exhausted handle that stayed listed', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
exhaust(budget, 't1')
|
||||
for (let refresh = 0; refresh < 5; refresh += 1) {
|
||||
budget.notifyListedHandles(new Set(['t1']))
|
||||
}
|
||||
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('refills only after the exhausted handle went absent and came back', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
exhaust(budget, 't1')
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
budget.notifyListedHandles(new Set())
|
||||
// Still exhausted while absent — the refill lands on the return.
|
||||
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
|
||||
budget.notifyListedHandles(new Set(['t1']))
|
||||
expect(budget.attempts('t1')).toBe(0)
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves below-cap handles untouched by list refreshes', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
budget.chargeAttempt('t1')
|
||||
budget.notifyListedHandles(new Set())
|
||||
budget.notifyListedHandles(new Set(['t1']))
|
||||
expect(budget.attempts('t1')).toBe(1)
|
||||
})
|
||||
|
||||
it('forget clears one handle, clear clears everything', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
exhaust(budget, 't1')
|
||||
exhaust(budget, 't2')
|
||||
budget.forget('t1')
|
||||
expect(budget.attempts('t1')).toBe(0)
|
||||
expect(budget.attempts('t2')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
|
||||
budget.clear()
|
||||
expect(budget.attempts('t2')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTerminalViewportFitPass', () => {
|
||||
const HANDLE = 't1'
|
||||
|
||||
function makeHarness(overrides: {
|
||||
hostCols?: number | null
|
||||
hostRows?: number | null
|
||||
viewportMeasured?: boolean
|
||||
viewport?: { cols: number; rows: number } | null
|
||||
measured?: { cols: number; rows: number } | null
|
||||
budget?: TerminalViewportResubscribeBudget
|
||||
}) {
|
||||
const budget = overrides.budget ?? new TerminalViewportResubscribeBudget()
|
||||
const diagnostics = {
|
||||
streamResubscribing: vi.fn(),
|
||||
streamResubscribeHeld: vi.fn(),
|
||||
streamResubscribeExhausted: vi.fn()
|
||||
}
|
||||
const terminalUnsubsRef = { current: new Map<string, () => void>([[HANDLE, () => {}]]) }
|
||||
const scheduled: { fn: () => void; ms: number }[] = []
|
||||
const webView = {
|
||||
awaitReady: () => Promise.resolve(),
|
||||
measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE)
|
||||
}
|
||||
const unsubscribeTerminal = vi.fn((handle: string) => {
|
||||
terminalUnsubsRef.current.delete(handle)
|
||||
})
|
||||
// Mimic the real subscribe path: arming registers an unsubscribe handle.
|
||||
const subscribeToTerminal = vi.fn((handle: string) => {
|
||||
terminalUnsubsRef.current.set(handle, () => {})
|
||||
})
|
||||
const showToast = vi.fn()
|
||||
const args: TerminalViewportFitPassArgs = {
|
||||
handle: HANDLE,
|
||||
seq: 1,
|
||||
hostCols: overrides.hostCols ?? null,
|
||||
hostRows: overrides.hostRows ?? null,
|
||||
budget,
|
||||
diagnostics,
|
||||
viewportRef: { current: overrides.viewport ?? null },
|
||||
viewportMeasuredRef: { current: overrides.viewportMeasured ?? false },
|
||||
subscribeSeqRef: { current: new Map([[HANDLE, 1]]) },
|
||||
initializedHandlesRef: { current: new Set([HANDLE]) },
|
||||
terminalUnsubsRef,
|
||||
terminalFrameHeightRef: { current: 0 },
|
||||
getTerminalRef: () => webView,
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
scheduleDelayedAction: (fn, ms) => scheduled.push({ fn, ms }),
|
||||
showToast
|
||||
}
|
||||
return {
|
||||
args,
|
||||
budget,
|
||||
diagnostics,
|
||||
scheduled,
|
||||
subscribeToTerminal,
|
||||
unsubscribeTerminal,
|
||||
showToast
|
||||
}
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
it('holds without any teardown when host dims are absent (STA-3337 regression)', async () => {
|
||||
const h = makeHarness({ viewportMeasured: true, viewport: PHONE })
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(h.diagnostics.streamResubscribeHeld).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('measures and resubscribes immediately on the first pass, charging one attempt', async () => {
|
||||
const h = makeHarness({ hostCols: 80, hostRows: 24 })
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.args.viewportRef.current).toEqual(PHONE)
|
||||
expect(h.args.viewportMeasuredRef.current).toBe(true)
|
||||
expect(h.budget.attempts(HANDLE)).toBe(1)
|
||||
})
|
||||
|
||||
it('treats an equal fresh measure as convergence instead of resubscribing', async () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
budget.chargeAttempt(HANDLE)
|
||||
// Stale cached viewport disagrees with the host, but the fresh measure matches it.
|
||||
const h = makeHarness({
|
||||
hostCols: PHONE.cols,
|
||||
hostRows: PHONE.rows,
|
||||
viewportMeasured: true,
|
||||
viewport: { cols: 30, rows: 20 },
|
||||
measured: PHONE,
|
||||
budget
|
||||
})
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(h.budget.attempts(HANDLE)).toBe(0)
|
||||
})
|
||||
|
||||
it('defers later attempts through the backoff scheduler while keeping the stream up', async () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
budget.chargeAttempt(HANDLE)
|
||||
const h = makeHarness({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
budget
|
||||
})
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.scheduled).toHaveLength(1)
|
||||
expect(h.scheduled[0].ms).toBeGreaterThan(0)
|
||||
// The stream must still be up until the deferred retry fires.
|
||||
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
h.scheduled[0].fn()
|
||||
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.budget.attempts(HANDLE)).toBe(2)
|
||||
})
|
||||
|
||||
it('drops a deferred retry whose subscribe generation went stale', async () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
budget.chargeAttempt(HANDLE)
|
||||
const h = makeHarness({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
budget
|
||||
})
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.scheduled).toHaveLength(1)
|
||||
h.args.subscribeSeqRef.current.set(HANDLE, 2)
|
||||
h.scheduled[0].fn()
|
||||
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.budget.attempts(HANDLE)).toBe(1)
|
||||
})
|
||||
|
||||
it('drops a deferred retry after the live stream converges', async () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
budget.chargeAttempt(HANDLE)
|
||||
const h = makeHarness({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
budget
|
||||
})
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.scheduled).toHaveLength(1)
|
||||
expect(h.budget.observeResize(HANDLE, PHONE, PHONE)).toEqual([PHONE.cols, PHONE.rows])
|
||||
h.scheduled[0].fn()
|
||||
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(h.budget.attempts(HANDLE)).toBe(0)
|
||||
})
|
||||
|
||||
it('announces exhaustion once and stops touching the stream', async () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
|
||||
budget.chargeAttempt(HANDLE)
|
||||
}
|
||||
const h = makeHarness({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
budget
|
||||
})
|
||||
runTerminalViewportFitPass(h.args)
|
||||
runTerminalViewportFitPass(h.args)
|
||||
await settle()
|
||||
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
|
||||
expect(h.showToast).toHaveBeenCalledTimes(1)
|
||||
expect(h.diagnostics.streamResubscribeExhausted).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-3337 stream shapes', () => {
|
||||
it('empty scrollback with absent dims settles after a single register pass', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
// Pass 1: no viewport yet — measure and resubscribe so the server learns it.
|
||||
const first = resolveTerminalViewportResubscribe({
|
||||
hostCols: null,
|
||||
hostRows: null,
|
||||
viewportMeasured: false,
|
||||
viewport: null,
|
||||
attempts: budget.attempts('t1')
|
||||
})
|
||||
expect(first).toEqual({ kind: 'resubscribe', delayMs: 0 })
|
||||
budget.chargeAttempt('t1')
|
||||
// Pass 2+: host still reports no dims — the stream must be left alone.
|
||||
for (let frame = 0; frame < 10; frame += 1) {
|
||||
expect(
|
||||
resolveTerminalViewportResubscribe({
|
||||
hostCols: null,
|
||||
hostRows: null,
|
||||
viewportMeasured: true,
|
||||
viewport: PHONE,
|
||||
attempts: budget.attempts('t1')
|
||||
}).kind
|
||||
).toBe('hold')
|
||||
}
|
||||
expect(budget.attempts('t1')).toBe(1)
|
||||
})
|
||||
|
||||
it('non-converging numeric dims degrade after the bounded backoff run', () => {
|
||||
const budget = new TerminalViewportResubscribeBudget()
|
||||
const kinds: string[] = []
|
||||
for (let frame = 0; frame < 6; frame += 1) {
|
||||
const decision = resolveTerminalViewportResubscribe({
|
||||
hostCols: 80,
|
||||
hostRows: 24,
|
||||
viewportMeasured: frame > 0,
|
||||
viewport: frame > 0 ? PHONE : null,
|
||||
attempts: budget.attempts('t1')
|
||||
})
|
||||
kinds.push(decision.kind)
|
||||
if (decision.kind === 'resubscribe') {
|
||||
budget.chargeAttempt('t1')
|
||||
}
|
||||
}
|
||||
expect(kinds).toEqual([
|
||||
'resubscribe',
|
||||
'resubscribe',
|
||||
'resubscribe',
|
||||
'exhausted',
|
||||
'exhausted',
|
||||
'exhausted'
|
||||
])
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
|
||||
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/** Bounds the scrollback→measure→resubscribe fit loop (STA-3337): a host whose
|
||||
* frame dims can never equal the phone viewport must not re-arm the stream
|
||||
* forever — it broke gesture recognition and drained battery at ~25 cycles/s. */
|
||||
|
||||
import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
|
||||
|
||||
export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3
|
||||
|
||||
/** Attempt-indexed teardown delay. Attempt 0 is the ordinary first fit pass
|
||||
* (server learns the viewport) and must stay immediate; later attempts mean
|
||||
* the server answered with non-matching dims, so probe at a decaying rate. */
|
||||
const TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS = [0, 750, 3000] as const
|
||||
|
||||
export type TerminalViewportDims = { readonly cols: number; readonly rows: number }
|
||||
|
||||
function readPositiveDimension(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export function readTerminalViewportDims(data: Readonly<Record<string, unknown>>): {
|
||||
readonly hostCols: number | null
|
||||
readonly hostRows: number | null
|
||||
} {
|
||||
return {
|
||||
hostCols: readPositiveDimension(data.cols),
|
||||
hostRows: readPositiveDimension(data.rows)
|
||||
}
|
||||
}
|
||||
|
||||
export type TerminalViewportResubscribeDecision =
|
||||
| { readonly kind: 'resubscribe'; readonly delayMs: number }
|
||||
| { readonly kind: 'converged' }
|
||||
| { readonly kind: 'hold' }
|
||||
| { readonly kind: 'exhausted' }
|
||||
|
||||
function resubscribeDelayMs(attempts: number): number {
|
||||
return TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS[
|
||||
Math.min(attempts, TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS.length - 1)
|
||||
]
|
||||
}
|
||||
|
||||
export function resolveTerminalViewportResubscribe(args: {
|
||||
hostCols: number | null
|
||||
hostRows: number | null
|
||||
viewportMeasured: boolean
|
||||
viewport: TerminalViewportDims | null
|
||||
attempts: number
|
||||
}): TerminalViewportResubscribeDecision {
|
||||
const overBudget = args.attempts >= MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
|
||||
// First subscribe carries no viewport; resubscribing is how the server learns it.
|
||||
if (!args.viewportMeasured || args.viewport == null) {
|
||||
return overBudget ? { kind: 'exhausted' } : { kind: 'resubscribe', delayMs: 0 }
|
||||
}
|
||||
// Why: a host that doesn't report PTY dims can never converge — resubscribing
|
||||
// replays the identical frame, so keep the stream instead of probing it.
|
||||
if (args.hostCols == null || args.hostRows == null) {
|
||||
return { kind: 'hold' }
|
||||
}
|
||||
if (args.hostCols === args.viewport.cols && args.hostRows === args.viewport.rows) {
|
||||
return { kind: 'converged' }
|
||||
}
|
||||
return overBudget
|
||||
? { kind: 'exhausted' }
|
||||
: { kind: 'resubscribe', delayMs: resubscribeDelayMs(args.attempts) }
|
||||
}
|
||||
|
||||
/** Post-measure re-check: the pre-measure mismatch may have been a stale cached
|
||||
* viewport. Resubscribing is only productive when the server still disagrees
|
||||
* with the fresh measure, or was never told the viewport at all. */
|
||||
export function shouldResubscribeAfterViewportMeasure(args: {
|
||||
hostCols: number | null
|
||||
hostRows: number | null
|
||||
measured: TerminalViewportDims
|
||||
viewportWasMeasured: boolean
|
||||
}): boolean {
|
||||
if (!args.viewportWasMeasured) {
|
||||
return true
|
||||
}
|
||||
return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows
|
||||
}
|
||||
|
||||
/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts
|
||||
* refill only when the handle actually left terminal.list and came back. A
|
||||
* still-listed non-converging handle re-funded on every list refresh would undo
|
||||
* the bound this class exists to enforce. */
|
||||
export class TerminalViewportResubscribeBudget {
|
||||
private readonly attemptsByHandle = new Map<string, number>()
|
||||
private readonly absentSinceExhaustion = new Set<string>()
|
||||
private readonly announcedExhaustion = new Set<string>()
|
||||
private readonly retryGenerationByHandle = new Map<string, object>()
|
||||
|
||||
attempts(handle: string): number {
|
||||
return this.attemptsByHandle.get(handle) ?? 0
|
||||
}
|
||||
|
||||
chargeAttempt(handle: string): void {
|
||||
this.attemptsByHandle.set(handle, this.attempts(handle) + 1)
|
||||
}
|
||||
|
||||
retryGeneration(handle: string): object {
|
||||
const existing = this.retryGenerationByHandle.get(handle)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const generation = {}
|
||||
this.retryGenerationByHandle.set(handle, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
isRetryGenerationCurrent(handle: string, generation: object): boolean {
|
||||
return this.retryGenerationByHandle.get(handle) === generation
|
||||
}
|
||||
|
||||
observeResize(
|
||||
handle: string,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
viewport: TerminalViewportDims | null
|
||||
): readonly [number, number] {
|
||||
const { hostCols, hostRows } = readTerminalViewportDims(data)
|
||||
const cols = hostCols ?? 80
|
||||
const rows = hostRows ?? 24
|
||||
if (viewport?.cols === cols && viewport.rows === rows) {
|
||||
this.markConverged(handle)
|
||||
}
|
||||
return [cols, rows]
|
||||
}
|
||||
|
||||
markConverged(handle: string): void {
|
||||
this.forget(handle)
|
||||
}
|
||||
|
||||
/** True exactly once per exhaustion so the degraded state is announced, not spammed. */
|
||||
shouldAnnounceExhaustion(handle: string): boolean {
|
||||
if (this.announcedExhaustion.has(handle)) {
|
||||
return false
|
||||
}
|
||||
this.announcedExhaustion.add(handle)
|
||||
return true
|
||||
}
|
||||
|
||||
notifyListedHandles(liveHandles: ReadonlySet<string>): void {
|
||||
for (const handle of Array.from(this.attemptsByHandle.keys())) {
|
||||
if (this.attempts(handle) < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) {
|
||||
continue
|
||||
}
|
||||
if (!liveHandles.has(handle)) {
|
||||
this.absentSinceExhaustion.add(handle)
|
||||
continue
|
||||
}
|
||||
// Why: only an absence marker buys a refill — the handle's PTY may be live
|
||||
// again, so a fresh budget (and a fresh degrade announcement) is warranted.
|
||||
if (this.absentSinceExhaustion.delete(handle)) {
|
||||
this.forget(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forget(handle: string): void {
|
||||
this.attemptsByHandle.delete(handle)
|
||||
this.absentSinceExhaustion.delete(handle)
|
||||
this.announcedExhaustion.delete(handle)
|
||||
this.retryGenerationByHandle.delete(handle)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.attemptsByHandle.clear()
|
||||
this.absentSinceExhaustion.clear()
|
||||
this.announcedExhaustion.clear()
|
||||
this.retryGenerationByHandle.clear()
|
||||
}
|
||||
}
|
||||
|
||||
type MutableRef<T> = { current: T }
|
||||
|
||||
type TerminalFitWebView = {
|
||||
awaitReady: () => Promise<unknown>
|
||||
measureFitDimensions: (frameHeight?: number) => Promise<TerminalViewportDims | null | undefined>
|
||||
}
|
||||
|
||||
export type TerminalViewportFitPassArgs = {
|
||||
handle: string
|
||||
seq: number
|
||||
hostCols: number | null
|
||||
hostRows: number | null
|
||||
budget: TerminalViewportResubscribeBudget
|
||||
diagnostics: Pick<
|
||||
MobileTerminalDiagnostics,
|
||||
'streamResubscribing' | 'streamResubscribeHeld' | 'streamResubscribeExhausted'
|
||||
>
|
||||
viewportRef: MutableRef<TerminalViewportDims | null>
|
||||
viewportMeasuredRef: MutableRef<boolean>
|
||||
subscribeSeqRef: MutableRef<Map<string, number>>
|
||||
initializedHandlesRef: MutableRef<Set<string>>
|
||||
terminalUnsubsRef: MutableRef<Map<string, () => void>>
|
||||
terminalFrameHeightRef: MutableRef<number>
|
||||
getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined
|
||||
unsubscribeTerminal: (handle: string) => void
|
||||
subscribeToTerminal: (handle: string) => void
|
||||
scheduleDelayedAction: (fn: () => void, ms: number) => void
|
||||
showToast: (message: string, durationMs?: number) => void
|
||||
}
|
||||
|
||||
/** One bounded fit pass per scrollback frame: converge, hold, degrade visibly,
|
||||
* or measure and resubscribe (backing off) so the server can phone-fit. */
|
||||
export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): void {
|
||||
const { handle, seq, hostCols, hostRows, budget, diagnostics } = args
|
||||
const retryGeneration = budget.retryGeneration(handle)
|
||||
const decision = resolveTerminalViewportResubscribe({
|
||||
hostCols,
|
||||
hostRows,
|
||||
viewportMeasured: args.viewportMeasuredRef.current,
|
||||
viewport: args.viewportRef.current,
|
||||
attempts: budget.attempts(handle)
|
||||
})
|
||||
if (decision.kind === 'converged') {
|
||||
budget.markConverged(handle)
|
||||
return
|
||||
}
|
||||
if (decision.kind === 'hold') {
|
||||
diagnostics.streamResubscribeHeld(handle, seq)
|
||||
return
|
||||
}
|
||||
if (decision.kind === 'exhausted') {
|
||||
diagnostics.streamResubscribeExhausted(handle, seq, budget.attempts(handle))
|
||||
if (budget.shouldAnnounceExhaustion(handle)) {
|
||||
args.showToast("Couldn't fit the terminal to this screen", 4000)
|
||||
}
|
||||
return
|
||||
}
|
||||
const viewportWasMeasured = args.viewportMeasuredRef.current
|
||||
void (async () => {
|
||||
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
|
||||
await args.getTerminalRef(handle)?.awaitReady()
|
||||
if (
|
||||
args.subscribeSeqRef.current.get(handle) !== seq ||
|
||||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const dims = await args
|
||||
.getTerminalRef(handle)
|
||||
?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined)
|
||||
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
|
||||
if (
|
||||
args.subscribeSeqRef.current.get(handle) !== seq ||
|
||||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!args.getTerminalRef(handle) || !dims) {
|
||||
return
|
||||
}
|
||||
args.viewportRef.current = dims
|
||||
args.viewportMeasuredRef.current = true
|
||||
if (
|
||||
!shouldResubscribeAfterViewportMeasure({
|
||||
hostCols,
|
||||
hostRows,
|
||||
measured: dims,
|
||||
viewportWasMeasured
|
||||
})
|
||||
) {
|
||||
// Why: the pre-measure mismatch was a stale cached viewport; the server already agrees.
|
||||
budget.markConverged(handle)
|
||||
return
|
||||
}
|
||||
const resubscribe = (): void => {
|
||||
if (
|
||||
args.subscribeSeqRef.current.get(handle) !== seq ||
|
||||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!args.getTerminalRef(handle)) {
|
||||
return
|
||||
}
|
||||
diagnostics.streamResubscribing(handle, seq, dims, budget.attempts(handle), decision.delayMs)
|
||||
args.unsubscribeTerminal(handle)
|
||||
args.initializedHandlesRef.current.delete(handle)
|
||||
args.subscribeToTerminal(handle)
|
||||
// Why: only a resubscribe that actually armed spends budget; one turned away by its own gates never reached the host.
|
||||
if (args.terminalUnsubsRef.current.has(handle)) {
|
||||
budget.chargeAttempt(handle)
|
||||
}
|
||||
}
|
||||
if (decision.delayMs > 0) {
|
||||
// Why: keep the live stream up through the backoff so input keeps flowing; teardown happens only when the retry fires.
|
||||
args.scheduleDelayedAction(resubscribe, decision.delayMs)
|
||||
} else {
|
||||
resubscribe()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
|
@ -2974,8 +2974,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
serialized: serialized?.data,
|
||||
oscLinks: serialized?.oscLinks,
|
||||
cwd: serialized?.cwd,
|
||||
cols: serialized?.cols ?? size?.cols,
|
||||
rows: serialized?.rows ?? size?.rows,
|
||||
// Why: an empty snapshot with no PTY size must still report the dims the fit
|
||||
// will produce — dimless frames re-armed the mobile fit loop (STA-3337).
|
||||
cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols,
|
||||
rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows,
|
||||
displayMode,
|
||||
seq
|
||||
})
|
||||
|
|
@ -3381,8 +3383,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
})
|
||||
const snapshotStats = sendSnapshotFrames(sendFrame, {
|
||||
kind: 'scrollback',
|
||||
cols: serialized?.cols ?? size?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? 24,
|
||||
// Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has
|
||||
// no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337).
|
||||
cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80,
|
||||
rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24,
|
||||
displayMode,
|
||||
seq: snapshotFrameSeq,
|
||||
cwd: serialized?.cwd,
|
||||
|
|
|
|||
Loading…
Reference in New Issue