feat(webrtc): recover a dropped connection without a signalling server; release v5.6.0
CodeQL Analysis / Analyze CodeQL (push) Canceled after 0s
Deploy Application / deploy (push) Canceled after 0s
Mirror to Codeberg / mirror (push) Canceled after 0s
Mirror to PrivacyGuides / mirror (push) Canceled after 0s

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:
lockbitchat
2026-08-02 21:16:13 -04:00
parent 60bf037ef9
commit 2a7142c722
18 changed files with 2794 additions and 224 deletions
+17
View File
@@ -75,6 +75,23 @@ function withTwoSessions() {
assert.equal(state.sessions.a.peerPresence, null, 'no stale presence after reconnect');
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'available' });
assert.equal(state.sessions.a.peerPresence, 'available', 'fresh presence applies after reconnect');
// A session repairing its network path is still a live session: blanking the
// peer's presence would make a two-second glitch look like a disconnect.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'reconnecting' });
assert.equal(state.sessions.a.peerPresence, 'available', 'presence survives a path repair');
}
// A reconnecting session reads as in-progress (amber), not as dropped (red).
{
const entry = createSessionEntry({ id: 'a', peerLabel: 'phone' });
entry.status = 'reconnecting';
const d = decorateSession(entry, 'a');
assert.equal(d.headerSub, 'Reconnecting…');
const dropped = createSessionEntry({ id: 'b', peerLabel: 'phone' });
dropped.status = 'disconnected';
assert.notEqual(d.dot, decorateSession(dropped, 'b').dot, 'reconnecting must not look dropped');
}
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.