Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,18 @@ and offers RETRY — a fresh join on the same code — or PLAY SOLO. A host with
policy, headless; `simcheck` walks it through a drop inside the grace, a recovery, a second
outage that runs out, ICE failing, and the channel closing.

**A dropped route gets one automatic recovery window.** Signalling stays connected for the
life of the peer. Either browser can notice an outage, but only the joiner creates the ICE
restart offer, avoiding competing offers. The existing peer connection, data channel, and
seat stay in place. Restart messages are addressed to the established peer and carry an
attempt number; early candidates wait for their description, and lost offers/answers are
retried. After eight seconds without recovery the existing CONNECTION LOST / RETRY flow
takes over. Closing or leaving also closes recovery listeners and the joiner's signalling.
This can repair a lost route when another direct path is available; it does not provide a
TURN relay or keep a match alive after the host leaves. `net/recovery.ts` owns negotiation
and its deadline; `scripts/recovery-check.ts` exercises loss, ordering, peer binding and
cleanup as part of the full simulation suite.

**Choose the wing before it flies.** `net/lobby.ts` reserves a seat only after a valid
protocol-v2 HELLO with a hull choice. Every waiting browser gets a versioned LOBBY roster:
seat number, hull, human/AI pilot, and which seat is yours. Host hull changes and departures
Expand Down
38 changes: 37 additions & 1 deletion scripts/mutate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ import { readFileSync, writeFileSync } from 'node:fs'

/** @type {{ name: string, file: string, from: string, to: string }[]} */
const MUTATIONS = [
{
name: 'recovery offers reuse the old ICE credentials',
file: 'src/net/recovery.ts',
from: 'pc.createOffer({ iceRestart: true })',
to: 'pc.createOffer({ iceRestart: false })',
},
{
name: 'a stranger can answer a recovery offer',
file: 'src/net/recovery.ts',
from: 'closed || message.from !== peer || message.to !== signal.pubkey',
to: 'closed || message.to !== signal.pubkey',
},
{
name: 'lost recovery offers are never resent',
file: 'src/net/recovery.ts',
from: 'if (now() - sentAt >= 1000) resend()',
to: '/* no retry */',
},
{
name: 'recovery waits forever instead of offering manual retry',
file: 'src/net/recovery.ts',
from: 'if (now() >= deadline) { fail(); return }',
to: '/* no deadline */',
},
{
name: 'early restart candidates are discarded at remote description',
file: 'src/net/recovery.ts',
from: 'for (const candidate of early.get(generation) ?? [])',
to: 'for (const candidate of [])',
},
{
name: 'recovery signalling listener survives channel cleanup',
file: 'src/net/recovery.ts',
from: ' stop()',
to: ' /* leave the subscription alive */',
},
/* ---- Wing reservations and launch --------------------------------------- */
{
name: 'lobby ignores the requested hull',
Expand Down Expand Up @@ -995,7 +1031,7 @@ function runSuite() {
* If you added or removed checks on purpose, bump this in the same commit. If you did not,
* something stopped running.
*/
const EXPECTED_ASSERTIONS = 718
const EXPECTED_ASSERTIONS = 737
const PASS_SUMMARY = 'All checks passed.'
const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/

Expand Down
137 changes: 137 additions & 0 deletions scripts/recovery-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { createIceRecovery } from '../src/net/recovery'
import type { Signal, SignalMessage } from '../src/net/signal'

/** Exercise negotiation ordering and loss separately from the browser's ICE engine. */
export async function testIceRecovery(check: (label: string, condition: boolean, detail?: string) => void) {
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() }
function rig(initiator = true) {
let time = 0
let listener: (message: SignalMessage) => void = () => {}
let removed = 0
let failures = 0
let offers = 0
let answers = 0
const states: string[] = []
const sent: SignalMessage[] = []
const applied: RTCSessionDescriptionInit[] = []
const candidates: RTCIceCandidateInit[] = []
let onCandidate: (event: RTCPeerConnectionIceEvent) => void = () => {}
const signal: Signal = {
pubkey: 'self',
async send(message) { sent.push({ ...message, from: 'self' } as SignalMessage) },
listen(handler) { listener = handler; return () => { removed++; listener = () => {} } },
close() {},
}
const fake = {
iceConnectionState: 'disconnected', signalingState: 'stable',
localDescription: null as RTCSessionDescriptionInit | null,
async createOffer(options?: RTCOfferOptions) {
offers++
return { type: 'offer', sdp: options?.iceRestart ? 'restart credentials' : 'old credentials' } as RTCSessionDescriptionInit
},
async createAnswer() { answers++; return { type: 'answer', sdp: 'new answer' } as RTCSessionDescriptionInit },
async setLocalDescription(description: RTCSessionDescriptionInit) {
fake.localDescription = description
fake.signalingState = description.type === 'offer' ? 'have-local-offer' : 'stable'
},
async setRemoteDescription(description: RTCSessionDescriptionInit) {
applied.push(description)
fake.signalingState = description.type === 'offer' ? 'have-remote-offer' : 'stable'
},
async addIceCandidate(candidate: RTCIceCandidateInit) { candidates.push(candidate) },
addEventListener(_type: string, handler: typeof onCandidate) { onCandidate = handler },
removeEventListener() { onCandidate = () => {} },
}
const recovery = createIceRecovery({
pc: fake as unknown as RTCPeerConnection, signal, peer: 'peer', initiator,
now: () => time, onIce: state => states.push(state), onFailure: () => { failures++ },
})
return {
recovery, sent, applied, candidates, fake, states,
receive: (message: SignalMessage) => listener(message),
candidate: (candidate: RTCIceCandidateInit) => onCandidate({ candidate: { toJSON: () => candidate } } as RTCPeerConnectionIceEvent),
tick: (value: number) => { time = value; recovery.poll() },
counts: () => ({ removed, failures, offers, answers }),
}
}
console.log('\nAn ICE restart preserves negotiation identity and has a bounded fallback')
const client = rig()
client.recovery.ice('failed')
await flush()
check('failed ICE starts a restart offer before declaring the link lost', client.states[0] === 'disconnected' && client.counts().failures === 0 && client.sent.some(m => m.type === 'restart-offer' && m.sdp === 'restart credentials'))
client.recovery.ice('disconnected')
await flush()
check('repeated outage events keep one negotiation', client.counts().offers === 1)
client.receive({ type: 'restart-answer', from: 'stranger', to: 'self', generation: 1, sdp: 'forged' })
client.receive({ type: 'restart-answer', from: 'peer', to: 'elsewhere', generation: 1, sdp: 'misaddressed' })
client.receive({ type: 'restart-answer', from: 'peer', to: 'self', generation: 2, sdp: 'future' })
await flush()
check('only the established peer and current addressed attempt can answer', client.applied.length === 0)
client.receive({ type: 'restart-ice', from: 'peer', to: 'self', generation: 1, candidate: { candidate: 'early' } })
await flush()
check('early candidates wait for the matching remote description', client.candidates.length === 0)
const beforeRetry = client.sent.length
client.candidate({ candidate: 'local' })
client.tick(1000)
check('lost offers and their candidates are retransmitted', client.sent.length >= beforeRetry + 3)
client.receive({ type: 'restart-answer', from: 'peer', to: 'self', generation: 1, sdp: 'answer' })
await flush()
check('the answer installs once and drains early candidates', client.applied.length === 1 && client.candidates[0]?.candidate === 'early')
client.receive({ type: 'restart-answer', from: 'peer', to: 'self', generation: 1, sdp: 'duplicate' })
await flush()
check('duplicate answers cannot overwrite a settled remote description', client.applied.length === 1)
client.fake.iceConnectionState = 'connected'
client.recovery.ice('connected')
client.tick(9000)
check('connected ICE cancels the old outage deadline', client.states.at(-1) === 'connected' && client.counts().failures === 0)
client.fake.iceConnectionState = 'failed'
client.recovery.ice('failed')
await flush()
check('a later outage uses a fresh attempt on the same connection', client.counts().offers === 2 && client.sent.some(m => m.type === 'restart-offer' && m.generation === 2))
const previousCandidates = client.candidates.length
client.receive({ type: 'restart-ice', from: 'peer', to: 'self', generation: 1, candidate: { candidate: 'stale' } })
await flush()
check('old-attempt candidates cannot enter the new negotiation', client.candidates.length === previousCandidates)
client.tick(16999)
check('the recovery gets the full eight-second grace', client.counts().failures === 0)
client.recovery.ice('disconnected')
client.tick(17000)
check('repeated disconnection cannot extend recovery indefinitely', client.counts().failures === 1 && client.states.at(-1) === 'failed')
const endedAt = client.sent.length
client.recovery.ice('disconnected')
client.candidate({ candidate: 'after close' })
client.tick(30000)
await flush()
check('terminal failure removes signalling and stops retries', client.sent.length === endedAt && client.counts().removed === 1)

const host = rig(false)
host.recovery.ice('disconnected')
await flush()
check('the host asks the joiner to offer instead of competing with it', host.counts().offers === 0 && host.sent[0]?.type === 'restart-request')
host.receive({ type: 'restart-ice', from: 'peer', to: 'self', generation: 1, candidate: { candidate: 'before offer' } })
const offer: SignalMessage = { type: 'restart-offer', from: 'peer', to: 'self', generation: 1, sdp: 'offer' }
host.receive(offer)
host.receive(offer)
await flush()
check('duplicate offers install one description and one answer', host.applied.length === 1 && host.counts().answers === 1)
check('host candidates arriving before the offer are preserved', host.candidates[0]?.candidate === 'before offer')
const beforeDuplicate = host.sent.length
host.fake.iceConnectionState = 'connected'
host.recovery.ice('connected')
host.receive(offer)
await flush()
check('a lost answer can be resent even after the host reconnects', host.sent.length > beforeDuplicate && host.sent.at(-1)?.type === 'restart-answer')
host.recovery.close()

const cancelled = rig()
cancelled.recovery.ice('disconnected')
cancelled.recovery.close()
await flush()
check('leaving during offer creation cannot publish a late negotiation', cancelled.sent.length === 0 && cancelled.counts().removed === 1)

const requested = rig()
requested.receive({ type: 'restart-request', from: 'peer', to: 'self', generation: 1 })
await flush()
check('a host-reported outage starts recovery on the joiner', requested.counts().offers === 1)
requested.recovery.close()
}
2 changes: 2 additions & 0 deletions scripts/simcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import * as THREE from 'three'
import { testIceRecovery } from './recovery-check'
import type { Audio } from '../src/core/audio'
import type { Input, InputState } from '../src/core/input'
import type { MatchResult, RunResult, SeatLine } from '../src/core/scores'
Expand Down Expand Up @@ -7993,6 +7994,7 @@ testALinkThatDropsIsNoticed()
testTheWingLaunchesItsReservations()
testPredictedWeaponsAreOnlyPresentation()
testClientWeaponsUnderLatency()
await testIceRecovery(check)

console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`)
process.exit(failures === 0 ? 0 : 1)
4 changes: 3 additions & 1 deletion src/net/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,11 @@ export async function joinMatch(game: Game, code: string, ship: ShipId, handlers
}
try {
channel = await connectAsClient(signal, handlers.status, hooks)
} finally {
} catch (error) {
signal.close()
throw error
}
channel.onClose(() => signal.close())
if (!handlers.active()) { channel.close(); throw new Error('join cancelled') }
const open = channel
open.onClose(() => monitor.closed())
Expand Down
Loading