mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): default injected timers to receiver-free wrappers (#21416)
* fix(mobile): default injected timers to receiver-free wrappers Every transport class stored a global timer function on an object and then called it back through that object, so the receiver was the instance or the dependency bag rather than the global. Hermes ignores the receiver; browsers reject it with TypeError: Illegal invocation, which makes the web build fatal at the first retry, liveness probe, or relay grace timer. Default each injected timer to a wrapper that calls the global receiver-free, and narrow the seam's type from `typeof setTimeout` to the call signature it actually uses. Node's `typeof setTimeout` also demands a `__promisify__` member that no injected timer or wrapper can supply, so the wrapper cannot satisfy it. Pruning mobile-relay-background-grace.test.ts from the typecheck baseline follows: the narrower type makes that file check clean. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the default timers against a browser receiver check Both classes are now constructed with no injected timers under a global setTimeout/clearTimeout that throws Illegal invocation for any explicit non-global receiver, mirroring the WebIDL rule. The watchdog gets its own file because its existing test is grandfathered out of the typecheck ratchet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the default clear leg and drop bare timer injections The clear assertions were vacuous: cancel() and stop() also drop the state a fired callback checks, so a no-op default clearTimer stayed green. Both tests now assert the wrapped global clearTimeout received the exact handle setTimeout returned, which fails when that default is mutated to a no-op. Three relay tests injected bare setTimeout/clearTimeout into dependency bags, the same receiver shape the product fix removed; inert under node, fatal under jsdom. relay-host-signed-out-verdict drops two `as unknown as typeof setTimeout` casts, since ScheduleTimer now types those arrows contextually. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): census bare global timers parked in properties and defaults mobile-endpoint-lifecycle could regress to bare globals with every other test green, because nothing there is reachable from a unit test. Walk every product file's AST and fail on a global timer parked where a later call reaches it through a receiver: a `??` or `||` default, an object literal member, or an assignment onto a property. A plain local capture stays legal, since calling it bare leaves the receiver undefined. A separate test asserts the walk sees the five fixed sites' wrapper shape, so an empty or misdirected scan fails instead of passing vacuously. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): define the receiver-free timer defaults once Five hand-written wrappers each restated the same invariant, so five places could drift. timer-scheduler now exports defaultScheduleTimer and defaultCancelTimer, and carries the reason for them; every site takes its default from there. The census keys its presence precondition on those two identifiers instead of the arrow shape. The census also missed `??=` and `||=`, which park a global exactly like their non-assigning forms. Both are handled now, with a parsed-source case per parking form and one for the local capture that stays legal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout>[]
|
||||
cleared: ReturnType<typeof setTimeout>[]
|
||||
}
|
||||
|
||||
// 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<typeof setTimeout>) {
|
||||
assertGlobalReceiver(this)
|
||||
handles.cleared.push(handle)
|
||||
cancelTimer(handle)
|
||||
})
|
||||
return handles
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<string, RetryState>()
|
||||
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 } {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
setTimer: typeof setTimeout
|
||||
setTimer: ScheduleTimer
|
||||
clearTimer: typeof clearTimeout
|
||||
onLog?: ConnectionLogSink
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout>
|
||||
}) as unknown as typeof setTimeout,
|
||||
clearTimer: (() => {}) as unknown as typeof clearTimeout
|
||||
},
|
||||
clearTimer: () => {}
|
||||
},
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout>
|
||||
|
||||
// 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user