diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts b/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts new file mode 100644 index 00000000000..eb8c3037b28 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts @@ -0,0 +1,52 @@ +import type { MobileWebShellSession, MobileWebShellStep } from './mobile-web-shell-session-contract' +import { + readFailureSide, + type MobileWebShellUpdateFailureCause, + type MobileWebShellUpdateFailureFacts +} from './mobile-web-shell-update-failure' +import { updateFailureOutcomeOf } from './mobile-web-shell-update-failure-outcome' +import { cachedGenerationWall, gateState, gateVerdict } from './mobile-web-shell-gates' +import { openByOwnRoutes } from './mobile-web-shell-cached-generation' +import { step } from './mobile-web-shell-session-step' + +// Record the resulting decision once for Troubleshoot; release builds forward no console logs. +export function onDownloadFailed( + session: MobileWebShellSession, + cause: MobileWebShellUpdateFailureCause +): MobileWebShellStep { + const decided = decideDownloadFailed(session, cause) + const failure: MobileWebShellUpdateFailureFacts = { + ...cause, + offeredBuildId: session.requestedBuildId, + cachedBuildId: session.cached?.buildId ?? null, + ...updateFailureOutcomeOf(decided.session.state) + } + return { ...decided, effects: [...decided.effects, { kind: 'record-update-failure', failure }] } +} + +// Fall back to intact cached bytes under their own routes, checking compatibility when the host remains reachable. +// Name only bundle refusals: link loss cannot establish an update existed; absent cache still gets download failure. +function decideDownloadFailed( + session: MobileWebShellSession, + cause: MobileWebShellUpdateFailureCause +): MobileWebShellStep { + const cached = session.cached + const gates = session.gates + if (cached === null || gates === null) { + return step(session, { + state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce } + }) + } + // Read current gates because they can change while fetching without restarting the flow. + const verdict = gateVerdict(gates) + const gated = gateState(verdict, session.retriedOnce) + if (gated !== null) { + return step(session, { state: gated }) + } + return openByOwnRoutes(session, cached, { + // Missing cached routes remain a retryable download failure after the new manifest claimed them or never arrived. + unclaimed: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce }, + served: { updateNotice: readFailureSide(cause.reason) === 'bundle' ? 'update-failed' : null }, + wall: cachedGenerationWall(verdict, gates, cached.compat) + }) +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts index f107c78fc7f..0c2b2281a05 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts @@ -10,6 +10,7 @@ import type { // Reuse state identities so equivalent transitions do not cause extra renders. export const CHECKING: MobileWebShellSessionState = { kind: 'checking' } +export const OFFLINE: MobileWebShellSessionState = { kind: 'offline' } export const ROUTE_UNAVAILABLE: MobileWebShellSessionState = { kind: 'route-unavailable' } // Keep displayed generations stable through reconnects; only checking/offline and unreadable-status failures wait for new gates. @@ -89,6 +90,8 @@ export function cachedGenerationWall( } // Compare verdicts instead of rebuilt gate objects so unchanged reconnect/status events cannot resweep cache and flicker the spinner. +// An early retry ending is a change: a first open waiting on it must go on to say offline. export function gateKey(gates: MobileWebShellGates): string { - return gateVerdict(gates).kind + const kind = gateVerdict(gates).kind + return kind === 'offline' && gates.retrying === true ? 'offline-retrying' : kind } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts index 2db075c90e1..ca41f4d9d96 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -18,6 +18,8 @@ export type MobileWebShellGates = { // Unreadable includes absent/undecodable replies; their empty capabilities must not masquerade as bundle-unavailable. readonly statusReadable: boolean readonly reachability: MobileWebShellReachability + // Unreachable, but still redialing within the attempts the UI calls connecting; absent reads as false. + readonly retrying?: boolean readonly hostCapabilities: readonly string[] readonly hostStatus: MobileWebBundleHostStatus } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts index 3fa18bb385c..245949afc95 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -611,6 +611,30 @@ describe('a gates change that says nothing new starts nothing', () => { expect(again.session.flow).toBe(checking.session.flow) }) + it('keeps a first open checking while the dial retries, then says offline when it stops', () => { + const retrying = gates({ reachability: 'unreachable', retrying: true }) + const waiting = run(started(retrying).session, { type: 'cache-read', generation: null }) + expect(waiting.session.state).toEqual({ kind: 'checking' }) + + const gaveUp = run(waiting.session, { + type: 'gates-changed', + gates: gates({ reachability: 'unreachable' }) + }) + expect(gaveUp.effects).toEqual([{ kind: 'open-cache' }]) + const offline = run(gaveUp.session, { type: 'cache-read', generation: null }) + expect(offline.session.state).toEqual({ kind: 'offline' }) + }) + + it('still opens a cached page at once while the dial retries', () => { + const retrying = gates({ reachability: 'unreachable', retrying: true }) + const step = run(started(retrying).session, { type: 'cache-read', generation: CACHED }) + const offline = gates({ reachability: 'unreachable' }) + const unretried = run(started(offline).session, { type: 'cache-read', generation: CACHED }) + expect(step.session.state).toEqual(unretried.session.state) + expect(step.effects).toEqual(unretried.effects) + expect(step.session.state.kind).toBe('activating') + }) + it('holds the offline screen through a reconnect cycle that never reaches the host', () => { const offline = run(started({ reachability: 'unreachable' }).session, { type: 'cache-read', diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index 5295afd8a88..b9f3b43b47c 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -9,16 +9,11 @@ import type { MobileWebShellSessionEvent, MobileWebShellStep } from './mobile-web-shell-session-contract' -import { - readFailureSide, - type MobileWebShellUpdateFailureCause, - type MobileWebShellUpdateFailureFacts -} from './mobile-web-shell-update-failure' -import { updateFailureOutcomeOf } from './mobile-web-shell-update-failure-outcome' +import { onDownloadFailed } from './mobile-web-shell-download-failure' import { awaitsGates, - cachedGenerationWall, CHECKING, + OFFLINE, gateKey, gateState, gateVerdict, @@ -94,7 +89,8 @@ function onCacheRead( if (gates.reachability === 'unreachable') { // No current host verdict exists offline; open trusted cache and recheck on the next connected entry. if (generation === null) { - return step(session, { cached: null, state: { kind: 'offline' } }) + // A first open has nothing to show offline; keep checking while the dial is still retrying. + return step(session, { cached: null, ...(gates.retrying === true ? {} : { state: OFFLINE }) }) } // Use cached routes offline; an absent route means offline, not evidence the unreachable desktop is outdated. return openByOwnRoutes(session, generation, { @@ -182,48 +178,6 @@ function onShellFailed(session: MobileWebShellSession, reason: ShellFailure): Mo ]) } -// Record the resulting decision once for Troubleshoot; release builds forward no console logs. -function onDownloadFailed( - session: MobileWebShellSession, - cause: MobileWebShellUpdateFailureCause -): MobileWebShellStep { - const decided = decideDownloadFailed(session, cause) - const failure: MobileWebShellUpdateFailureFacts = { - ...cause, - offeredBuildId: session.requestedBuildId, - cachedBuildId: session.cached?.buildId ?? null, - ...updateFailureOutcomeOf(decided.session.state) - } - return { ...decided, effects: [...decided.effects, { kind: 'record-update-failure', failure }] } -} - -// Fall back to intact cached bytes under their own routes, checking compatibility when the host remains reachable. -// Name only bundle refusals: link loss cannot establish an update existed; absent cache still gets download failure. -function decideDownloadFailed( - session: MobileWebShellSession, - cause: MobileWebShellUpdateFailureCause -): MobileWebShellStep { - const cached = session.cached - const gates = session.gates - if (cached === null || gates === null) { - return step(session, { - state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce } - }) - } - // Read current gates because they can change while fetching without restarting the flow. - const verdict = gateVerdict(gates) - const gated = gateState(verdict, session.retriedOnce) - if (gated !== null) { - return step(session, { state: gated }) - } - return openByOwnRoutes(session, cached, { - // Missing cached routes remain a retryable download failure after the new manifest claimed them or never arrived. - unclaimed: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce }, - served: { updateNotice: readFailureSide(cause.reason) === 'bundle' ? 'update-failed' : null }, - wall: cachedGenerationWall(verdict, gates, cached.compat) - }) -} - function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileWebShellStep { if (session.state.kind === 'failed' && session.state.reason === 'host-profile-unreadable') { return step(session, { wide }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts index fd3856ccd25..5811ea36fc6 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts @@ -5,6 +5,7 @@ import { provenHostStatus } from '../transport/proven-host-status' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { readMobileWebShellReachability } from './mobile-web-shell-reachability' +import { isEarlyConnectRetry } from '../transport/connection-health' import type { MobileWebShellGates } from './mobile-web-shell-session-contract' /** Feeds the host route's gates to the shell session, and returns the status they proved for the page's init. */ @@ -19,11 +20,14 @@ export function useMobileWebShellGates(args: { const { gates, connState, client, rearmKey, onGates } = args const { statusPending, statusReadable, hostCapabilities, hostProtocolWindow } = gates const reachability = readMobileWebShellReachability(connState, client) + const retrying = + client !== null && isEarlyConnectRetry(connState, () => client.getReconnectAttempt()) useEffect(() => { onGates({ statusPending, statusReadable, reachability, + retrying, hostCapabilities, hostStatus: hostProtocolWindow }) @@ -34,6 +38,7 @@ export function useMobileWebShellGates(args: { rearmKey, hostProtocolWindow, reachability, + retrying, statusPending, statusReadable ]) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 3b7d653726f..513c004bdca 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -7,6 +7,14 @@ import type { ConnectionState } from './types' // Absorb brief wake/network failures before warning; keep unreachable aligned with the retry loop’s GIVE_UP_AFTER_ATTEMPTS. // After a prior success, stale time distinguishes a changed/unreachable host from a transient drop. const WARNING_ATTEMPTS = 3 + +/** A redial still inside the attempts the UI calls connecting, before it warns the host is unreachable. */ +export function isEarlyConnectRetry( + state: ConnectionState, + readReconnectAttempts: () => number +): boolean { + return state === 'reconnecting' && readReconnectAttempts() < WARNING_ATTEMPTS +} const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000