mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Merge branch 'fix2-lifecycle-merge' into mobile-rearch
# Conflicts: # mobile/src/mobile-web/mobile-web-capability-broker.ts
This commit is contained in:
+14
-14
@@ -13,6 +13,7 @@ import {
|
||||
useMobileWebCapabilityBroker,
|
||||
type MobileWebBrokerPageIdentity
|
||||
} from '../src/mobile-web/use-mobile-web-capability-broker'
|
||||
import { useMobileWebPageDocument } from '../src/mobile-web/use-mobile-web-page-document'
|
||||
import { MOBILE_WEB_PRODUCTION_GRANTS } from '../src/mobile-web/mobile-web-production-grants'
|
||||
import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-deadline'
|
||||
import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session'
|
||||
@@ -49,7 +50,6 @@ export default function HybridScreen() {
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const viewRef = useRef<MobileWebShellViewRef>(null)
|
||||
const activeSessionIdRef = useRef<string | undefined>(undefined)
|
||||
const initializedSessionRef = useRef<string | undefined>(undefined)
|
||||
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
|
||||
const brokerRef = useRef<MobileWebCapabilityBroker | null>(null)
|
||||
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
|
||||
@@ -60,7 +60,6 @@ export default function HybridScreen() {
|
||||
)
|
||||
const { hosts, hostsLoading, hostLoadError, refreshHosts } = useMobileWebHostCatalog()
|
||||
const [selectedHostId, setSelectedHostId] = useState<string | undefined>(params.hostId)
|
||||
const [pageReadySessionId, setPageReadySessionId] = useState<string>()
|
||||
const [brokerSessionId, setBrokerSessionId] = useState<string>()
|
||||
const [hostedViewActive, setHostedViewActive] = useState(true)
|
||||
const selectHost = useCallback((hostId: string | undefined) => setSelectedHostId(hostId), [])
|
||||
@@ -140,14 +139,12 @@ export default function HybridScreen() {
|
||||
}
|
||||
}, [params.hostId])
|
||||
|
||||
// A view-epoch bump replaces the document, so every page-scoped grant retires with it.
|
||||
useEffect(() => {
|
||||
initializedSessionRef.current = undefined
|
||||
nativeRouteHandoffRef.current.clear()
|
||||
setPageReadySessionId(undefined)
|
||||
healthDeadlineRef.current.clear()
|
||||
return () => healthDeadlineRef.current.clear()
|
||||
}, [session?.sessionId, viewEpoch])
|
||||
const pageDocument = useMobileWebPageDocument({
|
||||
sessionId: session?.sessionId,
|
||||
viewEpoch,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef: nativeRouteHandoffRef
|
||||
})
|
||||
|
||||
const postToWeb = useCallback(async (message: MobileWebBridgeShellMessage) => {
|
||||
if (responseDropRef.current.shouldDrop(message)) {
|
||||
@@ -229,6 +226,7 @@ export default function HybridScreen() {
|
||||
sessionId: session?.sessionId,
|
||||
buildId: session?.buildId,
|
||||
viewEpoch,
|
||||
documentEpoch: pageDocument.epoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange: setBrokerSessionId
|
||||
@@ -243,7 +241,7 @@ export default function HybridScreen() {
|
||||
if (!current || activeSessionIdRef.current !== current.sessionId || !brokerRef.current) {
|
||||
return
|
||||
}
|
||||
initializedSessionRef.current = current.sessionId
|
||||
pageDocument.initializedSessionRef.current = current.sessionId
|
||||
healthDeadlineRef.current.arm(current.sessionId, (sessionId) => {
|
||||
if (activeSessionIdRef.current === sessionId) {
|
||||
void onHealthTimeout(sessionId)
|
||||
@@ -269,7 +267,7 @@ export default function HybridScreen() {
|
||||
useEffect(() => {
|
||||
brokerRef.current?.updateConnectionState(mobileWebBridgeConnectionState(state))
|
||||
const current = session
|
||||
if (!current || initializedSessionRef.current !== current.sessionId) {
|
||||
if (!current || pageDocument.initializedSessionRef.current !== current.sessionId) {
|
||||
return
|
||||
}
|
||||
void postToWeb({
|
||||
@@ -301,7 +299,7 @@ export default function HybridScreen() {
|
||||
if (parsed.value.type === 'ready') {
|
||||
// `ready` acknowledges init; echoing init here starves the health frame.
|
||||
if (activeSessionIdRef.current === current.sessionId) {
|
||||
setPageReadySessionId(current.sessionId)
|
||||
pageDocument.setReadySessionId(current.sessionId)
|
||||
}
|
||||
} else if (!backMessageHandled) {
|
||||
if (parsed.value.type === 'health') {
|
||||
@@ -356,7 +354,7 @@ export default function HybridScreen() {
|
||||
selectedHostId,
|
||||
connectionState: state,
|
||||
shellContext,
|
||||
pageReadySessionId,
|
||||
pageReadySessionId: pageDocument.readySessionId,
|
||||
brokerSessionId,
|
||||
getBroker,
|
||||
selectHost,
|
||||
@@ -394,7 +392,9 @@ export default function HybridScreen() {
|
||||
showWarning('That didn’t work. Try again.', 'recovery_action_failed')
|
||||
}
|
||||
onBridgeMessage={(message) => void handleBridgeMessage(message)}
|
||||
onDocumentLoadStarted={pageDocument.onLoadStart}
|
||||
onPageLoaded={() => {
|
||||
pageDocument.onLoaded()
|
||||
hardwareBackHandoff.resetPage()
|
||||
void postInit()
|
||||
}}
|
||||
|
||||
+2
@@ -94,6 +94,8 @@ class ExpoMobileWebShellModule : Module() {
|
||||
|
||||
OnViewDestroys { view ->
|
||||
sessionViews.entries.removeAll { it.value === view }
|
||||
// Deregister first: nothing may reach a WebView whose renderer process is being torn down.
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
AsyncFunction("activateSessionView") { view: MobileWebShellView, sessionId: String ->
|
||||
|
||||
+37
-1
@@ -2,6 +2,7 @@ package expo.modules.mobilewebshell
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
@@ -76,6 +77,7 @@ internal class MobileWebShellView(
|
||||
private var networkBlockerScriptHandler: ScriptHandler? = null
|
||||
private var debugProbeScriptHandler: ScriptHandler? = null
|
||||
private var bridgeMessageListenerAttached = false
|
||||
private var destroyed = false
|
||||
private var webView: WebView
|
||||
|
||||
init {
|
||||
@@ -125,6 +127,7 @@ internal class MobileWebShellView(
|
||||
}
|
||||
|
||||
fun setSessionId(sessionId: String?) {
|
||||
if (destroyed) return
|
||||
if (sessionId == null) {
|
||||
deactivateSessionView()
|
||||
return
|
||||
@@ -158,6 +161,7 @@ internal class MobileWebShellView(
|
||||
}
|
||||
|
||||
fun deactivateSessionView() {
|
||||
if (destroyed) return
|
||||
removeBridgeMessageListener()
|
||||
networkBlockerScriptHandler?.remove()
|
||||
networkBlockerScriptHandler = null
|
||||
@@ -176,6 +180,7 @@ internal class MobileWebShellView(
|
||||
require(message.toByteArray(Charsets.UTF_8).size <= MOBILE_WEB_MESSAGE_BYTE_LIMIT) {
|
||||
"mobile_web_bridge_message_too_large"
|
||||
}
|
||||
if (destroyed) return
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(function(value){
|
||||
@@ -196,14 +201,36 @@ internal class MobileWebShellView(
|
||||
if (activeSessionId == sessionId) postMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaching only parks the WebView — a deactivated view is reactivated in place — so the renderer
|
||||
* process, the JavaScript context and both script handlers only go away here. Expo calls this once
|
||||
* the view is unmounted, which is what a package swap, a host switch and a route exit all do.
|
||||
*/
|
||||
fun destroy() {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
removeBridgeMessageListener()
|
||||
networkBlockerScriptHandler?.remove()
|
||||
networkBlockerScriptHandler = null
|
||||
debugProbeScriptHandler?.remove()
|
||||
debugProbeScriptHandler = null
|
||||
activeSessionId = null
|
||||
documentLoaded = false
|
||||
webView.stopLoading()
|
||||
webView.loadUrl("about:blank")
|
||||
removeView(webView)
|
||||
webView.destroy()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
removeBridgeMessageListener()
|
||||
webView.stopLoading()
|
||||
if (!destroyed) webView.stopLoading()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (destroyed) return
|
||||
attachWebView()
|
||||
val sessionId = activeSessionId ?: return
|
||||
addBridgeMessageListener(sessionId)
|
||||
@@ -284,6 +311,15 @@ internal class MobileWebShellView(
|
||||
return !allowed
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
// The page can replace its own document (the route error boundary reloads on a failed chunk),
|
||||
// and that is the only signal the shell gets. Every load start is reported so the shell can
|
||||
// retire the outgoing page's grants before the new document initializes.
|
||||
if (!isAllowedDocumentRequestUrl(Uri.parse(url))) return
|
||||
documentLoaded = false
|
||||
onLoadState(mapOf("state" to "loading"))
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (isAllowedDocumentUrl(Uri.parse(url))) {
|
||||
documentLoaded = true
|
||||
|
||||
@@ -566,6 +566,14 @@ final class MobileWebShellView: ExpoView, WKNavigationDelegate, WKUIDelegate,
|
||||
decisionHandler(allowed ? .allow : .cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
// The page can replace its own document (the route error boundary reloads on a failed chunk),
|
||||
// and that is the only signal the shell gets. Every load start is reported so the shell can
|
||||
// retire the outgoing page's grants before the new document initializes.
|
||||
guard activeSessionId != nil else { return }
|
||||
onLoadState(["state": "loading"])
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard isAllowedDocumentUrl(webView.url) else { return }
|
||||
onLoadState(["state": "loaded"])
|
||||
|
||||
@@ -35,6 +35,7 @@ type MobileWebHybridShellPresentationProps = {
|
||||
onClearCache: () => void | Promise<void>
|
||||
onRecoveryFailure: () => void
|
||||
onBridgeMessage: (message: string) => void
|
||||
onDocumentLoadStarted: () => void
|
||||
onPageLoaded: () => void
|
||||
onLoadFailed: (reason: string | undefined) => void
|
||||
onNavigationBlocked: () => void
|
||||
@@ -57,6 +58,7 @@ export function MobileWebHybridShellPresentation({
|
||||
onClearCache,
|
||||
onRecoveryFailure,
|
||||
onBridgeMessage,
|
||||
onDocumentLoadStarted,
|
||||
onPageLoaded,
|
||||
onLoadFailed,
|
||||
onNavigationBlocked,
|
||||
@@ -141,6 +143,12 @@ export function MobileWebHybridShellPresentation({
|
||||
sessionId={hostedViewActive ? session.sessionId : null}
|
||||
onBridgeMessage={(event) => onBridgeMessage(event.nativeEvent.data)}
|
||||
onLoadState={(event) => {
|
||||
// A load only ever starts because the document is being replaced, and the outgoing
|
||||
// page's grants have to retire before the incoming one initializes.
|
||||
if (event.nativeEvent.state === 'loading') {
|
||||
onDocumentLoadStarted()
|
||||
return
|
||||
}
|
||||
if (event.nativeEvent.state === 'loaded') {
|
||||
onPageLoaded()
|
||||
return
|
||||
|
||||
@@ -7,6 +7,8 @@ import { handleMobileWebBrokerMessage } from './mobile-web-broker-message-handof
|
||||
import { MobileWebNativeRouteHandoff } from './mobile-web-native-route-handoff'
|
||||
import { MOBILE_WEB_PRODUCTION_NAVIGATION_GRANTS } from './mobile-web-production-navigation-grants'
|
||||
import { useMobileWebNavigationAuthority } from './use-mobile-web-navigation-authority'
|
||||
import { leaveHostRoute } from '../host-route-exit'
|
||||
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
|
||||
import type { MobileWebShellViewRef } from '@orca/expo-mobile-web-shell'
|
||||
import type { MobileWebNavigationAuthority } from './mobile-web-navigation-operations'
|
||||
|
||||
@@ -73,6 +75,21 @@ describe('hosted network diagnostics route', () => {
|
||||
expect(deactivateSessionView).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('leaves the hosted route before the unpair deletes the package cache it serves', async () => {
|
||||
const order: string[] = []
|
||||
vi.mocked(leaveHostRoute).mockImplementation(() => {
|
||||
order.push('leave-route')
|
||||
})
|
||||
vi.mocked(removeHostAndCloseClient).mockImplementation(async () => {
|
||||
order.push('remove-host')
|
||||
})
|
||||
const authority = renderNavigationAuthority(new MobileWebNativeRouteHandoff())
|
||||
|
||||
await authority.current?.removeHost()
|
||||
|
||||
expect(order).toEqual(['leave-route', 'remove-host'])
|
||||
})
|
||||
|
||||
it('leaves the host picker exit on the page-local route path', () => {
|
||||
const routeHandoff = new MobileWebNativeRouteHandoff()
|
||||
const authority = renderNavigationAuthority(routeHandoff)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ANDROID_VIEW = source(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt'
|
||||
)
|
||||
const ANDROID_MODULE = source(
|
||||
'../../packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/ExpoMobileWebShellModule.kt'
|
||||
)
|
||||
const IOS_VIEW = source('../../packages/expo-mobile-web-shell/ios/MobileWebShellView.swift')
|
||||
|
||||
describe('hosted WebView document lifecycle', () => {
|
||||
// The shell owns the document lifecycle, but a page can replace its own document — the route
|
||||
// error boundary reloads in place — and the load start is the only signal that reaches the shell.
|
||||
it('reports a document load the page itself starts, on both platforms', () => {
|
||||
expect(block(ANDROID_VIEW, 'override fun onPageStarted')).toContain(
|
||||
'onLoadState(mapOf("state" to "loading"))'
|
||||
)
|
||||
expect(
|
||||
block(IOS_VIEW, 'func webView(_ webView: WKWebView, didStartProvisionalNavigation')
|
||||
).toContain('onLoadState(["state": "loading"])')
|
||||
})
|
||||
|
||||
// Deactivation parks the WebView for a reactivation in place, so only an unmount may destroy it.
|
||||
it('destroys the Android WebView on unmount and never on deactivation', () => {
|
||||
expect(block(ANDROID_MODULE, 'OnViewDestroys')).toContain('view.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'fun destroy()')).toContain('webView.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'fun deactivateSessionView()')).not.toContain('webView.destroy()')
|
||||
expect(block(ANDROID_VIEW, 'override fun onDetachedFromWindow()')).not.toContain(
|
||||
'webView.destroy()'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function source(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
/** The declaration through the brace that closes it, so a match cannot leak into the next member. */
|
||||
function block(text: string, header: string): string {
|
||||
const start = text.indexOf(header)
|
||||
expect(start, `missing ${header}`).toBeGreaterThanOrEqual(0)
|
||||
const indent = text.slice(0, start).split('\n').at(-1) ?? ''
|
||||
const end = text.indexOf(`\n${indent}}`, start)
|
||||
return text.slice(start, end === -1 ? undefined : end)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ describe('mobile native shell route ownership', () => {
|
||||
const readyBranch = hybridShell.match(
|
||||
/if \(parsed\.value\.type === 'ready'\) \{([\s\S]*?)\} else if/
|
||||
)?.[1]
|
||||
expect(readyBranch).toContain('setPageReadySessionId(current.sessionId)')
|
||||
expect(readyBranch).toContain('setReadySessionId(current.sessionId)')
|
||||
expect(readyBranch).not.toContain('postInit')
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MobileWebCommitMessageGeneration } from './mobile-web-commit-message-ge
|
||||
import { MobileWebCapabilitySubscriptions } from './mobile-web-capability-subscriptions'
|
||||
import { MOBILE_WEB_PRODUCTION_GRANT_INDEX } from './mobile-web-production-grants'
|
||||
import { MobileWebTerminalStreams } from './mobile-web-terminal-streams'
|
||||
import { MOBILE_WEB_TERMINAL_CLIENT_CLOSURE } from './mobile-web-terminal-stream-retirement'
|
||||
import { MobileWebSpeechAuthority } from './mobile-web-speech-authority'
|
||||
import { executeMobileWebCapabilityRequest } from './mobile-web-capability-execution'
|
||||
import { MobileWebCapabilityAuthorities } from './mobile-web-capability-authorities'
|
||||
@@ -74,8 +75,8 @@ export class MobileWebCapabilityBroker {
|
||||
onFlowMetrics: options.onTerminalFlowMetrics,
|
||||
onResync: options.onTerminalResync,
|
||||
workspaceAuthority: this.authorities.workspace,
|
||||
postEvent: (subscriptionId, sequence, event) =>
|
||||
this.messages.event(subscriptionId, sequence, event)
|
||||
postEvent: this.messages.event.bind(this.messages),
|
||||
postClosed: mobileWebSubscriptionClosedPoster(this.messages)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ export class MobileWebCapabilityBroker {
|
||||
// The page document outlives the swap, so every live subscription needs a terminal frame; a
|
||||
// silent teardown leaves it waiting on a feed the new client will never resume.
|
||||
this.subscriptions.closeAll({ code: 'unavailable', retryable: true })
|
||||
this.terminalStreams.dispose(null)
|
||||
this.terminalStreams.dispose(null, MOBILE_WEB_TERMINAL_CLIENT_CLOSURE)
|
||||
this.speechAuthority.replaceClient()
|
||||
for (const [requestId, pending] of this.pending) {
|
||||
if (survivesCancellation(pending)) {
|
||||
@@ -244,8 +245,7 @@ export class MobileWebCapabilityBroker {
|
||||
sourceControlSubscriptions: this.subscriptions.sourceControl,
|
||||
sourceControlBranchCompare: this.authorities.sourceControlBranchCompare,
|
||||
speechAuthority: this.speechAuthority,
|
||||
postSpeechEvent: (subscriptionId, sequence, event) =>
|
||||
this.messages.event(subscriptionId, sequence, event),
|
||||
postSpeechEvent: this.messages.event.bind(this.messages),
|
||||
postSpeechClosed: mobileWebSubscriptionClosedPoster(this.messages),
|
||||
workspaceSubscriptions: this.subscriptions.workspace,
|
||||
terminalStreams: this.terminalStreams,
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createElement, useRef, type FunctionComponent } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
type MobileWebBridgePageMessage,
|
||||
type MobileWebBridgeShellMessage
|
||||
} from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
|
||||
import { MobileWebHealthDeadline } from './mobile-web-health-deadline'
|
||||
import { MobileWebHybridShellPresentation } from './MobileWebHybridShellPresentation'
|
||||
import { MobileWebNativeRouteHandoff } from './mobile-web-native-route-handoff'
|
||||
import { useMobileWebCapabilityBroker } from './use-mobile-web-capability-broker'
|
||||
import { useMobileWebPageDocument } from './use-mobile-web-page-document'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: Record<string, unknown>) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
MonitorSmartphone: 'MonitorSmartphone'
|
||||
}))
|
||||
vi.mock('@orca/expo-mobile-web-shell', () => ({ MobileWebShellView: 'MobileWebShellView' }))
|
||||
|
||||
const noop = (): void => {}
|
||||
const SESSION_ID = 'S'.repeat(43)
|
||||
const BUILD_ID = 'a'.repeat(64)
|
||||
|
||||
// A native-route excursion, the route error boundary's reload and a re-attach all replace the
|
||||
// document while the shell session, its build and the view epoch stand still. The previous page's
|
||||
// broker used to survive that, and its records held every per-operation grant (one for workspace),
|
||||
// so the new document was refused with rate_limited for the life of the shell session.
|
||||
describe('hosted document replacement', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let harness: ReturnType<typeof createHarness>
|
||||
|
||||
beforeEach(() => {
|
||||
harness = createHarness()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('re-subscribes after a native-route excursion reloads the page', async () => {
|
||||
await mount()
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
expect(harness.subscribe).toHaveBeenCalledOnce()
|
||||
|
||||
// The excursion deactivates the view (about:blank) and reactivates it on return; the page is
|
||||
// never unmounted, so the shell only ever sees the reload the reactivation starts.
|
||||
await setHostedViewActive(false)
|
||||
await setHostedViewActive(true)
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('re-subscribes after the page reloads itself in place', async () => {
|
||||
await mount()
|
||||
await loadDocument()
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
// What the route error boundary's recovery button does: same view, same session, new document.
|
||||
// The duplicate `loading` is what the shell really posts — once for the reload it starts and
|
||||
// once for the navigation the page started — and the pair is still a single new page.
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.created).toBe(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('never retires the broker that is about to serve the first document', async () => {
|
||||
await mount()
|
||||
const live = harness.brokerRef.current
|
||||
|
||||
// The shell posts `loading` for the first load too, and more than once for the same load. A
|
||||
// page that has not loaded yet has nothing to retire, and retiring here would drop the grants
|
||||
// out from under the document that is loading.
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
expect(harness.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(harness.created).toBe(1)
|
||||
expect(harness.brokerRef.current).toBe(live)
|
||||
expect(errorFor(harness.messages, 'A')).toEqual([])
|
||||
})
|
||||
|
||||
it('is the wiring the hosted screen uses', () => {
|
||||
// The harness above composes the two seams by hand; this pins the screen to the same pair.
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('../../app/hybrid.tsx', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(source).toContain('documentEpoch: pageDocument.epoch')
|
||||
expect(source).toContain('onDocumentLoadStarted={pageDocument.onLoadStart}')
|
||||
expect(source).toContain('pageDocument.onLoaded()')
|
||||
})
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Shell, { hostedViewActive: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setHostedViewActive(hostedViewActive: boolean): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Shell, { hostedViewActive }))
|
||||
})
|
||||
}
|
||||
|
||||
async function loadDocument(): Promise<void> {
|
||||
await emitLoadState({ state: 'loading' })
|
||||
await emitLoadState({ state: 'loaded' })
|
||||
}
|
||||
|
||||
async function emitLoadState(nativeEvent: { state: string; reason?: string }): Promise<void> {
|
||||
const shell = renderer!.root.findByType('MobileWebShellView' as never)
|
||||
await act(async () => {
|
||||
shell.props.onLoadState({ nativeEvent })
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(message: MobileWebBridgePageMessage): Promise<void> {
|
||||
await act(async () => {
|
||||
await harness.brokerRef.current?.handle(message)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness() {
|
||||
const messages: MobileWebBridgeShellMessage[] = []
|
||||
const unsubscribe = vi.fn()
|
||||
const subscribe = vi.fn<RpcClient['subscribe']>(() => unsubscribe)
|
||||
const client = {
|
||||
sendRequest: vi.fn<RpcClient['sendRequest']>(),
|
||||
subscribe,
|
||||
sendTerminalBinaryFrame: vi.fn(() => true)
|
||||
} as unknown as RpcClient
|
||||
const brokerRef: { current: MobileWebCapabilityBroker | null } = { current: null }
|
||||
// Stable like hybrid.tsx's useCallback: a fresh identity per render would retire the broker on
|
||||
// every render and hide whether the document epoch is doing the work.
|
||||
const createBroker = (page: { sessionId: string; buildId: string }) => {
|
||||
state.created += 1
|
||||
return new MobileWebCapabilityBroker({
|
||||
context: { shellSessionId: page.sessionId, buildId: page.buildId },
|
||||
getClient: () => client,
|
||||
isConnected: () => true,
|
||||
isActive: () => true,
|
||||
postMessage: (message) => void messages.push(message),
|
||||
nativeAuthority: {
|
||||
clipboardAvailability: vi.fn(),
|
||||
hapticFeedback: vi.fn(),
|
||||
clipboardWrite: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
terminalPreferences: vi.fn(),
|
||||
terminalTextScaleUpdate: vi.fn()
|
||||
},
|
||||
terminalClientId: 'device-token',
|
||||
randomBytes: (length) => new Uint8Array(length).fill(1)
|
||||
})
|
||||
}
|
||||
const state = {
|
||||
messages,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
brokerRef,
|
||||
created: 0,
|
||||
Shell: undefined as unknown as FunctionComponent<{ hostedViewActive: boolean }>
|
||||
}
|
||||
state.Shell = ({ hostedViewActive }) => {
|
||||
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
|
||||
const routeHandoffRef = useRef(new MobileWebNativeRouteHandoff())
|
||||
const pageDocument = useMobileWebPageDocument({
|
||||
sessionId: SESSION_ID,
|
||||
viewEpoch: 0,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef
|
||||
})
|
||||
useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId: SESSION_ID,
|
||||
buildId: BUILD_ID,
|
||||
viewEpoch: 0,
|
||||
documentEpoch: pageDocument.epoch,
|
||||
createBroker,
|
||||
onBrokerReady: noop,
|
||||
onBrokerSessionChange: noop
|
||||
})
|
||||
return createElement(MobileWebHybridShellPresentation, {
|
||||
viewRef: { current: null },
|
||||
selectedHost: { id: 'host-1', name: 'Desk', publicKeyB64: 'k' } as never,
|
||||
session: { sessionId: SESSION_ID, buildId: BUILD_ID } as never,
|
||||
viewEpoch: 0,
|
||||
packageLoading: false,
|
||||
packageProgress: undefined,
|
||||
packageWarning: undefined,
|
||||
hostedViewActive,
|
||||
onBack: noop,
|
||||
onShowHosts: noop,
|
||||
onRetryRecovery: noop,
|
||||
onUsePrevious: noop,
|
||||
onClearCache: noop,
|
||||
onRecoveryFailure: noop,
|
||||
onBridgeMessage: noop,
|
||||
onDocumentLoadStarted: pageDocument.onLoadStart,
|
||||
onPageLoaded: pageDocument.onLoaded,
|
||||
onLoadFailed: noop,
|
||||
onNavigationBlocked: noop,
|
||||
onProcessTerminated: noop
|
||||
})
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function subscribeRequest(
|
||||
requestId: string,
|
||||
subscriptionId: string
|
||||
): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
|
||||
return {
|
||||
version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
|
||||
shellSessionId: SESSION_ID,
|
||||
buildId: BUILD_ID,
|
||||
type: 'request',
|
||||
mode: 'subscription',
|
||||
requestId: requestId.repeat(22),
|
||||
subscriptionId: subscriptionId.repeat(22),
|
||||
capability: 'workspace',
|
||||
operation: 'subscribe',
|
||||
payload: {}
|
||||
}
|
||||
}
|
||||
|
||||
function errorFor(messages: MobileWebBridgeShellMessage[], requestId: string) {
|
||||
return messages.flatMap((message) =>
|
||||
message.type === 'response' &&
|
||||
message.requestId === requestId.repeat(22) &&
|
||||
message.status === 'error'
|
||||
? [message.error]
|
||||
: []
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { withReconnectJitter } from '../../../src/shared/reconnect-jitter'
|
||||
|
||||
// Why: the refresh effect restarts from zero on every client swap and the staged bytes are dropped
|
||||
// with it, so a flapping link re-downloads the whole bundle back to back on cellular data. The delay
|
||||
// escalates per attempt that never reached a completed download and stops at a ceiling; a user-driven
|
||||
// retry starts the ladder over.
|
||||
const REFRESH_RETRY_BASE_DELAY_MS = 1_000
|
||||
const REFRESH_RETRY_MAX_DELAY_MS = 30_000
|
||||
|
||||
export function mobileWebPackageRefreshDelayMs(
|
||||
unfinishedAttempts: number,
|
||||
random: () => number = Math.random
|
||||
): number {
|
||||
if (unfinishedAttempts <= 0) {
|
||||
return 0
|
||||
}
|
||||
return withReconnectJitter(
|
||||
Math.min(
|
||||
REFRESH_RETRY_BASE_DELAY_MS * 2 ** (unfinishedAttempts - 1),
|
||||
REFRESH_RETRY_MAX_DELAY_MS
|
||||
),
|
||||
random
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves false when the wait was abandoned, so the caller drops the attempt instead of starting. */
|
||||
export function waitBeforeMobileWebPackageRefresh(
|
||||
delayMs: number,
|
||||
signal: AbortSignal
|
||||
): Promise<boolean> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
if (delayMs <= 0) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(true)
|
||||
}, delayMs)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -57,6 +57,7 @@ describe('hosted shell document load failures', () => {
|
||||
onClearCache: noop,
|
||||
onRecoveryFailure: noop,
|
||||
onBridgeMessage: noop,
|
||||
onDocumentLoadStarted: noop,
|
||||
onPageLoaded: loaded,
|
||||
onLoadFailed: (reason) => failures.push(reason),
|
||||
onNavigationBlocked: noop,
|
||||
|
||||
@@ -5,6 +5,14 @@ import {
|
||||
} from '../../../src/shared/mobile-web/terminal-stream-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import type {
|
||||
MobileWebPostSubscriptionClosed,
|
||||
MobileWebSubscriptionClosure
|
||||
} from './mobile-web-subscription-closure'
|
||||
import {
|
||||
MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE,
|
||||
MOBILE_WEB_TERMINAL_DELIVERY_CLOSURE
|
||||
} from './mobile-web-terminal-stream-retirement'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
|
||||
type LeaseSubscribeRequest = Extract<MobileWebTerminalRequest, { operation: 'subscribe' }>
|
||||
@@ -34,6 +42,7 @@ export class MobileWebTerminalLeaseStreams {
|
||||
sequence: number,
|
||||
event: MobileWebTerminalEvent
|
||||
) => Promise<void>
|
||||
postClosed: MobileWebPostSubscriptionClosed
|
||||
}
|
||||
) {}
|
||||
|
||||
@@ -85,12 +94,12 @@ export class MobileWebTerminalLeaseStreams {
|
||||
return this.records.has(streamId)
|
||||
}
|
||||
|
||||
cancel(subscriptionId: string): string | null {
|
||||
cancel(subscriptionId: string, closure?: MobileWebSubscriptionClosure): string | null {
|
||||
const record = this.records.get(subscriptionId)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
this.retire(record)
|
||||
this.retire(record, closure)
|
||||
return record.requestId
|
||||
}
|
||||
|
||||
@@ -102,9 +111,9 @@ export class MobileWebTerminalLeaseStreams {
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
dispose(closure?: MobileWebSubscriptionClosure): void {
|
||||
for (const subscriptionId of this.records.keys()) {
|
||||
this.cancel(subscriptionId)
|
||||
this.cancel(subscriptionId, closure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +122,14 @@ export class MobileWebTerminalLeaseStreams {
|
||||
}
|
||||
|
||||
private receive(subscriptionId: string, record: LeaseRecord, value: unknown): void {
|
||||
if (!this.isLive(subscriptionId, record) || !this.isAuthorized(record)) {
|
||||
if (!this.isLive(subscriptionId, record)) {
|
||||
this.retire(record)
|
||||
return
|
||||
}
|
||||
if (!this.isAuthorized(record)) {
|
||||
this.retire(record, MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE)
|
||||
return
|
||||
}
|
||||
if (!isRecord(value) || typeof value.type !== 'string') {
|
||||
this.post(
|
||||
record,
|
||||
@@ -183,7 +196,7 @@ export class MobileWebTerminalLeaseStreams {
|
||||
this.retire(record)
|
||||
}
|
||||
})
|
||||
.catch(() => this.retire(record))
|
||||
.catch(() => this.retire(record, MOBILE_WEB_TERMINAL_DELIVERY_CLOSURE))
|
||||
}
|
||||
|
||||
private isLive(subscriptionId: string, record: LeaseRecord): boolean {
|
||||
@@ -201,7 +214,7 @@ export class MobileWebTerminalLeaseStreams {
|
||||
}
|
||||
}
|
||||
|
||||
private retire(record: LeaseRecord): void {
|
||||
private retire(record: LeaseRecord, closure?: MobileWebSubscriptionClosure): void {
|
||||
if (!record.active) {
|
||||
return
|
||||
}
|
||||
@@ -212,6 +225,9 @@ export class MobileWebTerminalLeaseStreams {
|
||||
} catch {
|
||||
// The authenticated host subscription is already gone.
|
||||
}
|
||||
if (closure) {
|
||||
this.options.postClosed(record.pageStreamId, closure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { MobileWebTerminalStreamRecord } from './mobile-web-terminal-flow-control'
|
||||
import type {
|
||||
MobileWebPostSubscriptionClosed,
|
||||
MobileWebSubscriptionClosure
|
||||
} from './mobile-web-subscription-closure'
|
||||
import { safeUnsubscribeMobileWebTerminal } from './mobile-web-terminal-host-transport'
|
||||
import type { MobileWebTerminalStreamRegistry } from './mobile-web-terminal-stream-registry'
|
||||
|
||||
/** The page cannot observe a shell-side retirement: its terminal keeps a live entry, freezes on the
|
||||
* last frame it got and never re-subscribes. So every retirement the page did not ask for carries a
|
||||
* closure, and every retirement drops the host stream first — a retired record is unreachable from
|
||||
* the registry, so no later sweep can unsubscribe it. */
|
||||
export const MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE: MobileWebSubscriptionClosure = {
|
||||
code: 'not_found',
|
||||
retryable: false
|
||||
}
|
||||
export const MOBILE_WEB_TERMINAL_DELIVERY_CLOSURE: MobileWebSubscriptionClosure = {
|
||||
code: 'unavailable',
|
||||
retryable: true
|
||||
}
|
||||
/** A client swap keeps the page and its grants, so re-subscribing on the new transport works. */
|
||||
export const MOBILE_WEB_TERMINAL_CLIENT_CLOSURE: MobileWebSubscriptionClosure = {
|
||||
code: 'cancelled',
|
||||
retryable: true
|
||||
}
|
||||
|
||||
export class MobileWebTerminalStreamRetirement {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
registry: MobileWebTerminalStreamRegistry
|
||||
postClosed: MobileWebPostSubscriptionClosed
|
||||
}
|
||||
) {}
|
||||
|
||||
retire(
|
||||
record: MobileWebTerminalStreamRecord,
|
||||
client: RpcClient | null,
|
||||
closure?: MobileWebSubscriptionClosure
|
||||
): void {
|
||||
if (client) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
}
|
||||
this.options.registry.retire(record)
|
||||
if (closure) {
|
||||
this.options.postClosed(record.subscriptionId, closure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
TerminalStreamOpcode,
|
||||
type TerminalStreamFrame
|
||||
} from '../transport/terminal-stream-protocol'
|
||||
import type { MobileWebSubscriptionClosure } from './mobile-web-subscription-closure'
|
||||
import { MobileWebTerminalStreams } from './mobile-web-terminal-streams'
|
||||
import { MOBILE_WEB_TERMINAL_CLIENT_CLOSURE } from './mobile-web-terminal-stream-retirement'
|
||||
import {
|
||||
prepareMobileWebClipboardPaste,
|
||||
prepareMobileWebImageAttachment
|
||||
@@ -536,10 +538,159 @@ describe('MobileWebTerminalStreams', () => {
|
||||
expect(harness.streams.cancel(SUBSCRIPTION_ID, harness.client)).toBeNull()
|
||||
expect(harness.leaseUnsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Every shell-side retirement below used to be silent: the page kept a live terminal entry with
|
||||
// no error and no resubscribe, and some of them left the host PTY publishing into nothing.
|
||||
it('releases the host stream when the page session stops being active', async () => {
|
||||
let active = true
|
||||
const harness = createHarness({ isActive: () => active })
|
||||
const hostStreamId = await subscribeHostStream(harness, 'request-inactive')
|
||||
|
||||
active = false
|
||||
harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId })
|
||||
|
||||
expect(harness.sentFrames.at(-1)).toMatchObject({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: hostStreamId
|
||||
})
|
||||
// The shell drops every frame for an inactive page, so a closure would go nowhere.
|
||||
expect(harness.closures).toEqual([])
|
||||
expect(harness.streams.countForOperation('terminal.subscribe')).toBe(0)
|
||||
})
|
||||
|
||||
it('closes the page terminal when the workspace binding disappears under a host frame', async () => {
|
||||
const harness = createHarness()
|
||||
const hostStreamId = await subscribeHostStream(harness, 'request-frame-revoked')
|
||||
harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId })
|
||||
harness.workspaceAuthority.synchronize([])
|
||||
|
||||
expect(
|
||||
harness.emitFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId: hostStreamId,
|
||||
seq: 0,
|
||||
payload: encodeTerminalStreamText('hi')
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(harness.sentFrames.at(-1)).toMatchObject({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: hostStreamId
|
||||
})
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: { code: 'not_found', retryable: false } }
|
||||
])
|
||||
})
|
||||
|
||||
it('closes the page terminal when a multiplex event sweeps an unauthorized record', async () => {
|
||||
const harness = createHarness()
|
||||
const hostStreamId = await subscribeHostStream(harness, 'request-sweep-revoked')
|
||||
harness.workspaceAuthority.synchronize([])
|
||||
|
||||
harness.emitMultiplex({ type: 'ready' })
|
||||
|
||||
expect(harness.sentFrames.at(-1)).toMatchObject({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: hostStreamId
|
||||
})
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: { code: 'not_found', retryable: false } }
|
||||
])
|
||||
})
|
||||
|
||||
it('closes the page terminal when its own request finds the binding revoked', async () => {
|
||||
const harness = createHarness()
|
||||
const hostStreamId = await subscribeHostStream(harness, 'request-revoked-closure')
|
||||
harness.workspaceAuthority.synchronize([])
|
||||
|
||||
expect(() =>
|
||||
harness.streams.handle(
|
||||
{ operation: 'resize', streamId: SUBSCRIPTION_ID, viewport: { cols: 100, rows: 30 } },
|
||||
harness.client
|
||||
)
|
||||
).toThrow('not_found')
|
||||
|
||||
expect(harness.sentFrames.at(-1)).toMatchObject({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: hostStreamId
|
||||
})
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: { code: 'not_found', retryable: false } }
|
||||
])
|
||||
})
|
||||
|
||||
it('closes the page terminal when bridge delivery fails', async () => {
|
||||
const harness = createHarness({ postEvent: () => Promise.reject(new Error('bridge gone')) })
|
||||
const hostStreamId = await subscribeHostStream(harness, 'request-delivery-failure')
|
||||
|
||||
harness.emitMultiplex({ type: 'subscribed', streamId: hostStreamId })
|
||||
await settle()
|
||||
|
||||
expect(harness.sentFrames.at(-1)).toMatchObject({
|
||||
opcode: TerminalStreamOpcode.Unsubscribe,
|
||||
streamId: hostStreamId
|
||||
})
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: { code: 'unavailable', retryable: true } }
|
||||
])
|
||||
})
|
||||
|
||||
it('tells the page to re-subscribe when the transport is swapped underneath it', async () => {
|
||||
const harness = createHarness()
|
||||
await subscribeHostStream(harness, 'request-client-swap')
|
||||
|
||||
// What MobileWebCapabilityBroker.replaceClient passes: the old transport can no longer carry an
|
||||
// unsubscribe, so the page has to hear that its terminal is gone and ask again.
|
||||
harness.streams.dispose(null, MOBILE_WEB_TERMINAL_CLIENT_CLOSURE)
|
||||
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: MOBILE_WEB_TERMINAL_CLIENT_CLOSURE }
|
||||
])
|
||||
expect(harness.multiplexUnsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.streams.countForOperation('terminal.subscribe')).toBe(0)
|
||||
})
|
||||
|
||||
it('closes a lease stream whose workspace binding disappears', async () => {
|
||||
const harness = createHarness()
|
||||
await harness.streams.start({
|
||||
requestId: 'request-lease-revoked',
|
||||
subscriptionId: SUBSCRIPTION_ID,
|
||||
payload: { ...subscribePayload(), visible: false, leaseOnly: true },
|
||||
client: harness.client,
|
||||
isRequestActive: () => true
|
||||
})
|
||||
harness.workspaceAuthority.synchronize([])
|
||||
|
||||
harness.emitLease({ type: 'subscribed' })
|
||||
|
||||
expect(harness.leaseUnsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.closures).toEqual([
|
||||
{ subscriptionId: SUBSCRIPTION_ID, closure: { code: 'not_found', retryable: false } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function createHarness() {
|
||||
async function subscribeHostStream(
|
||||
harness: ReturnType<typeof createHarness>,
|
||||
requestId: string
|
||||
): Promise<number> {
|
||||
await harness.streams.start({
|
||||
requestId,
|
||||
subscriptionId: SUBSCRIPTION_ID,
|
||||
payload: subscribePayload(),
|
||||
client: harness.client,
|
||||
isRequestActive: () => true
|
||||
})
|
||||
harness.emitMultiplex({ type: 'ready' })
|
||||
return decodeTerminalStreamJson<Record<string, unknown>>(harness.sentFrames.at(-1)!.payload)!
|
||||
.streamId as number
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
overrides: { isActive?: () => boolean; postEvent?: () => Promise<void> } = {}
|
||||
) {
|
||||
const sentFrames: TerminalStreamFrame[] = []
|
||||
const closures: { subscriptionId: string; closure: MobileWebSubscriptionClosure }[] = []
|
||||
const events: { sequence: number; event: MobileWebTerminalEvent }[] = []
|
||||
const resyncReasons: string[] = []
|
||||
const flowMetrics: { ackLagMs: number | undefined; outstandingBytes: number }[] = []
|
||||
@@ -548,6 +699,7 @@ function createHarness() {
|
||||
let emitFrame = (_frame: TerminalStreamFrame): boolean => false
|
||||
let emitLease = (_result: unknown): void => {}
|
||||
const leaseUnsubscribe = vi.fn()
|
||||
const multiplexUnsubscribe = vi.fn()
|
||||
const workspaceAuthority = new MobileWebWorkspaceAuthority(() => new Uint8Array(16).fill(9))
|
||||
workspaceAuthority.synchronize([{ workspaceId: HOST_WORKSPACE_ID, repoId: '/secret/repo' }])
|
||||
const client = {
|
||||
@@ -572,7 +724,7 @@ function createHarness() {
|
||||
}
|
||||
emitMultiplex = listener
|
||||
emitFrame = options?.onTerminalBinaryFrame ?? emitFrame
|
||||
return vi.fn()
|
||||
return multiplexUnsubscribe
|
||||
}),
|
||||
sendTerminalBinaryFrame: vi.fn((frame) => {
|
||||
sentFrames.push(frame)
|
||||
@@ -580,7 +732,7 @@ function createHarness() {
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
const streams = new MobileWebTerminalStreams({
|
||||
isActive: () => true,
|
||||
isActive: overrides.isActive ?? (() => true),
|
||||
clientId: 'device-secret',
|
||||
now: () => nowMs,
|
||||
onFlowMetrics: (metrics) => flowMetrics.push(metrics),
|
||||
@@ -588,7 +740,9 @@ function createHarness() {
|
||||
workspaceAuthority,
|
||||
postEvent: async (_subscriptionId, sequence, event) => {
|
||||
events.push({ sequence, event })
|
||||
}
|
||||
await overrides.postEvent?.()
|
||||
},
|
||||
postClosed: (subscriptionId, closure) => closures.push({ subscriptionId, closure })
|
||||
})
|
||||
return {
|
||||
streams,
|
||||
@@ -605,7 +759,9 @@ function createHarness() {
|
||||
emitMultiplex: (result: unknown) => emitMultiplex(result),
|
||||
emitFrame: (frame: TerminalStreamFrame) => emitFrame(frame),
|
||||
emitLease: (result: unknown) => emitLease(result),
|
||||
leaseUnsubscribe
|
||||
leaseUnsubscribe,
|
||||
multiplexUnsubscribe,
|
||||
closures
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
type TerminalStreamFrame
|
||||
} from '../transport/terminal-stream-protocol'
|
||||
import { MobileWebBrokerError } from './mobile-web-broker-error'
|
||||
import type {
|
||||
MobileWebPostSubscriptionClosed,
|
||||
MobileWebSubscriptionClosure
|
||||
} from './mobile-web-subscription-closure'
|
||||
import { runMobileWebTerminalAction } from './mobile-web-terminal-actions'
|
||||
import {
|
||||
acknowledgeMobileWebTerminalOutput,
|
||||
@@ -16,13 +20,17 @@ import {
|
||||
type MobileWebTerminalStreamRecord
|
||||
} from './mobile-web-terminal-flow-control'
|
||||
import {
|
||||
safeUnsubscribeMobileWebTerminal,
|
||||
sendMobileWebTerminalFrame,
|
||||
sendMobileWebTerminalSubscribe
|
||||
} from './mobile-web-terminal-host-transport'
|
||||
import { handleMobileWebTerminalMultiplexEvent } from './mobile-web-terminal-multiplex-events'
|
||||
import { resolveMobileWebTerminal } from './mobile-web-terminal-resolution'
|
||||
import { MobileWebTerminalStreamRegistry } from './mobile-web-terminal-stream-registry'
|
||||
import {
|
||||
MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE,
|
||||
MOBILE_WEB_TERMINAL_DELIVERY_CLOSURE,
|
||||
MobileWebTerminalStreamRetirement
|
||||
} from './mobile-web-terminal-stream-retirement'
|
||||
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
|
||||
import { MobileWebTerminalLeaseStreams } from './mobile-web-terminal-lease-streams'
|
||||
import { sendMobileWebTerminalAckBytes } from './mobile-web-terminal-stream-control'
|
||||
@@ -35,6 +43,7 @@ import type {
|
||||
|
||||
export class MobileWebTerminalStreams {
|
||||
private readonly registry: MobileWebTerminalStreamRegistry
|
||||
private readonly retirement: MobileWebTerminalStreamRetirement
|
||||
private readonly leaseStreams: MobileWebTerminalLeaseStreams
|
||||
private disposeMultiplex: (() => void) | null = null
|
||||
private multiplexReady = false
|
||||
@@ -53,9 +62,14 @@ export class MobileWebTerminalStreams {
|
||||
sequence: number,
|
||||
event: MobileWebTerminalEvent
|
||||
) => Promise<void>
|
||||
postClosed: MobileWebPostSubscriptionClosed
|
||||
}
|
||||
) {
|
||||
this.registry = new MobileWebTerminalStreamRegistry(options.workspaceAuthority)
|
||||
this.retirement = new MobileWebTerminalStreamRetirement({
|
||||
registry: this.registry,
|
||||
postClosed: options.postClosed
|
||||
})
|
||||
this.leaseStreams = new MobileWebTerminalLeaseStreams(options)
|
||||
}
|
||||
|
||||
@@ -124,8 +138,7 @@ export class MobileWebTerminalStreams {
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
if (!this.isAuthorized(record)) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
this.registry.retire(record)
|
||||
this.retirement.retire(record, client, MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE)
|
||||
throw new MobileWebBrokerError('not_found')
|
||||
}
|
||||
if (
|
||||
@@ -165,10 +178,7 @@ export class MobileWebTerminalStreams {
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
if (client) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
}
|
||||
this.registry.retire(record)
|
||||
this.retirement.retire(record, client)
|
||||
return record.requestId
|
||||
}
|
||||
|
||||
@@ -185,12 +195,10 @@ export class MobileWebTerminalStreams {
|
||||
return operationKey === 'terminal.subscribe' ? this.registry.size + this.leaseStreams.size : 0
|
||||
}
|
||||
|
||||
dispose(client: RpcClient | null): void {
|
||||
this.leaseStreams.dispose()
|
||||
dispose(client: RpcClient | null, closure?: MobileWebSubscriptionClosure): void {
|
||||
this.leaseStreams.dispose(closure)
|
||||
for (const record of this.registry.records()) {
|
||||
if (client) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
}
|
||||
this.retirement.retire(record, client, closure)
|
||||
}
|
||||
this.registry.clear()
|
||||
this.disposeMultiplex?.()
|
||||
@@ -229,8 +237,7 @@ export class MobileWebTerminalStreams {
|
||||
return false
|
||||
}
|
||||
if (!this.isAuthorized(record)) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
this.registry.retire(record)
|
||||
this.retirement.retire(record, client, MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE)
|
||||
return true
|
||||
}
|
||||
handleMobileWebHostTerminalFrame(record, frame, this.flowContext(client))
|
||||
@@ -266,26 +273,26 @@ export class MobileWebTerminalStreams {
|
||||
|
||||
private post(record: MobileWebTerminalStreamRecord, event: MobileWebTerminalEvent): void {
|
||||
if (!this.options.isActive()) {
|
||||
this.registry.retire(record)
|
||||
// No closure: the shell drops every frame for an inactive page, so the host stream is all
|
||||
// there is left to release.
|
||||
this.retirement.retire(record, record.client)
|
||||
return
|
||||
}
|
||||
if (!this.isAuthorized(record)) {
|
||||
safeUnsubscribeMobileWebTerminal(record.client, record)
|
||||
this.registry.retire(record)
|
||||
this.retirement.retire(record, record.client, MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE)
|
||||
return
|
||||
}
|
||||
const sequence = record.bridgeSequence++
|
||||
record.delivery = record.delivery
|
||||
.then(() => this.options.postEvent(record.subscriptionId, sequence, event))
|
||||
.catch(() => {
|
||||
safeUnsubscribeMobileWebTerminal(record.client, record)
|
||||
this.registry.retire(record)
|
||||
this.retirement.retire(record, record.client, MOBILE_WEB_TERMINAL_DELIVERY_CLOSURE)
|
||||
})
|
||||
}
|
||||
|
||||
private retireUnauthorizedRecords(client: RpcClient): void {
|
||||
for (const record of this.registry.retireUnauthorized()) {
|
||||
safeUnsubscribeMobileWebTerminal(client, record)
|
||||
this.retirement.retire(record, client, MOBILE_WEB_TERMINAL_AUTHORITY_CLOSURE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,20 @@ describe('useMobileWebCapabilityBroker', () => {
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the previous page subscriptions when the document is replaced in place', async () => {
|
||||
await mount(0)
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness, { viewEpoch: 0, documentEpoch: 1 }))
|
||||
})
|
||||
await handle(subscribeRequest('B', 'Y'))
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(harness.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(errorFor(harness.messages, 'B')).toEqual([])
|
||||
})
|
||||
|
||||
it('retires the broker on demand so an in-place reload cannot inherit it', async () => {
|
||||
await mount(0)
|
||||
await handle(subscribeRequest('A', 'Z'))
|
||||
@@ -99,7 +113,10 @@ function createHarness() {
|
||||
created: 0,
|
||||
brokerSessionId: undefined as string | undefined,
|
||||
retireBroker: undefined as (() => void) | undefined,
|
||||
Harness: undefined as unknown as FunctionComponent<{ viewEpoch: number }>
|
||||
Harness: undefined as unknown as FunctionComponent<{
|
||||
viewEpoch: number
|
||||
documentEpoch?: number
|
||||
}>
|
||||
}
|
||||
const createBroker = (page: MobileWebBrokerPageIdentity): MobileWebCapabilityBroker => {
|
||||
state.created += 1
|
||||
@@ -124,12 +141,13 @@ function createHarness() {
|
||||
const onBrokerSessionChange = (sessionId: string | undefined): void => {
|
||||
state.brokerSessionId = sessionId
|
||||
}
|
||||
state.Harness = ({ viewEpoch }) => {
|
||||
state.Harness = ({ viewEpoch, documentEpoch = 0 }) => {
|
||||
const lifecycle = useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId: CONTEXT.shellSessionId,
|
||||
buildId: CONTEXT.buildId,
|
||||
viewEpoch,
|
||||
documentEpoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange
|
||||
|
||||
@@ -5,12 +5,15 @@ export type MobileWebBrokerPageIdentity = { sessionId: string; buildId: string }
|
||||
|
||||
// A view-epoch bump loads a fresh document over the same shell session, so the previous page's
|
||||
// broker (subscriptions, terminal streams, speech authority, replay window, rate limiter) has to
|
||||
// be retired before the new document can post against it.
|
||||
// be retired before the new document can post against it. A document epoch is the same boundary
|
||||
// without a native remount: the shell replaces the document in place on a native-route return, an
|
||||
// in-page reload and a re-attach, and each of those pages is just as new.
|
||||
export function useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
sessionId,
|
||||
buildId,
|
||||
viewEpoch,
|
||||
documentEpoch,
|
||||
createBroker,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange
|
||||
@@ -19,6 +22,7 @@ export function useMobileWebCapabilityBroker({
|
||||
sessionId: string | undefined
|
||||
buildId: string | undefined
|
||||
viewEpoch: number
|
||||
documentEpoch: number
|
||||
createBroker: (page: MobileWebBrokerPageIdentity) => MobileWebCapabilityBroker | null
|
||||
onBrokerReady: () => void
|
||||
onBrokerSessionChange: (sessionId: string | undefined) => void
|
||||
@@ -52,6 +56,7 @@ export function useMobileWebCapabilityBroker({
|
||||
brokerRef,
|
||||
buildId,
|
||||
createBroker,
|
||||
documentEpoch,
|
||||
onBrokerReady,
|
||||
onBrokerSessionChange,
|
||||
retireBroker,
|
||||
|
||||
@@ -51,6 +51,10 @@ export function useMobileWebNavigationAuthority({
|
||||
return forceReconnectHost(hostId)
|
||||
},
|
||||
removeHost() {
|
||||
// The hosted document is served out of the host's package cache that the unpair deletes,
|
||||
// so leave the route first instead of letting the hosts-list change unmount the view later.
|
||||
clearColdResumeRoute()
|
||||
leaveHostRoute(router)
|
||||
return removeHostAndCloseClient(hostId, hostPublicKeyB64, closeHostClient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,57 @@ describe('useMobileWebPackageRefresh', () => {
|
||||
expect(await reuse?.(BUILD_ID)).toBe(true)
|
||||
expect(native.openSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A flapping link aborts the download and drops every staged byte, so an unpaced restart
|
||||
// re-downloads the whole bundle over cellular data as fast as the link comes back.
|
||||
it('paces a restart that follows an attempt which never finished downloading', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
downloadPackage.mockImplementation(() => new Promise(() => {}))
|
||||
const props = {
|
||||
client: { sendRequest: vi.fn() } as unknown as RpcClient,
|
||||
host: HOST,
|
||||
state: 'connected',
|
||||
packageCapability: { status: 'supported', gzip: false },
|
||||
cachedBuildProbeRef: ref({
|
||||
hostEpoch: 1,
|
||||
promise: Promise.resolve(null),
|
||||
resolve: vi.fn()
|
||||
}),
|
||||
hostEpochRef: ref(1),
|
||||
ownedSessionRef: ref<OwnedSession | null>(null),
|
||||
rejectedBuildIdsRef: ref(new Set<string>()),
|
||||
refreshingHostEpochRef: ref<number | null>(null),
|
||||
publishSession: vi.fn(async () => true),
|
||||
refreshEpoch: 0,
|
||||
setPackageLoading: vi.fn(),
|
||||
setPackageWarning: vi.fn(),
|
||||
setPackageProgress: vi.fn()
|
||||
} as const
|
||||
function Harness({ swap }: { swap: number }): null {
|
||||
useMobileWebPackageRefresh({
|
||||
...props,
|
||||
client: { sendRequest: vi.fn(), swap } as unknown as RpcClient
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { swap: 0 }))
|
||||
})
|
||||
expect(downloadPackage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(Harness, { swap: 1 }))
|
||||
})
|
||||
expect(downloadPackage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_200)
|
||||
})
|
||||
expect(downloadPackage).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, type MutableRefObject } from 'react'
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import ExpoMobileWebShell, { type MobileWebShellSession } from '@orca/expo-mobile-web-shell'
|
||||
import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from '../../../src/shared/mobile-web/bridge-contract'
|
||||
import { MOBILE_WEB_PACKAGE_MAX_RANGE_BYTES } from '../../../src/shared/mobile-web/package-rpc-contract'
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
type MobileWebPackageDownloadProgress
|
||||
} from './mobile-web-package-downloader'
|
||||
import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store'
|
||||
import {
|
||||
mobileWebPackageRefreshDelayMs,
|
||||
waitBeforeMobileWebPackageRefresh
|
||||
} from './mobile-web-package-refresh-backoff'
|
||||
import type { MobileWebCachedBuildProbe } from './mobile-web-cached-build-probe'
|
||||
import { mobileWebPackageRefreshWarning } from './mobile-web-package-refresh-warning'
|
||||
import type { MobileWebShellNotice } from './mobile-web-shell-notice'
|
||||
@@ -56,6 +60,7 @@ export function useMobileWebPackageRefresh(args: {
|
||||
setPackageWarning,
|
||||
setPackageProgress
|
||||
} = args
|
||||
const retryRef = useRef({ hostId: '', refreshEpoch, attempts: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!host || !client || state !== 'connected' || packageCapability.status !== 'supported') {
|
||||
@@ -64,11 +69,20 @@ export function useMobileWebPackageRefresh(args: {
|
||||
const hostEpoch = hostEpochRef.current
|
||||
const cachedBuildProbe = cachedBuildProbeRef.current
|
||||
const controller = new AbortController()
|
||||
// A user-driven retry and a host change both start the backoff ladder over.
|
||||
if (retryRef.current.hostId !== host.id || retryRef.current.refreshEpoch !== refreshEpoch) {
|
||||
retryRef.current = { hostId: host.id, refreshEpoch, attempts: 0 }
|
||||
}
|
||||
const retryDelayMs = mobileWebPackageRefreshDelayMs(retryRef.current.attempts)
|
||||
retryRef.current.attempts += 1
|
||||
refreshingHostEpochRef.current = hostEpoch
|
||||
setPackageLoading(true)
|
||||
setPackageWarning(undefined)
|
||||
setPackageProgress(undefined)
|
||||
void (async () => {
|
||||
if (!(await waitBeforeMobileWebPackageRefresh(retryDelayMs, controller.signal))) {
|
||||
return
|
||||
}
|
||||
const refreshStartedAt = Date.now()
|
||||
try {
|
||||
const downloaded = await downloadMobileWebPackage(
|
||||
@@ -98,6 +112,8 @@ export function useMobileWebPackageRefresh(args: {
|
||||
}
|
||||
}
|
||||
)
|
||||
// The bundle is staged: whatever happens next costs no more download, so the ladder resets.
|
||||
retryRef.current.attempts = 0
|
||||
if (controller.signal.aborted || hostEpochRef.current !== hostEpoch) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
|
||||
import type { MobileWebHealthDeadline } from './mobile-web-health-deadline'
|
||||
import type { MobileWebNativeRouteHandoff } from './mobile-web-native-route-handoff'
|
||||
|
||||
/**
|
||||
* The shell owns the document lifecycle, so it also owns the page-scoped state that dies with a
|
||||
* document: what was initialized, what reported ready, and the health deadline armed for it.
|
||||
*
|
||||
* A document is replaced without the shell session, its build or the view epoch moving at all — a
|
||||
* native-route excursion deactivates the view to about:blank and reloads on return, the route error
|
||||
* boundary reloads in place, and a re-attached view reloads its URL. Each of those mints a new page
|
||||
* with new subscription ids while the previous page's broker survives, and its records keep holding
|
||||
* every per-operation grant (one for workspace, account and source control), so the new document's
|
||||
* subscribes are refused with `rate_limited` for the life of the shell session. The document epoch
|
||||
* is that boundary: it retires the outgoing page's broker before the incoming one initializes.
|
||||
*/
|
||||
export function useMobileWebPageDocument({
|
||||
sessionId,
|
||||
viewEpoch,
|
||||
healthDeadlineRef,
|
||||
routeHandoffRef
|
||||
}: {
|
||||
sessionId: string | undefined
|
||||
viewEpoch: number
|
||||
healthDeadlineRef: MutableRefObject<MobileWebHealthDeadline>
|
||||
routeHandoffRef: MutableRefObject<MobileWebNativeRouteHandoff>
|
||||
}): {
|
||||
epoch: number
|
||||
initializedSessionRef: MutableRefObject<string | undefined>
|
||||
readySessionId: string | undefined
|
||||
setReadySessionId: (sessionId: string | undefined) => void
|
||||
onLoadStart: () => void
|
||||
onLoaded: () => void
|
||||
} {
|
||||
const initializedSessionRef = useRef<string | undefined>(undefined)
|
||||
const loadedRef = useRef(false)
|
||||
const [epoch, setEpoch] = useState(0)
|
||||
const [readySessionId, setReadySessionId] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
initializedSessionRef.current = undefined
|
||||
loadedRef.current = false
|
||||
routeHandoffRef.current.clear()
|
||||
setReadySessionId(undefined)
|
||||
healthDeadlineRef.current.clear()
|
||||
return () => healthDeadlineRef.current.clear()
|
||||
}, [epoch, healthDeadlineRef, routeHandoffRef, sessionId, viewEpoch])
|
||||
|
||||
const onLoadStart = useCallback(() => {
|
||||
// Only a load that displaces a document that finished loading is a replacement. The shell posts
|
||||
// `loading` for the first load too, and more than once per load, and neither is a new page.
|
||||
if (!loadedRef.current) {
|
||||
return
|
||||
}
|
||||
loadedRef.current = false
|
||||
setEpoch((current) => current + 1)
|
||||
}, [])
|
||||
|
||||
const onLoaded = useCallback(() => {
|
||||
loadedRef.current = true
|
||||
}, [])
|
||||
|
||||
return {
|
||||
epoch,
|
||||
initializedSessionRef,
|
||||
readySessionId,
|
||||
setReadySessionId,
|
||||
onLoadStart,
|
||||
onLoaded
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,35 @@ describe('host removal lifecycle', () => {
|
||||
expect(closeHostClient).toHaveBeenCalledWith('host-1')
|
||||
})
|
||||
|
||||
it('deletes the package cache only after the client that served it is closed', async () => {
|
||||
// The hosted WebView reads its document out of that cache: deleting it first turned every
|
||||
// later asset request into a 403 under a still-mounted document.
|
||||
const order: string[] = []
|
||||
removeHostMock.mockImplementation(async () => {
|
||||
order.push('remove-metadata')
|
||||
})
|
||||
removeMobileWebHostCacheMock.mockImplementation(async () => {
|
||||
order.push('delete-cache')
|
||||
})
|
||||
clearMobileWebColdResumeRouteForHostMock.mockImplementation(async () => {
|
||||
order.push('clear-cold-route')
|
||||
})
|
||||
|
||||
await removeHostAndCloseClient('host-1', 'public-key-1', () => order.push('close-client'))
|
||||
|
||||
expect(order).toEqual(['remove-metadata', 'close-client', 'delete-cache', 'clear-cold-route'])
|
||||
})
|
||||
|
||||
it('keeps the package cache when the unpair itself never commits', async () => {
|
||||
removeHostMock.mockRejectedValue(new Error('storage unavailable'))
|
||||
|
||||
await expect(removeHostAndCloseClient('host-1', 'public-key-1', vi.fn())).rejects.toThrow(
|
||||
'storage unavailable'
|
||||
)
|
||||
|
||||
expect(removeMobileWebHostCacheMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forgets removed-host logs even when client teardown throws', async () => {
|
||||
removeHostMock.mockResolvedValue(undefined)
|
||||
const closeHostClient = vi.fn(() => {
|
||||
|
||||
@@ -12,10 +12,6 @@ export async function removeHostAndCloseClient(
|
||||
hostPublicKey: string,
|
||||
forgetHostClient: (hostId: string) => void
|
||||
): Promise<void> {
|
||||
// Why: cache deletion is recoverable by redownload, so a hybrid-only failure here must
|
||||
// never block the unpair itself — on a native build the cache may not even exist.
|
||||
await removeMobileWebHostCache(hostPublicKey).catch(() => null)
|
||||
await clearMobileWebColdResumeRouteForHost(hostId).catch(() => null)
|
||||
// Why: closing before the metadata commit can strand a still-paired host on
|
||||
// storage failure; closing immediately after success prevents socket leaks.
|
||||
await removeHost(hostId)
|
||||
@@ -26,5 +22,11 @@ export async function removeHostAndCloseClient(
|
||||
forgetHostNotificationSession(hostId)
|
||||
void clearWatermark(hostId)
|
||||
connectionLogStore.delete(hostId)
|
||||
// Why last: the hybrid WebView serves its document out of this cache, so deleting it while one
|
||||
// is still mounted 403s every asset the page asks for next. Cache deletion is recoverable by
|
||||
// redownload, so a hybrid-only failure here must never block the unpair either — on a native
|
||||
// build the cache may not even exist.
|
||||
await removeMobileWebHostCache(hostPublicKey).catch(() => null)
|
||||
await clearMobileWebColdResumeRouteForHost(hostId).catch(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,26 @@ describe('startRuntimeCapabilityProbe', () => {
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('stops polling at cutover speed when the link keeps cutting over', async () => {
|
||||
const outcomes: ProbeOutcome[] = Array.from(
|
||||
{ length: 6 },
|
||||
() => new LogicalClientCutoverError()
|
||||
)
|
||||
outcomes.push(ok(['a.v1']))
|
||||
const { client, calls } = makeClient(outcomes)
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
|
||||
// Three prompt retries, then the backoff ladder: an endless 4 Hz poll would be 40 calls here.
|
||||
await vi.advanceTimersByTimeAsync(250 * 4)
|
||||
expect(calls()).toBe(4)
|
||||
await vi.advanceTimersByTimeAsync(1_000 + 2_000 + 4_000)
|
||||
expect(seen).toEqual([['a.v1']])
|
||||
expect(calls()).toBe(7)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('stops retrying and dropping results once cancelled', async () => {
|
||||
const { client, calls } = makeClient([new Error('timeout'), ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
|
||||
// status.get without ever changing connState, so a one-shot probe would latch
|
||||
// capability-gated UI hidden until the screen remounts; retry until one lands.
|
||||
const CUTOVER_RETRY_DELAY_MS = 250
|
||||
// Why a cap: a link that keeps forcing cutovers would otherwise poll status.get four times a second
|
||||
// for as long as the screen is mounted. After the prompt attempts, cutovers join the backoff ladder.
|
||||
const CUTOVER_FAST_RETRY_LIMIT = 3
|
||||
const FAILURE_RETRY_BASE_DELAY_MS = 1_000
|
||||
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
|
||||
|
||||
@@ -43,6 +46,7 @@ export function startRuntimeCapabilityRead<T>(
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let failureRetries = 0
|
||||
let cutoverRetries = 0
|
||||
|
||||
function attempt(): void {
|
||||
void readCapabilities().then(
|
||||
@@ -64,9 +68,10 @@ export function startRuntimeCapabilityRead<T>(
|
||||
function scheduleRetry(cutover: boolean): void {
|
||||
// Why: cutover means the replacement transport is already authenticated —
|
||||
// re-ask promptly; other failures back off so a wedged host isn't hammered.
|
||||
const delay = cutover
|
||||
? CUTOVER_RETRY_DELAY_MS
|
||||
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
|
||||
const delay =
|
||||
cutover && cutoverRetries++ < CUTOVER_FAST_RETRY_LIMIT
|
||||
? CUTOVER_RETRY_DELAY_MS
|
||||
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
|
||||
retryTimer = setTimeout(attempt, delay)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user