fix(mobile): a resume during an urgent probe keeps its deadline, so a tap burst cannot starve the verdict

This commit is contained in:
Jinwoo-H
2026-09-07 15:42:55 -04:00
parent 8b69d35947
commit c24901b755
3 changed files with 22 additions and 11 deletions
@@ -12,8 +12,9 @@ const rotated = {
relay: { v: 1, directorUrl: 'https://relay.example', cellUrl: 'https://c1.relay.example' }
}
const rotate = vi.hoisted(() => vi.fn())
const needsRotation = vi.hoisted(() => vi.fn(() => true))
vi.mock('./mobile-relay-credential-rotation', () => ({
mobileRelayCredentialNeedsRotation: () => true,
mobileRelayCredentialNeedsRotation: needsRotation,
rotateMobileRelayCredential: rotate
}))
@@ -40,6 +41,14 @@ function fixture(overrides: { persistResolvedRelay?: () => Promise<void> } = {})
}
describe('MobileRelayCredentialRefresh', () => {
it('does nothing unforced while the credential is still fresh', async () => {
needsRotation.mockReturnValueOnce(false)
const { refresh, order } = fixture()
await refresh.run(false)
expect(order).toEqual([])
expect(rotate).not.toHaveBeenCalled()
})
it('lifts the gate and starts the relay race only after the endpoint is durable', async () => {
const { refresh, order } = fixture()
await refresh.run(true)
@@ -276,14 +276,13 @@ describe('RpcSessionLivenessWatchdog', () => {
})
watchdog.start(identity)
// Resumes every 1.5 s on a black-holed socket: each lands inside the 2 s urgent
// window, so none may re-arm the deadline or the verdict never comes.
watchdog.probeNow(identity, 'resume')
await vi.advanceTimersByTimeAsync(2_000)
expect(terminate).not.toHaveBeenCalled()
// The second resume restarts the 2 s clock on the miss already booked.
watchdog.probeNow(identity, 'resume')
await vi.advanceTimersByTimeAsync(1_000)
watchdog.probeNow(identity, 'resume')
await vi.advanceTimersByTimeAsync(2_000)
for (let elapsed = 0; elapsed < 6_000; elapsed += 1_500) {
await vi.advanceTimersByTimeAsync(1_500)
watchdog.probeNow(identity, 'resume')
}
expect(terminate).toHaveBeenCalledOnce()
})
@@ -127,9 +127,12 @@ export class RpcSessionLivenessWatchdog {
this.lastVoluntaryProbeAt = now
// Why: a resume is a new observation on a cold radio, so it starts the urgent window
// with a clean budget (startProbe zeroes the count on a profile switch). A resume that
// lands while an urgent probe is already in flight restarts the clock but keeps the
// count: otherwise repeated resumes, or a user tapping reconnect on a dead socket,
// zero the budget on every tap and the verdict never lands.
// lands while an urgent probe is already in flight keeps that probe's deadline and
// count: re-arming the 2 s clock on every tap would let a user tapping reconnect, or
// an AppState flap, hold a dead socket open for as long as they keep tapping.
if (urgent && this.probing && this.profile === this.urgentProfile) {
return
}
this.startProbe(identity, urgent ? this.urgentProfile : this.ordinaryProfile)
}