feat(webrtc): recover a dropped connection without a signalling server; release v5.6.0
A chat no longer dies when the network moves under it. A NAT rebind, a lift, a Wi-Fi radio parking itself, a phone that dozed: the session repairs its own network path in place, and the messages typed meanwhile go out when it returns. Recovery is an ICE restart, which renegotiates only the transport path — the DTLS handshake, the session keys and the SCTP association carrying the data channel all sit above ICE and survive it. The renegotiation SDP therefore travels over the existing end-to-end encrypted, SAS-verified channel: no signalling service enters the design, and an attacker who cannot already decrypt the session cannot inject a reconnection. A restart is refused outright unless the DTLS fingerprint in the incoming SDP matches the live session's, so recovery can never re-point a conversation at a different peer. When the path is gone for good the session is ended and its data wiped rather than left half-alive: with no server there is nothing to re-signal through, and a conversation whose transport is gone should not leave its plaintext in an open tab. The two cases where that is already certain are recognised in seconds instead of being retried for two minutes — a channel that has delivered nothing at all since the drop cannot carry a renegotiation, and an ICE agent left bound to a network that no longer exists reports zero candidate pairs on every restart. Judging liveness was the hard part. Silence is not evidence of death: browsers freeze backgrounded tabs outright, and a frozen peer answers nothing while being perfectly healthy. What survives that freeze is ICE consent, which the browser runs in its network stack rather than on the page's thread — so a connected ICE state means a silent peer is asleep, and only a degraded one turns an unanswered probe into a teardown. The grace window before a restart is sized to the browser's own timings: 'disconnected' arrives after ~5s of missed consent responses and is held ~25s before 'failed', and that window exists for self-healing, so restarting at the start of it broke connections that were about to recover. Several long-standing bugs surfaced along the way and are fixed here: - handleHeartbeat() was dispatched to but never defined, so every inbound heartbeat threw a TypeError and peer liveness was never observed at all. - Heartbeats were folded into the 5-minute maintenance cycle instead of running on their own timer, far too coarse to notice a dead path. - ondatachannel can hand over a channel that is already open, so the answering side's 'open' event had been dispatched before the handler was assigned and never fired, leaving that side with no heartbeat, no watchdog and no file-transfer init. The peer whose network was fine kept showing "connected" indefinitely because nothing was running to notice. - Answering a heartbeat required the peer to have finished verifying, but the two sides confirm a SAS code at different moments; for that whole window one of them could not reply and was declared dead on a healthy connection. - Sending on a channel that was not ready returned in silence: the text stayed in the box, nothing was transmitted, and nothing said why. - The send path gated on navigator.onLine and the offline/online events, which report whether an interface exists rather than whether anything is reachable. A tab the OS froze misses the 'online' edge, and this side then queued every message forever: one tick on everything it sent, while incoming messages kept arriving. Sending is now decided by the data channel, and queues drain by polling rather than on an edge, so a missed event cannot strand them. - A false offline modal appeared on a working session, because the offline event was taken at face value. tests/session-recovery.test.mjs covers the state machine, the backoff and its serialisation, the offline hold, the sleeping-peer discriminator and the identity check.
This commit is contained in:
@@ -1,5 +1,95 @@
|
||||
# Changelog
|
||||
|
||||
## v5.6.0 — Survive a dropped connection
|
||||
|
||||
A chat no longer dies when the network moves under it. Switching Wi-Fi → LTE,
|
||||
a NAT rebind, a lift, a tunnel: the session now repairs its own network path and
|
||||
the messages you typed meanwhile go out when it comes back.
|
||||
|
||||
This is done without adding any server. An ICE restart renegotiates *only* the
|
||||
transport path; the DTLS handshake, the session keys and the SCTP association
|
||||
carrying the data channel all sit above ICE and survive it. So the renegotiation
|
||||
SDP travels over the existing end-to-end encrypted, SAS-verified channel — there
|
||||
is still no signalling service anywhere in the design, and an attacker who cannot
|
||||
already decrypt the session cannot inject a reconnection.
|
||||
|
||||
### Added
|
||||
|
||||
- **Automatic session recovery.** A broken path is repaired in place with an
|
||||
in-band ICE restart, retried with a 1/2/4/8/15/30 s backoff for up to two
|
||||
minutes. Keys, SAS verification and message history are all preserved — no
|
||||
re-handshake, no comparing codes again.
|
||||
- **Liveness detection that understands sleeping devices.** A data channel keeps
|
||||
reporting `readyState: 'open'` long after the path underneath it has died — the
|
||||
classic Wi-Fi → LTE switch, where nothing closes and nothing errors, packets
|
||||
just stop. Silence alone is deliberately not treated as death, because a
|
||||
browser freezes a backgrounded tab outright and a healthy peer then answers
|
||||
nothing at all. What survives that freeze is ICE consent, which the browser
|
||||
runs in its network stack rather than on the page's thread — so a connected ICE
|
||||
state means a silent peer is asleep, not gone, and the session is left alone.
|
||||
Only when ICE itself is degraded does an unanswered probe end the session.
|
||||
- **Recovery is given up promptly when it cannot possibly work.** Every route out
|
||||
of a broken path runs over the data channel, so if nothing at all has reached
|
||||
us since the drop, no further attempt can succeed. Likewise an ICE agent left
|
||||
bound to a network that is gone — every candidate times out, every restart ends
|
||||
with zero candidate pairs — cannot be repaired by restarting it. Both are now
|
||||
recognised in seconds instead of being retried for two minutes.
|
||||
- **A session that cannot be recovered is closed, not left half-alive.** When the
|
||||
path is gone for good, the chat is ended and its data wiped — keys, queued
|
||||
messages and transcript together. There is deliberately no manual fallback: a
|
||||
conversation whose transport is gone should not leave its plaintext sitting in
|
||||
a tab, and starting a fresh one is a single, honest step.
|
||||
- **Store-and-forward while reconnecting.** Messages typed during a repair are
|
||||
queued and delivered when the path returns, in order. A send that races a
|
||||
still-settling path is re-queued rather than marked failed.
|
||||
- **The conversation stays on screen** during a repair, with a "Restoring
|
||||
connection…" state, instead of dropping you back to the connect screen.
|
||||
- **A device with no network holds the session open.** Five minutes underground
|
||||
no longer costs a session: the give-up deadline does not run while this device
|
||||
has no connectivity, and recovery retries the moment the radio returns or the
|
||||
tab comes back to the foreground.
|
||||
- `tests/session-recovery.test.mjs` covers the state machine, the backoff, the
|
||||
offline hold and the identity check below.
|
||||
|
||||
### Security
|
||||
|
||||
- **A reconnection cannot re-point a session at a different peer.** The DTLS
|
||||
fingerprint in an incoming restart offer or answer is checked against the
|
||||
fingerprint of the live, already-verified session *before* anything is applied
|
||||
to the peer connection. A mismatch aborts recovery. If there is no live
|
||||
fingerprint to compare against, the restart is refused rather than trusted.
|
||||
- Only the side that created the original offer may drive a restart; the other
|
||||
side asks. With no signalling server there is no referee to resolve glare.
|
||||
- Calls cannot be placed onto a path that is mid-repair, where the media
|
||||
renegotiation would race the ICE restart on the same connection.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every inbound heartbeat threw a `TypeError`.** `handleHeartbeat()` was
|
||||
dispatched to but never defined, so peer liveness was never actually observed.
|
||||
- **Heartbeats were sent every 5 minutes, not the intended interval** — the send
|
||||
was folded into the general maintenance cycle, far too coarse to notice a dead
|
||||
path. It now runs on its own timer, and answering one no longer requires the
|
||||
peer to have finished verifying: the two sides confirm a SAS code at different
|
||||
moments, and for that whole window one of them could not reply and was being
|
||||
declared dead on a healthy connection.
|
||||
- **The answering side never started its watchdog.** `ondatachannel` can hand over
|
||||
a channel that is already open, so the `open` event had been dispatched before
|
||||
the handler was assigned and never fired — leaving that side with no heartbeat,
|
||||
no liveness watchdog and no file-transfer init. The peer whose network was fine
|
||||
kept showing "connected" indefinitely because nothing was running to notice.
|
||||
- **A failed send no longer fails silently.** Sending on a channel that was not
|
||||
ready simply returned: the text stayed in the box, nothing was transmitted and
|
||||
nothing said why.
|
||||
- **A transient `disconnected` no longer tears down the session.** ICE reports it
|
||||
routinely and the browser usually recovers unaided; it is now given a grace
|
||||
window before a restart is spent, and it never clears verification on its own.
|
||||
- A reconnected session no longer re-announces "secure connection established" —
|
||||
it is the same session resuming, and no handshake took place.
|
||||
- Liveness bookkeeping can no longer throw ahead of message routing, where the
|
||||
surrounding catch would have swallowed it and silently dropped every inbound
|
||||
message.
|
||||
|
||||
## v5.5.4 — Fix the desktop download buttons
|
||||
|
||||
### Fixed
|
||||
|
||||
Reference in New Issue
Block a user