# 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 ```ts 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 ```ts 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` 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.