diff --git a/mobile/src/files/mobile-file-preview-navigation.ts b/mobile/src/files/mobile-file-preview-navigation.ts index e5024a4fbdc..3cd89212212 100644 --- a/mobile/src/files/mobile-file-preview-navigation.ts +++ b/mobile/src/files/mobile-file-preview-navigation.ts @@ -1,4 +1,5 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import { defaultScheduleTimer } from '../transport/timer-scheduler' import { createMobileFilePreviewHref, type MobileFilePreviewHref, @@ -24,7 +25,7 @@ export function navigateToMobileFilePreview( if (options.embedded && options.onRequestClose) { // Why: closing the dock immediately can unmount the subtree before Expo // commits the route transition. - const scheduleClose = options.scheduleClose ?? setTimeout + const scheduleClose = options.scheduleClose ?? defaultScheduleTimer scheduleClose(options.onRequestClose, 0) } } diff --git a/mobile/src/transport/global-timer-receiver-test-fakes.ts b/mobile/src/transport/global-timer-receiver-test-fakes.ts new file mode 100644 index 00000000000..8300f16d626 --- /dev/null +++ b/mobile/src/transport/global-timer-receiver-test-fakes.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest' + +// Mirrors the browser rule for WebIDL global operations: an explicit non-global +// receiver is rejected, while an absent one resolves to the global. +function assertGlobalReceiver(receiver: unknown): void { + if (receiver !== undefined && receiver !== globalThis) { + throw new TypeError('Illegal invocation') + } +} + +export type GuardedTimerHandles = { + scheduled: ReturnType[] + cleared: ReturnType[] +} + +// Wraps whatever timers are currently installed (real or vitest's fakes), so callers +// keep using vi.advanceTimersByTime. Undo with vi.unstubAllGlobals(). +export function installIllegalInvocationTimerGuards(): GuardedTimerHandles { + const scheduleTimer = globalThis.setTimeout + const cancelTimer = globalThis.clearTimeout + const handles: GuardedTimerHandles = { scheduled: [], cleared: [] } + vi.stubGlobal('setTimeout', function (this: unknown, handler: () => void, ms?: number) { + assertGlobalReceiver(this) + const handle = scheduleTimer(handler, ms) + handles.scheduled.push(handle) + return handle + }) + vi.stubGlobal('clearTimeout', function (this: unknown, handle: ReturnType) { + assertGlobalReceiver(this) + handles.cleared.push(handle) + cancelTimer(handle) + }) + return handles +} diff --git a/mobile/src/transport/host-open-retry-scheduler.test.ts b/mobile/src/transport/host-open-retry-scheduler.test.ts index 6ee2b18bea5..6235521b256 100644 --- a/mobile/src/transport/host-open-retry-scheduler.test.ts +++ b/mobile/src/transport/host-open-retry-scheduler.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes' import { HostOpenRetryScheduler } from './host-open-retry-scheduler' describe('HostOpenRetryScheduler', () => { beforeEach(() => vi.useFakeTimers()) - afterEach(() => vi.useRealTimers()) + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) it('advances through bounded retry tiers', async () => { let generation = 1 @@ -45,6 +49,23 @@ describe('HostOpenRetryScheduler', () => { expect(open).toHaveBeenCalledTimes(2) }) + it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => { + const timers = installIllegalInvocationTimerGuards() + const open = vi.fn() + const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open }) + + scheduler.recordFailure('host-1', 1) + await vi.advanceTimersByTimeAsync(1_000) + expect(open).toHaveBeenCalledOnce() + + scheduler.recordFailure('host-1', 1) + scheduler.cancel('host-1') + expect(timers.cleared).toHaveLength(1) + expect(timers.cleared[0]).toBe(timers.scheduled[1]) + await vi.advanceTimersByTimeAsync(60_000) + expect(open).toHaveBeenCalledOnce() + }) + it('cancels retry delivery', async () => { const open = vi.fn() const scheduler = new HostOpenRetryScheduler({ canRetry: () => true, open }) diff --git a/mobile/src/transport/host-open-retry-scheduler.ts b/mobile/src/transport/host-open-retry-scheduler.ts index 94bf1cc5843..fd69d81fa43 100644 --- a/mobile/src/transport/host-open-retry-scheduler.ts +++ b/mobile/src/transport/host-open-retry-scheduler.ts @@ -1,3 +1,5 @@ +import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler' + const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 15_000, 30_000, 60_000] as const type RetryState = { @@ -9,18 +11,18 @@ type RetryState = { type HostOpenRetrySchedulerOptions = { canRetry: (hostId: string, generation: number) => boolean open: (hostId: string) => void - setTimer?: typeof setTimeout + setTimer?: ScheduleTimer clearTimer?: typeof clearTimeout } export class HostOpenRetryScheduler { private readonly states = new Map() - private readonly setTimer: typeof setTimeout + private readonly setTimer: ScheduleTimer private readonly clearTimer: typeof clearTimeout constructor(private readonly options: HostOpenRetrySchedulerOptions) { - this.setTimer = options.setTimer ?? setTimeout - this.clearTimer = options.clearTimer ?? clearTimeout + this.setTimer = options.setTimer ?? defaultScheduleTimer + this.clearTimer = options.clearTimer ?? defaultCancelTimer } recordFailure(hostId: string, generation: number): { failureCount: number; nextDelayMs: number } { diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts index 3ae31edd07f..c3b3464a3ba 100644 --- a/mobile/src/transport/mobile-direct-return-probe.ts +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -1,6 +1,7 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import type { RpcClient } from './rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { HostProfile } from './types' import type { MobileConnectionPath } from './stable-logical-rpc-client' @@ -17,7 +18,7 @@ export class DirectReturnProbe { constructor( private readonly deps: { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout openDirect: (endpoint: string) => RpcClient }, diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 7ec5f28b945..b7cab59c49a 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -11,6 +11,7 @@ import { import { saveHost } from './host-store' import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' type EndpointLifecycle = { @@ -104,7 +105,7 @@ function createSupervisor( onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, - setTimer: setTimeout, - clearTimer: clearTimeout + setTimer: defaultScheduleTimer, + clearTimer: defaultCancelTimer }) } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index 2a784fd8895..247c2ec051e 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -4,6 +4,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { ConnectionLogSink, HostProfile } from './types' export type MobileEndpointSupervisorDependencies = { @@ -20,7 +21,7 @@ export type MobileEndpointSupervisorDependencies = { saveHost: (host: HostProfile) => Promise now: () => number randomBytes: (length: number) => Uint8Array - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout onLog?: ConnectionLogSink } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index e026ea26889..0ca61ab3337 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' +import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -216,8 +217,8 @@ export function dependencies( saveHost: vi.fn(async () => {}), now: Date.now, randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, + setTimer: defaultScheduleTimer, + clearTimer: defaultCancelTimer, ...overrides } } diff --git a/mobile/src/transport/mobile-relay-background-grace.test.ts b/mobile/src/transport/mobile-relay-background-grace.test.ts index 64e60b07b94..c1e2f12334f 100644 --- a/mobile/src/transport/mobile-relay-background-grace.test.ts +++ b/mobile/src/transport/mobile-relay-background-grace.test.ts @@ -11,7 +11,11 @@ describe('MobileRelayBackgroundGraceTimer', () => { vi.useFakeTimers() const onExpired = vi.fn() const timer = new MobileRelayBackgroundGraceTimer( - { now: Date.now, setTimer: setTimeout, clearTimer: clearTimeout }, + { + now: Date.now, + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle) + }, onExpired ) diff --git a/mobile/src/transport/mobile-relay-background-grace.ts b/mobile/src/transport/mobile-relay-background-grace.ts index cdea1374b4f..03990041f7b 100644 --- a/mobile/src/transport/mobile-relay-background-grace.ts +++ b/mobile/src/transport/mobile-relay-background-grace.ts @@ -1,12 +1,13 @@ import type { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' // Retain a healthy Relay briefly across routine app switches without waking the app. export const RELAY_BACKGROUND_GRACE_MS = 30_000 type RelayBackgroundGraceDependencies = { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-direct-grace-timer.ts b/mobile/src/transport/mobile-relay-direct-grace-timer.ts index df3c1ba1428..1c6c26bc634 100644 --- a/mobile/src/transport/mobile-relay-direct-grace-timer.ts +++ b/mobile/src/transport/mobile-relay-direct-grace-timer.ts @@ -1,4 +1,5 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' // Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the // whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery @@ -8,7 +9,7 @@ import type { StableLogicalRpcClient } from './stable-logical-rpc-client' const DIRECT_DIAL_GRACE_MS = 2500 type DirectGraceTimerDependencies = { - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts index 19aedfe8be1..924d8275eff 100644 --- a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts +++ b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts @@ -1,3 +1,5 @@ +import type { ScheduleTimer } from './timer-scheduler' + // Why: the relay resume lease expires; the phone must proactively re-resume a // little before the deadline (and retry shortly if a forced rotation didn't land) // so the session never lapses. Owns the single lease/rotation timer slot. @@ -12,7 +14,7 @@ const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000 export type RelayLeaseRotationDependencies = { now: () => number - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index ac6112f2c44..6432794cd17 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -393,8 +393,8 @@ function createController( { now: Date.now, randomBytes: () => new Uint8Array([128, 0]), - setTimer: setTimeout, - clearTimer: clearTimeout + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle) }, onRetry ) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index a606abbe9b4..ad8672ef69c 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -12,6 +12,7 @@ import { RelayCredentialEligibility } from './relay-credential-eligibility' import { RelayPairingRejectionLatch } from './relay-pairing-rejection-latch' import { RelayRecoveryFailureCount } from './relay-recovery-failure-count' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ScheduleTimer } from './timer-scheduler' import type { ConnectionState, ForegroundNudgeReason } from './types' type RelayCredentialLease = { expiresAt: number; version: number } @@ -19,7 +20,7 @@ type RelayCredentialLease = { expiresAt: number; version: number } export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array - setTimer: typeof setTimeout + setTimer: ScheduleTimer clearTimer: typeof clearTimeout } diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index ce7cca3fd9f..7b3790a56c3 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -236,8 +236,8 @@ function dependencies( saveHost: vi.fn(async () => {}), now: Date.now, randomBytes: (length: number) => new Uint8Array(length), - setTimer: setTimeout, - clearTimer: clearTimeout, + setTimer: (handler, ms) => setTimeout(handler, ms), + clearTimer: (handle) => clearTimeout(handle), ...overrides } } diff --git a/mobile/src/transport/relay-host-signed-out-verdict.test.ts b/mobile/src/transport/relay-host-signed-out-verdict.test.ts index 2607b922b58..3d510793540 100644 --- a/mobile/src/transport/relay-host-signed-out-verdict.test.ts +++ b/mobile/src/transport/relay-host-signed-out-verdict.test.ts @@ -138,11 +138,11 @@ describe('RelayReconnectController cadence', () => { { now: () => 0, randomBytes: () => new Uint8Array([0, 0]), - setTimer: ((callback: () => void, delay: number) => { + setTimer: (callback, delay) => { delays.push(delay) return 1 as unknown as ReturnType - }) as unknown as typeof setTimeout, - clearTimer: (() => {}) as unknown as typeof clearTimeout + }, + clearTimer: () => {} }, vi.fn() ) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts new file mode 100644 index 00000000000..dd79fa5aa1d --- /dev/null +++ b/mobile/src/transport/rpc-session-liveness-watchdog-default-timers.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installIllegalInvocationTimerGuards } from './global-timer-receiver-test-fakes' +import { + LIVENESS_IDLE_MS, + LIVENESS_PROBE_TIMEOUT_MS, + RpcSessionLivenessWatchdog +} from './rpc-session-liveness-watchdog' + +describe('RpcSessionLivenessWatchdog default timers', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('schedules and clears with no injected timers when the global rejects a non-global receiver', async () => { + const timers = installIllegalInvocationTimerGuards() + const sendProbe = vi.fn(() => true) + const terminate = vi.fn() + const watchdog = new RpcSessionLivenessWatchdog({ transport: 'direct', sendProbe, terminate }) + const identity = {} + + watchdog.start(identity) + await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS) + expect(sendProbe).toHaveBeenCalledOnce() + + watchdog.stop(identity) + expect(timers.cleared).toHaveLength(1) + expect(timers.cleared[0]).toBe(timers.scheduled[1]) + await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS) + expect(terminate).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index cbe891f810c..1b2251e1373 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -1,3 +1,5 @@ +import { defaultCancelTimer, defaultScheduleTimer, type ScheduleTimer } from './timer-scheduler' + export const LIVENESS_IDLE_MS = 20_000 export const LIVENESS_PROBE_TIMEOUT_MS = 8_000 export const MISSED_PROBE_LIMIT = 3 @@ -17,7 +19,7 @@ type WatchdogOptions = { missedProbeLimit?: number voluntaryProbeMinIntervalMs?: number now?: () => number - setTimer?: typeof setTimeout + setTimer?: ScheduleTimer clearTimer?: typeof clearTimeout } @@ -41,7 +43,7 @@ export class RpcSessionLivenessWatchdog { private readonly missedProbeLimit: number private readonly voluntaryProbeMinIntervalMs: number private readonly now: () => number - private readonly setTimer: typeof setTimeout + private readonly setTimer: ScheduleTimer private readonly clearTimer: typeof clearTimeout constructor(private readonly options: WatchdogOptions) { @@ -50,8 +52,8 @@ export class RpcSessionLivenessWatchdog { this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0 this.now = options.now ?? Date.now - this.setTimer = options.setTimer ?? setTimeout - this.clearTimer = options.clearTimer ?? clearTimeout + this.setTimer = options.setTimer ?? defaultScheduleTimer + this.clearTimer = options.clearTimer ?? defaultCancelTimer } start(identity: RpcSessionIdentity): void { diff --git a/mobile/src/transport/timer-receiver-census.test.ts b/mobile/src/transport/timer-receiver-census.test.ts new file mode 100644 index 00000000000..8ff818a139b --- /dev/null +++ b/mobile/src/transport/timer-receiver-census.test.ts @@ -0,0 +1,124 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' + +const SOURCE_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const TIMER_GLOBALS = new Set(['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']) +const GLOBAL_RECEIVERS = new Set(['global', 'globalThis', 'window']) +const SHARED_DEFAULTS = new Set(['defaultScheduleTimer', 'defaultCancelTimer']) + +// Sites that take their default from timer-scheduler; the census is meaningless if it +// cannot see them, so an empty or misdirected walk fails instead of passing vacuously. +const SHARED_DEFAULT_SITES = [ + 'files/mobile-file-preview-navigation.ts', + 'transport/host-open-retry-scheduler.ts', + 'transport/mobile-endpoint-lifecycle.ts', + 'transport/mobile-endpoint-supervisor-test-fakes.ts', + 'transport/rpc-session-liveness-watchdog.ts' +] + +const PARKING_OPERATORS = new Set([ + ts.SyntaxKind.QuestionQuestionToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ts.SyntaxKind.BarBarToken, + ts.SyntaxKind.BarBarEqualsToken +]) + +type Census = { parked: string[]; shared: string[] } + +function productFiles(): string[] { + return readdirSync(SOURCE_ROOT, { recursive: true, encoding: 'utf8' }) + .filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$|\.generated\.ts$/.test(entry)) + .map((entry) => entry.replaceAll('\\', '/')) +} + +function timerName(node: ts.Node): string | null { + if (ts.isIdentifier(node) && TIMER_GLOBALS.has(node.text)) { + return node.text + } + if ( + ts.isPropertyAccessExpression(node) && + TIMER_GLOBALS.has(node.name.text) && + ts.isIdentifier(node.expression) && + GLOBAL_RECEIVERS.has(node.expression.text) + ) { + return node.name.text + } + return null +} + +// The receiver is only lost once the function is parked somewhere a later call reaches +// through: a nullish/logical default, an object literal member, or an assignment onto a +// property. A plain local capture stays legal: calling it bare leaves the receiver undefined. +function parkedTimer(node: ts.Node): ts.Node | null { + if (ts.isBinaryExpression(node)) { + const operator = node.operatorToken.kind + const parks = + PARKING_OPERATORS.has(operator) || + (operator === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left)) + return parks ? node.right : null + } + if (ts.isPropertyAssignment(node)) { + return node.initializer + } + if (ts.isShorthandPropertyAssignment(node)) { + return node.name + } + return null +} + +function scanSource(relativePath: string, text: string, census: Census): void { + const sourceFile = ts.createSourceFile(relativePath, text, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + const candidate = parkedTimer(node) + const name = candidate === null ? null : timerName(candidate) + if (candidate !== null && name !== null) { + const line = sourceFile.getLineAndCharacterOfPosition(candidate.getStart(sourceFile)).line + 1 + census.parked.push(`${relativePath}:${line} ${name}`) + } + if (ts.isIdentifier(node) && SHARED_DEFAULTS.has(node.text)) { + census.shared.push(relativePath) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) +} + +function parkedIn(source: string): string[] { + const census: Census = { parked: [], shared: [] } + scanSource('fixture.ts', source, census) + return census.parked +} + +describe('global timer receiver census', () => { + const census: Census = { parked: [], shared: [] } + for (const relativePath of productFiles()) { + scanSource(relativePath, readFileSync(`${SOURCE_ROOT}${relativePath}`, 'utf8'), census) + } + + it('sees the shared receiver-free defaults, so an empty or misdirected walk cannot pass', () => { + expect(census.shared).toEqual(expect.arrayContaining(SHARED_DEFAULT_SITES)) + }) + + it('parks no bare global timer where a later call would supply a non-global receiver', () => { + expect(census.parked).toEqual([]) + }) + + it.each([ + ['a nullish default', 'const schedule = injected ?? setTimeout'], + ['a logical default', 'const schedule = injected || setTimeout'], + ['a nullish assignment default', 'schedule ??= setTimeout'], + ['a logical assignment default', 'schedule ||= setTimeout'], + ['an object literal member', 'const deps = { setTimer: setTimeout }'], + ['a shorthand object member', 'const deps = { setTimeout }'], + ['an assignment onto a property', 'this.setTimer = setTimeout'], + ['a qualified global read', 'const deps = { setTimer: globalThis.setTimeout }'] + ])('flags a global timer parked by %s', (_form, source) => { + expect(parkedIn(source)).toEqual(['fixture.ts:1 setTimeout']) + }) + + it('leaves a plain local capture alone, which a bare call invokes receiver-free', () => { + expect(parkedIn('const schedule = globalThis.setTimeout')).toEqual([]) + }) +}) diff --git a/mobile/src/transport/timer-scheduler.ts b/mobile/src/transport/timer-scheduler.ts new file mode 100644 index 00000000000..5e20f41a33e --- /dev/null +++ b/mobile/src/transport/timer-scheduler.ts @@ -0,0 +1,7 @@ +// The injected-timer seam's real contract: `typeof setTimeout` additionally demands +// Node's `__promisify__` member, which no injected timer (or safe wrapper) can supply. +export type ScheduleTimer = (handler: () => void, ms: number) => ReturnType + +// Why: browsers throw Illegal invocation when a global timer is called with a non-global receiver; Hermes does not. +export const defaultScheduleTimer: ScheduleTimer = (handler, ms) => setTimeout(handler, ms) +export const defaultCancelTimer: typeof clearTimeout = (handle) => clearTimeout(handle) diff --git a/mobile/tests-typecheck-baseline.txt b/mobile/tests-typecheck-baseline.txt index 0e2c3b3c0f7..34dd8f7cf66 100644 --- a/mobile/tests-typecheck-baseline.txt +++ b/mobile/tests-typecheck-baseline.txt @@ -103,7 +103,6 @@ src/transport/host-removal-lifecycle.test.ts src/transport/host-status-gates.test.ts src/transport/host-store.test.ts src/transport/mobile-endpoint-supervisor-nudge.test.ts -src/transport/mobile-relay-background-grace.test.ts src/transport/mobile-relay-background-lifecycle.test.ts src/transport/mobile-relay-direct-upgrade.test.ts src/transport/mobile-relay-e2ee-link.test.ts