fix(mobile): add a native watchdog so a dead terminal document can't stay silently blank

CodeRabbit round: if the webview document dies before the glue can post
anything (or the RN message bridge never comes up), no error message and
no native handler fires. Arm a 15s foreground-gated watchdog per document
generation that paints the fatal overlay when web-ready never arrives;
first fatal diagnostics win over later cascades. Extract the watchdog and
the public contract types to keep TerminalWebView under the line cap, and
document the SVG xmlns percent-encoding transform.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-02 21:43:06 -07:00
co-authored by Orca
parent f531948880
commit b6deec5a67
6 changed files with 201 additions and 64 deletions
@@ -85,6 +85,10 @@ async function main() {
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
...packages.map(readPackageVersion)
])
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
// terminal document. These xmlns URIs live inside data: URLs (never fetched);
// percent-encoding the scheme colon satisfies the gate and URI-decodes back
// before the SVG is parsed.
const engineCss = rawEngineCss
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
+21 -63
View File
@@ -2,78 +2,29 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMem
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type {
MobileTerminalTheme,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
useTerminalWebViewEngineErrorState
} from './terminal-webview-engine-error-state'
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
export type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
type Props = {
style?: StyleProp<ViewStyle>
@@ -122,6 +73,10 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
@@ -154,6 +109,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
if (msg.type === 'web-ready') {
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
onWebReady?.()
flushPendingMessages()
@@ -254,6 +210,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
[
flushPendingMessages,
clearEngineError,
clearWebReadyWatchdog,
reportEngineError,
onWebReady,
onSelectionMode,
@@ -272,10 +229,11 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
pendingMessages.clear()
}, [pendingMessages])
}, [armWebReadyWatchdog, pendingMessages])
const handleReload = useCallback(() => {
clearEngineError()
@@ -0,0 +1,64 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
@@ -29,7 +29,10 @@ export function useTerminalWebViewEngineErrorState(onEngineError?: (message: str
// eslint-disable-next-line no-console
console.warn('[terminal-webview] engine error', message)
if (fatal) {
setEngineError(message)
// Why: the first fatal report is the root cause; later cascades (e.g. the
// web-ready watchdog firing after a process-crash report) must not
// overwrite its more specific diagnostics. clearEngineError resets.
setEngineError((previous) => previous ?? message)
}
},
[onEngineError]
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: {
@@ -104,4 +105,63 @@ describe('TerminalWebView engine errors', () => {
expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk')
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
})
it('keeps the first fatal diagnostics when later fatal reports cascade', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: true,
message: 'terminal engine missing - root cause',
type: 'error'
})
postWebViewMessage(renderer, {
fatal: true,
message: 'Terminal did not initialize - watchdog cascade',
type: 'error'
})
expect(renderedText(renderer)).toContain('terminal engine missing - root cause')
expect(renderedText(renderer)).not.toContain('watchdog cascade')
})
// Why: if the document dies before the glue can post anything (or the RN
// bridge never comes up), no message and no native error handler fires —
// the watchdog must convert that silence into the visible fatal overlay.
it('paints the fatal overlay when web-ready never arrives', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
act(() => {
vi.advanceTimersByTime(15000)
})
expect(onEngineError).toHaveBeenCalledWith(
'Terminal did not initialize - no ready signal from the terminal view'
)
expect(renderedText(renderer)).toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
it('does not fire the watchdog once web-ready has arrived', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, { type: 'web-ready' })
act(() => {
vi.advanceTimersByTime(60000)
})
expect(onEngineError).not.toHaveBeenCalled()
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { AppState } from 'react-native'
// Why: if the document dies before the glue can post anything (or the RN
// message bridge never comes up), no webview error and no native handler
// fires — without a native watchdog that failure is a silent blank pane.
const WEB_READY_WATCHDOG_MS = 15000
export function useTerminalWebReadyWatchdog(
isWebReadyRef: RefObject<boolean>,
reportEngineError: (message: string, fatal: boolean) => void
) {
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearWebReadyWatchdog = useCallback(() => {
if (watchdogRef.current) {
clearTimeout(watchdogRef.current)
watchdogRef.current = null
}
}, [])
const armWebReadyWatchdog = useCallback(() => {
clearWebReadyWatchdog()
const fire = () => {
watchdogRef.current = null
if (isWebReadyRef.current) {
return
}
if (AppState.currentState !== 'active') {
// Why: backgrounded WebViews legitimately stall; only judge foreground loads.
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
return
}
reportEngineError(
'Terminal did not initialize - no ready signal from the terminal view',
true
)
}
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
}, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError])
useEffect(() => {
armWebReadyWatchdog()
return clearWebReadyWatchdog
}, [armWebReadyWatchdog, clearWebReadyWatchdog])
return { armWebReadyWatchdog, clearWebReadyWatchdog }
}