Files
skills/docs/networking/drive/drive-webrtc/src/client/ice/ice.md
2026-05-29 08:20:56 +01:00

4.2 KiB

ICE Handlers

(no submodules)

Purpose

The ice module owns the two thin pieces of ICE handling that WebRTCClient needs but that don't belong in the orchestrator: listener attachment with auto-restart policy, and a typed wrapper around addIceCandidate. Keeping this logic in its own module means the restart algorithm is testable in isolation (against a structural FakeRTCPeerConnection) and reusable if another transport wants to adopt the same bounded-restart semantics.

The restart policy is intentionally bounded — three attempts by default — because mobile networks routinely cycle iceConnectionState === "failed" during handover. We want to ride those out without flapping forever; once the budget is exhausted, onStateChange("failed") fires and the caller (typically WebRTCClient) decides whether to close the peer or escalate to the reconnect controller.

Features

  • attachIceHandlers(pc, peerId, options) installs both icecandidate and iceconnectionstatechange listeners on an RTCPeerConnection and returns an idempotent dispose() handle.
  • Bounded auto-restart: on iceConnectionState === "failed" the helper calls pc.restartIce() and emits onRestart({ peerId, attempt, timestamp }) until maxRestartAttempts (default 3) is reached.
  • restartIce throws are swallowed (closed connections re-fire the terminal state through onStateChange).
  • applyIceCandidate(pc, candidate) wraps pc.addIceCandidate and translates failures into DriveWebRtcError(ICE_FAILED) with the original error preserved via cause.
  • Injectable clock keeps timestamp deterministic in tests.

Usage: Basic Example

import { applyIceCandidate, attachIceHandlers } from "@suaveplan/drive-webrtc";

const handle = attachIceHandlers(pc, "peer-A", {
    onCandidate: (c) => signaling.send({ type: "ice-candidate", payload: c }),
    onStateChange: (s) => console.log("ice state", s),
    onRestart: (info) => console.log("restart", info),
});

// Later, when a remote candidate arrives:
await applyIceCandidate(pc, remoteCandidate);

// On peer teardown:
handle.dispose();

Usage: Advanced Example

import { attachIceHandlers } from "@suaveplan/drive-webrtc";

let nowMs = 0;
const clock = { now: () => nowMs };

const restarts: { attempt: number; timestamp: number }[] = [];
const states: RTCIceConnectionState[] = [];

const handle = attachIceHandlers(fakePc, "peer-X", {
    onCandidate: () => {},
    onStateChange: (s) => states.push(s),
    onRestart: (info) => restarts.push(info),
    maxRestartAttempts: 2,
    clock,
});

// Simulate three failures: the first two trigger restartIce, the third
// surfaces as a terminal "failed" without restart.
nowMs = 100; fakePc.simulateIceState("failed");
nowMs = 200; fakePc.simulateIceState("failed");
nowMs = 300; fakePc.simulateIceState("failed");
// restarts === [{ attempt: 1, timestamp: 100 }, { attempt: 2, timestamp: 200 }]
// states === ["failed", "failed", "failed"]
// handle.restartAttempts === 2

handle.dispose();

API Reference

attachIceHandlers(pc, peerId, options): IceHandlersHandle

  • pc: RTCPeerConnection
  • peerId: string
  • options.onCandidate(candidate: RTCIceCandidate): void
  • options.onStateChange(state: RTCIceConnectionState): void
  • options.onRestart(info: IceRestartInfo): void
  • options.maxRestartAttempts?: number (default 3)
  • options.clock?: { now: () => number }

Returns { dispose(), restartAttempts }. dispose() is idempotent.

applyIceCandidate(pc, candidate): Promise<void>

Wraps pc.addIceCandidate(candidate). Throws DriveWebRtcError(ICE_FAILED) with cause populated on any failure.

Implementation Notes

  • EventTarget-style addEventListener/removeEventListener is used rather than oniceconnectionstatechange = ... assignment so that the module never clobbers a listener the caller installed for another purpose.
  • pc.restartIce() is guarded by typeof pc.restartIce === "function" to remain robust against older RTCPeerConnection shims and the structural test double.
  • dispose() flips a local flag before removing listeners so an in-flight event-dispatch loop that re-enters during teardown is a no-op rather than a double-emit.