fix(mobile): harden hybrid lifecycle races

This commit is contained in:
Jinwoo-H
2026-08-23 20:30:56 -04:00
parent eb1b8bcc72
commit 5d4af00f5f
22 changed files with 397 additions and 110 deletions
+4 -6
View File
@@ -8,12 +8,10 @@ import {
type MobileWebBridgeShellMessage,
type MobileWebResumeRoute
} from '../../src/shared/mobile-web/bridge-contract'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from '../src/mobile-web/mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from '../src/mobile-web/mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from '../src/mobile-web/mobile-web-production-grants'
import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-deadline'
import { useMobileWebPackageSession } from '../src/mobile-web/use-mobile-web-package-session'
import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session'
import { createMobileWebNativeCapabilityAuthority } from '../src/mobile-web/mobile-web-native-capability-authority'
import { MobileWebHybridShellPresentation } from '../src/mobile-web/MobileWebHybridShellPresentation'
import { useMobileWebNavigationIntentHandoff } from '../src/mobile-web/use-mobile-web-navigation-intent-handoff'
@@ -85,7 +83,7 @@ export default function HybridScreen() {
recoverPrevious,
clearCache,
showWarning
} = useMobileWebPackageSession({ client, host: selectedHost, state })
} = useMobileWebAlertSafePackageSession({ client, host: selectedHost, state })
const bridgeRuntimeRef = useMobileWebBridgeRuntimeRef(client, state, session?.sessionId)
const coldResumeRoute = useMobileWebColdResumeRoute({
hosts,
@@ -117,6 +117,28 @@ describe('mobile web BackHandler adapter', () => {
expect(harness.target.location.href).toBe('https://orca.test/files')
expect(handler).not.toHaveBeenCalled()
})
it('tracks rapid delayed Back traversals independently', () => {
vi.useFakeTimers()
const harness = navigationHarness(500)
const backHandler = backHandlerTarget()
installMobileWebBackNavigationAdapter(backHandler, harness.target)
harness.target.history.pushState({}, '', '/files')
harness.target.history.pushState({}, '', '/preview')
harness.target.history.pushState({}, '', '/detail')
const handler = vi.fn(() => {
harness.target.history.back()
return true
})
backHandler.addEventListener('hardwareBackPress', handler)
expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true)
expect(dispatchMobileWebBackNavigation(harness.target)).toBe(true)
vi.advanceTimersByTime(500)
expect(handler).toHaveBeenCalledTimes(2)
expect(harness.target.location.href).toBe('https://orca.test/files')
})
})
function backHandlerTarget() {
@@ -21,8 +21,14 @@ type MobileWebNavigationTarget = {
const HISTORY_INDEX_KEY = '__orcaMobileWebBackIndex'
const PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS = 5_000
const MAX_EXPECTED_TRAVERSALS = 32
const installedHistories = new WeakMap<object, () => boolean>()
type ExpectedTraversal = {
targetIndex: number
timeout: ReturnType<typeof setTimeout>
}
export function installMobileWebBackNavigationAdapter(
backHandler: MobileWebBackHandlerTarget = BackHandler,
target: MobileWebNavigationTarget = window
@@ -40,19 +46,27 @@ export function installMobileWebBackNavigationAdapter(
const originalForward = history.forward.bind(history)
let currentIndex = historyIndex(history.state) ?? 0
let maximumIndex = currentIndex
let expectedProgrammaticIndex: number | null = null
let programmaticReset: ReturnType<typeof setTimeout> | undefined
const expectedTraversals: ExpectedTraversal[] = []
let restoringFromIndex: number | null = null
originalReplaceState(indexedHistoryState(history.state, currentIndex), '', undefined)
const markProgrammatic = (targetIndex: number): void => {
expectedProgrammaticIndex = targetIndex
clearTimeout(programmaticReset)
programmaticReset = setTimeout(() => {
expectedProgrammaticIndex = null
}, PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS)
if (expectedTraversals.length === MAX_EXPECTED_TRAVERSALS) {
clearTimeout(expectedTraversals.shift()?.timeout)
}
const expected: ExpectedTraversal = {
targetIndex,
timeout: setTimeout(() => {
const index = expectedTraversals.indexOf(expected)
if (index !== -1) {
expectedTraversals.splice(index, 1)
}
}, PROGRAMMATIC_TRAVERSAL_TIMEOUT_MS)
}
expectedTraversals.push(expected)
}
const projectedIndex = (): number => expectedTraversals.at(-1)?.targetIndex ?? currentIndex
history.pushState = (data, unused, url) => {
currentIndex += 1
maximumIndex = currentIndex
@@ -62,21 +76,24 @@ export function installMobileWebBackNavigationAdapter(
originalReplaceState(indexedHistoryState(data, currentIndex), unused, url)
}
history.go = (delta = 0) => {
const targetIndex = Math.max(0, Math.min(maximumIndex, currentIndex + delta))
if (targetIndex !== currentIndex) {
const fromIndex = projectedIndex()
const targetIndex = Math.max(0, Math.min(maximumIndex, fromIndex + delta))
if (targetIndex !== fromIndex) {
markProgrammatic(targetIndex)
}
originalGo(delta)
}
history.back = () => {
if (currentIndex > 0) {
markProgrammatic(currentIndex - 1)
const fromIndex = projectedIndex()
if (fromIndex > 0) {
markProgrammatic(fromIndex - 1)
}
originalBack()
}
history.forward = () => {
if (currentIndex < maximumIndex) {
markProgrammatic(currentIndex + 1)
const fromIndex = projectedIndex()
if (fromIndex < maximumIndex) {
markProgrammatic(fromIndex + 1)
}
originalForward()
}
@@ -114,9 +131,12 @@ export function installMobileWebBackNavigationAdapter(
}
const direction = Math.sign(nextIndex - currentIndex)
if (nextIndex === expectedProgrammaticIndex) {
expectedProgrammaticIndex = null
clearTimeout(programmaticReset)
const expectedIndex = expectedTraversals.findIndex(
(expected) => expected.targetIndex === nextIndex
)
if (expectedIndex !== -1) {
const consumed = expectedTraversals.splice(0, expectedIndex + 1)
consumed.forEach((expected) => clearTimeout(expected.timeout))
currentIndex = nextIndex
return
}
@@ -4,7 +4,7 @@ import {
parseMobileWebBridgeShellMessage
} from '../../../src/shared/mobile-web/bridge-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
const CONTEXT = {
@@ -34,7 +34,6 @@ import {
type PageRequest = Extract<MobileWebBridgePageMessage, { type: 'request' }>
type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled: boolean }
export { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
export class MobileWebCapabilityBroker {
private readonly pending = new Map<string, PendingRequest>()
private readonly replay = new MobileWebBrokerReplayGuard()
@@ -218,6 +217,7 @@ export class MobileWebCapabilityBroker {
await this.messages.error(request.requestId, code, isRetryableMobileWebBridgeError(code))
}
} finally {
this.authorities.sourceControlBranchCompare.releaseClaim(request.requestId)
if (this.pending.get(request.requestId) === pending) {
this.pending.delete(request.requestId)
}
@@ -231,6 +231,7 @@ export async function executeMobileWebCapabilityRequest(
client: args.connectedClient(),
workspaceAuthority: args.workspaceAuthority,
branchComparePager: args.sourceControlBranchCompare,
requestId: request.requestId,
terminalClientId: args.terminalClientId
})
}
@@ -5,10 +5,8 @@ import {
type MobileWebBridgeShellMessage
} from '../../../src/shared/mobile-web/bridge-contract'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from './mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
const CONTEXT = {
shellSessionId: 'S'.repeat(43),
@@ -5,10 +5,8 @@ import {
} from '../../../src/shared/mobile-web/bridge-contract'
import { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from './mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
const CONTEXT = {
shellSessionId: 'S'.repeat(43),
@@ -1,6 +1,9 @@
import type { AlertButton, AlertOptions } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import { presentMobileWebNativeAlert } from './mobile-web-native-alert'
import {
MobileWebNativeAlertLifecycle,
presentMobileWebNativeAlert
} from './mobile-web-native-alert'
vi.mock('react-native', () => ({ Alert: { alert: vi.fn() } }))
@@ -57,4 +60,37 @@ describe('mobile web native alert', () => {
options?.onDismiss?.()
await expect(result).resolves.toEqual({ kind: 'dismissed' })
})
it('serializes native alerts across broker lifecycles', async () => {
let buttons: AlertButton[] = []
let options: AlertOptions | undefined
const target = {
alert: vi.fn((_title, _message, nextButtons, nextOptions) => {
buttons = nextButtons ?? []
options = nextOptions
})
}
const lifecycle = new MobileWebNativeAlertLifecycle()
const first = lifecycle.present({ title: 'First', buttons: [{ text: 'OK' }] }, target)
await expect(
lifecycle.present({ title: 'Second', buttons: [{ text: 'OK' }] }, target)
).rejects.toMatchObject({ code: 'rate_limited' })
let idle = false
const waiting = lifecycle.waitForIdle().then(() => {
idle = true
})
await Promise.resolve()
expect(idle).toBe(false)
buttons[0]?.onPress?.()
options?.onDismiss?.()
await expect(first).resolves.toEqual({ kind: 'button', buttonIndex: 0 })
await waiting
expect(idle).toBe(true)
const third = lifecycle.present({ title: 'Third', buttons: [{ text: 'OK' }] }, target)
options?.onDismiss?.()
await expect(third).resolves.toEqual({ kind: 'dismissed' })
})
})
@@ -3,6 +3,7 @@ import type {
MobileWebNativeAlertPayload,
MobileWebNativeAlertResult
} from '../../../src/shared/mobile-web/native-operation-contract'
import { MobileWebBrokerError } from './mobile-web-broker-error'
type MobileWebNativeAlertTarget = Pick<typeof Alert, 'alert'>
@@ -32,3 +33,34 @@ export function presentMobileWebNativeAlert(
)
})
}
export class MobileWebNativeAlertLifecycle {
private pending: Promise<void> | null = null
readonly present = (
payload: MobileWebNativeAlertPayload,
target: MobileWebNativeAlertTarget = Alert
): Promise<MobileWebNativeAlertResult> => {
if (this.pending) {
return Promise.reject(new MobileWebBrokerError('rate_limited'))
}
const result = presentMobileWebNativeAlert(payload, target)
const pending = result.then(
() => undefined,
() => undefined
)
this.pending = pending
void pending.then(() => {
if (this.pending === pending) {
this.pending = null
}
})
return result
}
readonly waitForIdle = async (): Promise<void> => {
await this.pending
}
}
export const mobileWebNativeAlertLifecycle = new MobileWebNativeAlertLifecycle()
@@ -49,7 +49,7 @@ import {
} from '../components/codex-reset-credit'
import { readCodexResetCreditCapability } from '../components/codex-reset-credit-capability'
import type { RpcClient } from '../transport/rpc-client'
import { presentMobileWebNativeAlert } from './mobile-web-native-alert'
import { mobileWebNativeAlertLifecycle } from './mobile-web-native-alert'
type MobileWebNativeDraftScope = {
hostIdentity: string
@@ -98,10 +98,13 @@ export type MobileWebNativeCapabilityAuthority = {
}
export function createMobileWebNativeCapabilityAuthority(
draftScope: MobileWebNativeDraftScope
draftScope: MobileWebNativeDraftScope,
alert: NonNullable<
MobileWebNativeCapabilityAuthority['alert']
> = mobileWebNativeAlertLifecycle.present
): MobileWebNativeCapabilityAuthority {
return {
alert: presentMobileWebNativeAlert,
alert,
hapticFeedback(kind) {
if (kind === 'selection') {
triggerSelection()
@@ -5,10 +5,8 @@ import {
} from '../../../src/shared/mobile-web/bridge-contract'
import { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from './mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
const CONTEXT = {
shellSessionId: 'S'.repeat(43),
@@ -5,10 +5,8 @@ import {
} from '../../../src/shared/mobile-web/bridge-contract'
import { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from './mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from './mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
const CONTEXT = {
shellSessionId: 'S'.repeat(43),
@@ -0,0 +1,15 @@
import type { MobileWebShellSession } from '@orca/expo-mobile-web-shell'
export type MobileWebPackageSession = {
session: MobileWebShellSession | null
viewEpoch: number
packageLoading: boolean
packageWarning: string | undefined
markHealthy: (sessionId: string) => Promise<void>
handleHealthTimeout: (sessionId: string) => Promise<void>
handleProcessTerminated: (sessionId: string) => Promise<void>
retryPackage: () => void
recoverPrevious: () => Promise<void>
clearCache: () => Promise<void>
showWarning: (warning: string) => void
}
@@ -12,6 +12,21 @@ const OID_A = 'a'.repeat(40)
const OID_B = 'b'.repeat(40)
describe('mobile web source-control branch-compare pager', () => {
it('creates continuation identities without a Node global Buffer', async () => {
const originalBuffer = globalThis.Buffer
vi.stubGlobal('Buffer', undefined)
try {
const pager = new MobileWebSourceControlBranchComparePager()
const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1)
await expect(pager.page(firstPayload(), client, workspaceAuthority)).resolves.toMatchObject({
nextOffset: MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT
})
} finally {
vi.stubGlobal('Buffer', originalBuffer)
}
})
it('uses one host snapshot across single-use continuation claims', async () => {
const pager = new MobileWebSourceControlBranchComparePager()
const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1)
@@ -46,22 +61,61 @@ describe('mobile web source-control branch-compare pager', () => {
})
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('keeps two identical comparison sequences independently claimable', async () => {
const pager = new MobileWebSourceControlBranchComparePager()
const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1)
const [first, second] = await Promise.all([
pager.page(firstPayload(), client, workspaceAuthority),
pager.page(firstPayload(), client, workspaceAuthority)
])
const firstContinuation = continuationPayload(first)
const secondContinuation = continuationPayload(second)
expect(pager.claimContinuation(firstContinuation, 'request-a')).toBe(true)
expect(pager.claimContinuation(secondContinuation, 'request-b')).toBe(true)
await expect(
Promise.all([
pager.page(firstContinuation, client, workspaceAuthority, 'request-a'),
pager.page(secondContinuation, client, workspaceAuthority, 'request-b')
])
).resolves.toEqual([
expect.objectContaining({ nextOffset: null }),
expect.objectContaining({ nextOffset: null })
])
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('does not let one comparison identity revoke another', async () => {
const pager = new MobileWebSourceControlBranchComparePager()
const client = compareClient(MOBILE_WEB_SOURCE_CONTROL_COMPARE_ENTRY_LIMIT + 1)
const first = await pager.page(firstPayload('main'), client, workspaceAuthority)
const second = await pager.page(firstPayload('release'), client, workspaceAuthority)
const firstContinuation = continuationPayload(first, 'main')
const secondContinuation = continuationPayload(second, 'release')
expect(pager.claimContinuation(firstContinuation, 'request-a')).toBe(true)
expect(pager.claimContinuation(secondContinuation, 'request-b')).toBe(true)
})
})
function firstPayload(): MobileWebSourceControlBranchComparePayload {
return { workspaceId: 'workspace-1', baseRef: 'main', offset: 0, limit: 128 }
function firstPayload(baseRef = 'main'): MobileWebSourceControlBranchComparePayload {
return { workspaceId: 'workspace-1', baseRef, offset: 0, limit: 128 }
}
function continuationPayload(first: {
nextOffset: number | null
revision: string
}): MobileWebSourceControlBranchComparePayload {
function continuationPayload(
first: {
nextOffset: number | null
revision: string
},
baseRef = 'main'
): MobileWebSourceControlBranchComparePayload {
if (first.nextOffset === null) {
throw new Error('Expected a continuation')
}
return {
workspaceId: 'workspace-1',
baseRef: 'main',
baseRef,
offset: first.nextOffset,
limit: 128,
expectedRevision: first.revision
@@ -3,6 +3,8 @@ import {
type MobileWebSourceControlBranchComparePayload,
type MobileWebSourceControlBranchCompareResult
} from '../../../src/shared/mobile-web/source-control-history-contract'
import { sha256 } from '@noble/hashes/sha256'
import { Buffer } from 'buffer/'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
@@ -10,6 +12,8 @@ import { sanitizeMobileWebBranchCompare } from './mobile-web-source-control-hist
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
const HOST_RESULT_MAX_BYTES = 8 * 1024 * 1024
const MAX_RETAINED_CONTINUATIONS = 8
const DIRECT_REQUEST_ID = 'direct'
type Continuation = {
workspaceId: string
@@ -20,77 +24,83 @@ type Continuation = {
}
export class MobileWebSourceControlBranchComparePager {
private continuation: Continuation | null = null
private claimed: Continuation | null = null
private active = false
private readonly continuations: Continuation[] = []
private readonly claimed = new Map<string, Continuation>()
private sequence = 0
claimRequestContinuation(request: {
requestId: string
capability: string
operation: string
payload: unknown
}): boolean {
this.claimed = null
return (
request.capability === 'sourceControl' &&
request.operation === 'branchCompare' &&
this.claimContinuation(request.payload)
this.claimContinuation(request.payload, request.requestId)
)
}
claimContinuation(payloadValue: unknown): boolean {
claimContinuation(payloadValue: unknown, requestId = DIRECT_REQUEST_ID): boolean {
const parsed = MobileWebSourceControlBranchComparePayloadSchema.safeParse(payloadValue)
const continuation = this.continuation
if (!parsed.success || !parsed.data.expectedRevision || !continuation) {
if (!parsed.success || !parsed.data.expectedRevision || this.claimed.has(requestId)) {
return false
}
const payload = parsed.data
if (
continuation.workspaceId !== payload.workspaceId ||
continuation.baseRef !== payload.baseRef ||
continuation.revision !== payload.expectedRevision ||
continuation.nextOffset !== payload.offset
) {
const index = this.continuations.findIndex((continuation) =>
matchesContinuation(continuation, payload)
)
if (index === -1) {
return false
}
this.continuation = null
this.claimed = continuation
const [continuation] = this.continuations.splice(index, 1)
if (!continuation) {
return false
}
this.claimed.set(requestId, continuation)
return true
}
async page(
payloadValue: unknown,
client: RpcClient,
workspaceAuthority: MobileWebWorkspaceAuthority
workspaceAuthority: MobileWebWorkspaceAuthority,
requestId = DIRECT_REQUEST_ID
): Promise<MobileWebSourceControlBranchCompareResult> {
if (this.active) {
throw new MobileWebBrokerError('rate_limited')
}
this.active = true
try {
const payload = MobileWebSourceControlBranchComparePayloadSchema.parse(payloadValue)
const hostResult = payload.expectedRevision
? this.consumeClaim(payload)
const continuation = payload.expectedRevision ? this.consumeClaim(payload, requestId) : null
const hostResult = continuation
? continuation.hostResult
: await this.begin(payload, client, workspaceAuthority)
const page = sanitizeMobileWebBranchCompare(hostResult, payload)
const sanitized = sanitizeMobileWebBranchCompare(
hostResult,
continuation ? { ...payload, expectedRevision: undefined } : payload
)
const revision = continuation?.revision ?? this.createRevision(sanitized.revision, requestId)
const page = { ...sanitized, revision }
if (page.nextOffset !== null) {
this.continuation = {
this.retain({
workspaceId: payload.workspaceId,
baseRef: payload.baseRef,
revision: page.revision,
nextOffset: page.nextOffset,
hostResult
}
})
}
return page
} finally {
this.active = false
this.claimed = null
this.claimed.delete(requestId)
}
}
clear(): void {
this.continuation = null
this.claimed = null
this.continuations.length = 0
this.claimed.clear()
}
releaseClaim(requestId: string): void {
this.claimed.delete(requestId)
}
private async begin(
@@ -98,7 +108,6 @@ export class MobileWebSourceControlBranchComparePager {
client: RpcClient,
workspaceAuthority: MobileWebWorkspaceAuthority
): Promise<unknown> {
this.clear()
if (payload.offset !== 0) {
throw new MobileWebBrokerError('invalid_request')
}
@@ -116,8 +125,11 @@ export class MobileWebSourceControlBranchComparePager {
return response.result
}
private consumeClaim(payload: MobileWebSourceControlBranchComparePayload): unknown {
const continuation = this.claimed
private consumeClaim(
payload: MobileWebSourceControlBranchComparePayload,
requestId: string
): Continuation {
const continuation = this.claimed.get(requestId)
if (!continuation) {
throw new MobileWebBrokerError('invalid_request')
}
@@ -131,6 +143,31 @@ export class MobileWebSourceControlBranchComparePager {
) {
throw new MobileWebBrokerError('invalid_request')
}
return continuation.hostResult
return continuation
}
private retain(continuation: Continuation): void {
if (this.continuations.length >= MAX_RETAINED_CONTINUATIONS) {
this.continuations.shift()
}
this.continuations.push(continuation)
}
private createRevision(contentRevision: string, requestId: string): string {
this.sequence += 1
const content = new TextEncoder().encode(`${contentRevision}:${requestId}:${this.sequence}`)
return Buffer.from(sha256(content)).toString('hex')
}
}
function matchesContinuation(
continuation: Continuation,
payload: MobileWebSourceControlBranchComparePayload
): boolean {
return (
continuation.workspaceId === payload.workspaceId &&
continuation.baseRef === payload.baseRef &&
continuation.revision === payload.expectedRevision &&
continuation.nextOffset === payload.offset
)
}
@@ -41,6 +41,7 @@ export async function executeMobileWebSourceControlHistoryOperation(args: {
client: RpcClient
workspaceAuthority: MobileWebWorkspaceAuthority
branchComparePager?: MobileWebSourceControlBranchComparePager
requestId?: string
}): Promise<HistoryOperationResult> {
if (args.operation === 'branches') {
const payload = MobileWebSourceControlBranchesPayloadSchema.parse(args.payload)
@@ -70,7 +71,12 @@ export async function executeMobileWebSourceControlHistoryOperation(args: {
if (!args.branchComparePager) {
throw new MobileWebBrokerError('internal')
}
return args.branchComparePager.page(args.payload, args.client, args.workspaceAuthority)
return args.branchComparePager.page(
args.payload,
args.client,
args.workspaceAuthority,
args.requestId
)
}
const payload = MobileWebSourceControlCommitComparePayloadSchema.parse(args.payload)
@@ -61,6 +61,7 @@ export async function executeMobileWebSourceControlOperation(args: {
client: RpcClient
workspaceAuthority: MobileWebWorkspaceAuthority
branchComparePager?: MobileWebSourceControlBranchComparePager
requestId?: string
terminalClientId?: string
}): Promise<
| MobileWebSourceControlStatusResult
@@ -96,7 +97,8 @@ export async function executeMobileWebSourceControlOperation(args: {
payload: args.payload,
client: args.client,
workspaceAuthority: args.workspaceAuthority,
branchComparePager: args.branchComparePager
branchComparePager: args.branchComparePager,
requestId: args.requestId
})
}
if (args.operation === 'status') {
@@ -0,0 +1,11 @@
import { mobileWebNativeAlertLifecycle } from './mobile-web-native-alert'
import { useMobileWebPackageSession } from './use-mobile-web-package-session'
export function useMobileWebAlertSafePackageSession(
args: Omit<Parameters<typeof useMobileWebPackageSession>[0], 'beforeSessionReplacement'>
): ReturnType<typeof useMobileWebPackageSession> {
return useMobileWebPackageSession({
...args,
beforeSessionReplacement: mobileWebNativeAlertLifecycle.waitForIdle
})
}
@@ -56,10 +56,12 @@ const SESSION_B = {
describe('useMobileWebPackageSession', () => {
let renderer: ReactTestRenderer | null = null
let packageSession: MobileWebPackageSession | null = null
let beforeSessionReplacement: (() => Promise<void>) | undefined
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
packageSession = null
beforeSessionReplacement = undefined
native.openSession.mockReset()
native.recoverSession.mockReset()
native.markSessionHealthy.mockReset().mockResolvedValue({ buildId: SESSION_A.buildId })
@@ -78,11 +80,18 @@ describe('useMobileWebPackageSession', () => {
renderer = null
})
function Harness({ state }: { state: ConnectionState }): null {
function Harness({
state,
host = HOST
}: {
state: ConnectionState
host?: HostProfile | null
}): null {
packageSession = useMobileWebPackageSession({
client: state === 'connected' ? CLIENT : null,
host: HOST,
state
host: host ?? undefined,
state,
beforeSessionReplacement
})
return null
}
@@ -313,6 +322,59 @@ describe('useMobileWebPackageSession', () => {
expect(packageSession?.packageLoading).toBe(false)
})
it('keeps the current session published until replacement is safe', async () => {
const safe = deferred<void>()
beforeSessionReplacement = () => safe.promise
native.openSession.mockImplementation((_host: string, buildId: string | null) =>
Promise.resolve(buildId ? SESSION_B : SESSION_A)
)
downloadPackage.mockResolvedValue({ commit: { buildId: SESSION_B.buildId } })
await mount('connected')
expect(packageSession?.session).toEqual(SESSION_A)
expect(native.openSession).toHaveBeenCalledWith(
HOST.publicKeyB64,
SESSION_B.buildId,
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION
)
expect(native.closeSession).not.toHaveBeenCalledWith(SESSION_A.sessionId)
await act(async () => {
safe.resolve()
await safe.promise
await flushPromises()
})
expect(packageSession?.session).toEqual(SESSION_B)
expect(native.closeSession).toHaveBeenCalledWith(SESSION_A.sessionId)
})
it('rejects a replacement whose host becomes stale while activation waits', async () => {
const safe = deferred<void>()
beforeSessionReplacement = () => safe.promise
native.openSession.mockImplementation((_host: string, buildId: string | null) =>
Promise.resolve(buildId ? SESSION_B : SESSION_A)
)
downloadPackage.mockResolvedValue({ commit: { buildId: SESSION_B.buildId } })
await mount('connected')
expect(packageSession?.session).toEqual(SESSION_A)
await act(async () => {
renderer?.update(createElement(Harness, { state: 'connected', host: null }))
await flushPromises()
})
await act(async () => {
safe.resolve()
await safe.promise
await flushPromises()
})
expect(packageSession?.session).toBeNull()
expect(native.closeSession).toHaveBeenCalledWith(SESSION_B.sessionId)
})
it('keeps the loading state while a first desktop refresh is active', async () => {
const refresh = deferred<{ commit: { buildId: string } }>()
native.openSession.mockRejectedValue(new Error('cache unavailable'))
@@ -17,32 +17,23 @@ import {
import { mobileWebPackageRefreshWarning } from './mobile-web-package-refresh-warning'
import { useMobileWebPackageCapability } from './use-mobile-web-package-capability'
import { useMobileWebPackageRecovery } from './use-mobile-web-package-recovery'
import type { MobileWebPackageSession } from './mobile-web-package-session-state'
export type { MobileWebPackageSession } from './mobile-web-package-session-state'
const MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING =
'Update Orca on this desktop to use its workspace interface.'
export type MobileWebPackageSession = {
session: MobileWebShellSession | null
viewEpoch: number
packageLoading: boolean
packageWarning: string | undefined
markHealthy: (sessionId: string) => Promise<void>
handleHealthTimeout: (sessionId: string) => Promise<void>
handleProcessTerminated: (sessionId: string) => Promise<void>
retryPackage: () => void
recoverPrevious: () => Promise<void>
clearCache: () => Promise<void>
showWarning: (warning: string) => void
}
export function useMobileWebPackageSession({
client,
host,
state
state,
beforeSessionReplacement
}: {
client: RpcClient | null
host: HostProfile | undefined
state: ConnectionState
beforeSessionReplacement?: () => Promise<void>
}): MobileWebPackageSession {
const hostEpochRef = useRef(0)
const activeHostIdRef = useRef<string | null>(null)
@@ -79,6 +70,13 @@ export function useMobileWebPackageSession({
return false
}
const previous = ownedSessionRef.current
if (previous && previous.sessionId !== next.sessionId) {
await beforeSessionReplacement?.()
if (hostEpochRef.current !== hostEpoch) {
await ExpoMobileWebShell.closeSession(next.sessionId).catch(() => {})
return false
}
}
ownedSessionRef.current = next
setSession(next)
setViewEpoch(0)
@@ -94,7 +92,7 @@ export function useMobileWebPackageSession({
}
return true
},
[]
[beforeSessionReplacement]
)
useEffect(() => {
@@ -6,10 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import WebSocketClient, { WebSocketServer, type RawData, type WebSocket } from 'ws'
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MOBILE_WEB_PRODUCTION_GRANTS,
MobileWebCapabilityBroker
} from '../mobile-web/mobile-web-capability-broker'
import { MobileWebCapabilityBroker } from '../mobile-web/mobile-web-capability-broker'
import { MOBILE_WEB_PRODUCTION_GRANTS } from '../mobile-web/mobile-web-production-grants'
import {
MOBILE_WEB_BRIDGE_PROTOCOL_VERSION,
parseMobileWebBridgePageMessage,