mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user