Reconnecting only for a problem that lasts (spec 15.23)
Paul, 30 Sep: "I saw 'Reconnecting' appear occasionally over the video,
but only for a second ... I didn't notice any interruption." The core's
reconnecting phase begins the moment the signalling stream ends, and the
stream ends on purpose at its TTL and resumes half a second later.
Mac, iPhone and web now say it only when the stream has been down 4 s, or
a connection disconnected/failed 3 s, or no audio for 2 s from someone
unmuted - that person's tile ("Reconnecting to Samuel...") unless it is
everyone - and hold it 1 s past recovery. ReconnectWatch (Swift) and
reconnectStep (room.js), named constants, unit tests either side; the
browser gate's renewal scenario and the native gate's resume/renewals fail
on any Reconnecting through renewals. The web's title pill says it too.
The connection log (os_log category connection) goes into Copy call
diagnostics. The same commit adds room-share.test.mjs's new report fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 files changed,
+638
−16
Makefile+1 −0docs/superpowers/specs/2026-09-07-meet-design.md+58 −3internal/calls/static/call-window.js+2 −0internal/calls/static/room.js+136 −1native/README.md+7 −0native/apple/Sources/CallsInterop/Interop.swift+3 −0native/apple/Sources/CallsKit/Call.swift+49 −4native/apple/Sources/CallsKit/ChatView.swift+1 −1native/apple/Sources/CallsKit/MacFullWindowCall.swift+1 −1native/apple/Sources/CallsKit/Media.swift+4 −1native/apple/Sources/CallsKit/ReconnectWatch.swift+125 −0native/apple/Sources/CallsMac/Resources/MacIcon.pngbinarynative/apple/Tests/CallsKitTests/ReconnectWatchTests.swift+102 −0native/ios/Sources/PhoneFullScreenCall.swift+1 −1test/browser/call.mjs+23 −2test/browser/native-screen.mjs+15 −0test/browser/room-reconnect.test.mjs+105 −0test/browser/room-share.test.mjs+5 −2
CI passed — run details
diff --git a/Makefile b/Makefile| index 7c5b800..0075e0f 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -151,6 +151,7 @@ browser: |
| node --test test/browser/room-space.test.mjs |
| node --test test/browser/room-hearing.test.mjs |
| node --test test/browser/room-screen.test.mjs |
| + node --test test/browser/room-reconnect.test.mjs |
| node --test test/browser/room-glow.test.mjs |
| node --test test/browser/room-layout.test.mjs |
| node --test test/browser/room-window.test.mjs |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index a522d98..243ec8e 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2460,9 +2460,39 @@ receiving packets and frames, decodes none, and drops them: packets +75, |
| frames received +32, decoded +0, dropped +29 in the first four seconds, between |
| two apps. Keyframes arrive and are dropped with the rest. A new transceiver, |
| which gets a new receiver and decoder at the far end, mends it at once, in both |
| -directions and between two apps. No long share (§15.22's soaks, below) has yet |
| -produced a stall that keyframes did not mend, so the wedge is a stand-in for the |
| -cause, not the cause. |
| +directions and between two apps. No long share has yet produced a stall that |
| +keyframes did not mend, so the wedge is a stand-in for the cause, not the cause: |
| + |
| +- Samuel was on the web, so the path was the Mac app to Chromium, where |
| + §15.21 found H.264 held to level 3.1. With the level cap switched off |
| + (`CALLS_SCREEN_UNCAPPED=1`), a Motion share of a 1920×1246 page (a 16:10 |
| + Retina display as the capture fits it) reproduces Paul's picture exactly: |
| + VideoToolbox refuses every frame (-12902, 1,323 times in 150 s), the browser |
| + receives nothing and sends PLIs, freezes of 7–23 s and one that never ended, |
| + which keyframes, fresh encoders and a re-add only partly mend. With the cap as |
| + shipped, the same page starting in Motion and starting in Balanced, modes |
| + cycled every 30 s and the uplink capped from 600 kbit/s to 3 Mbit/s, eleven |
| + ICE restarts from the app and twenty-three at the browser in four minutes |
| + each: no -12902 and no freeze. The real display through ScreenCaptureKit |
| + (3024×1964, sent at 1920×1246) for seven minutes the same way: no -12902; the |
| + only gaps, 4 s each, were Text and detail on a 600 kbit/s link, where a |
| + full-size frame of a still screen takes that long to send, which is the link's |
| + and which the ladder does not take further (nothing was sent). |
| + A screencast source (Balanced, Text and detail) is set up by this WebRTC |
| + build without a fixed level and carries 1920×1246 to Chromium; a source that |
| + is not one (Motion, or a share started in it) is capped, now from the first |
| + frame, since the Mac capture's size is known before it delivers one. |
| +- The browser sharing to the app, modes cycled, renewals every ten seconds: |
| + four and a half minutes, no freeze. |
| +- Considered and not done: making Chromium's H.264 level higher (it answers |
| + 3.1 and room.js does not munge SDP, §5.1), and VP8 or VP9 for screens to |
| + browsers (software VP8 managed 2 fps at 1080p, §5.4's measurements). The |
| + measured cap holds; the ladder covers what it does not. |
| + |
| +What is left untraced, and what the new statistics are for: a receiver's own |
| +decoder going wrong (a hardware decoder in the watcher's browser, which a |
| +headless gate never uses), and the one ICE restart in ten between two apps that |
| +stops the screen for three or four seconds (§15.21). |
| **Decision.** The sharer takes a report further, the receiver does its own |
| share, and both say what they see. |
| @@ -2511,3 +2541,28 @@ receiver's longer report with keyframes as before. |
| **Not logged, not stored.** As §15.19; the timeline stays on the device until |
| someone copies it. |
| + |
| +### 15.23 §4.5, §5 — "Reconnecting…" only for a problem that lasts (2026-09-30) |
| + |
| +**Why.** Paul, 2026-09-30: "During that call I also saw 'Reconnecting' appear |
| +occasionally over the video, but only for a second … it didn't affect the call |
| +quality … I didn't notice any interruption in the call." The apps' core enters |
| +its `reconnecting` phase the moment the signalling stream ends, and the stream |
| +ends on purpose at its TTL (every five minutes, §4.5) and is resumed half a |
| +second later; the apps said "Reconnecting…" every time. The media never |
| +noticed. The browser said nothing at all, even for a real outage. |
| + |
| +**Decision.** The notice is said, on Mac, iPhone and web, only when: |
| + |
| +- the signalling stream has been down for 4 s or more — the whole call; or |
| +- a peer connection's ICE state has been `disconnected` or `failed` for 3 s or |
| + more, or no inbound audio packets have arrived for 2 s or more from a peer |
| + that is not muted — that peer's tile, "Reconnecting to Samuel…"; or the whole |
| + call, when it is every peer (two or more). |
| + |
| +Once said it stays a second past the recovery, then goes. A routine renewal or |
| +resume is never said. The timings are named constants (`ReconnectWatch` in |
| +the apps, `RECONNECT_*` in room.js) with unit tests either side; the browser |
| +gate's `renewal` scenario and the native gate's `resume` and `renewals` watch |
| +for the words through several renewals and fail on any. The core's phase is |
| +unchanged: controls that need an admitted call still follow it. |
diff --git a/internal/calls/static/call-window.js b/internal/calls/static/call-window.js| index a34337d..58c2440 100644 |
| --- a/internal/calls/static/call-window.js |
| +++ b/internal/calls/static/call-window.js |
| @@ -370,6 +370,8 @@ |
| if (s.terminal) return 'Call ended'; |
| if (/full/i.test(status || '')) return 'The room is full'; |
| if (!s.admitted) return /waiting/i.test(status || '') ? 'Waiting to be let in' : 'Connecting'; |
| + // §15.23: only ever a problem that has lasted (room.js's watch). |
| + if (s.reconnect && s.reconnect.call) return 'Reconnecting'; |
| return 'In the meeting'; |
| } |
| const stateLine = $('call-state'); |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 7aa2c24..54a16d4 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -303,6 +303,18 @@ |
| const SCREEN_ESCALATE_SENT = 3; |
| // The screen's timeline, for "Copy call diagnostics": its last lines. |
| const SCREEN_LOG_LINES = 200; |
| + // §15.23 (2026-09-30), "Reconnecting…" only for a problem that lasts: |
| + // the stream down RECONNECT_STREAM_AFTER (the whole call); a connection |
| + // disconnected or failed RECONNECT_CONNECTION_AFTER, or no audio packets |
| + // from someone unmuted RECONNECT_AUDIO_AFTER (that person's tile, or the |
| + // whole call when it is everyone). Said for RECONNECT_HOLD past the |
| + // recovery, so it does not flicker. A stream renewal — ended on purpose |
| + // and resumed in half a second — is never said at all. |
| + const RECONNECT_STREAM_AFTER = 4000; |
| + const RECONNECT_CONNECTION_AFTER = 3000; |
| + const RECONNECT_AUDIO_AFTER = 2000; |
| + const RECONNECT_HOLD = 1000; |
| + const RECONNECT_EVERY = 200; |
| // §5.4, sending: "Optimise shared screen for". What a screen gives up |
| // first when the uplink or the encoder cannot carry all of it. The |
| // first is the default; sharePlan says what each one means. |
| @@ -378,6 +390,9 @@ |
| let shareResharedAt = 0; |
| const shareCounts = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 }; |
| const screenLines = []; |
| + // §15.23: when the stream went down (0 while it is up), and the watch. |
| + let streamDownSince = 0; |
| + const reconnect = reconnectRecord(); |
| // §15.20: peer id -> what this tab is hearing from that peer, as |
| // pollLevels reads it (hearingRecord). selfHearing is the same record |
| // for our own microphone, read off the sender. |
| @@ -451,6 +466,7 @@ |
| // trying since it opened, and that is the stretch STALL_WINDOW means. |
| let lastHelloAt = Date.now(); |
| let reconnectTimer = 0; |
| + let reconnectWatchTimer = 0; |
| let statsTimer = 0; |
| let fullTimer = 0; |
| let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt` |
| @@ -1281,6 +1297,7 @@ |
| if (source !== es) return; |
| closeStream(); |
| if (state.stopped) return; |
| + if (!streamDownSince) streamDownSince = Date.now(); |
| // Only a stream that actually lived clears the backoff. One that |
| // died moments after its hello has not proved anything, and |
| // resetting on the hello alone is how a tight reconnect loop |
| @@ -1418,6 +1435,7 @@ |
| // then stayed open. |
| stalls = 0; |
| lastHelloAt = Date.now(); |
| + streamDownSince = 0; |
| const fresh = !state.peer; |
| state.peer = msg.peer || null; |
| state.boot = msg.boot || state.boot; |
| @@ -4626,6 +4644,111 @@ |
| return lines.join('\n'); |
| } |
| + // --------------------------------------------------------------- |
| + // "Reconnecting…" (§15.23, 2026-09-30) |
| + // --------------------------------------------------------------- |
| + // |
| + // Paul: "I saw 'Reconnecting' appear occasionally over the video, but |
| + // only for a second … I didn't notice any interruption in the call." |
| + // The Mac app said so the moment its stream ended, and a stream ends on |
| + // purpose every few minutes and is resumed half a second later. Here, |
| + // as there, the words wait for a problem that lasts. |
| + |
| + function reconnectRecord() { |
| + return { streamSince: 0, connection: new Map(), audio: new Map(), call: false, callClear: 0, |
| + peers: new Set(), peerClear: new Map() }; |
| + } |
| + |
| + // reconnectStep is one look: `streamDown`, and `people` (peer id -> |
| + // {connectionBad, audioMissing}) for everyone else. It moves `rec` on: |
| + // rec.call is the whole call's words, rec.peers the tiles that say |
| + // "Reconnecting to …". The native app's ReconnectWatch is its twin. |
| + // Pure, and lifted by name in the node test. |
| + function reconnectStep(rec, now, streamDown, people) { |
| + rec.streamSince = streamDown ? rec.streamSince || now : 0; |
| + for (const id of Array.from(rec.connection.keys())) if (!people.has(id)) rec.connection.delete(id); |
| + for (const id of Array.from(rec.audio.keys())) if (!people.has(id)) rec.audio.delete(id); |
| + const troubled = new Set(); |
| + for (const [id, p] of people) { |
| + if (p.connectionBad) { |
| + if (!rec.connection.has(id)) rec.connection.set(id, now); |
| + } else { |
| + rec.connection.delete(id); |
| + } |
| + if (p.audioMissing) { |
| + if (!rec.audio.has(id)) rec.audio.set(id, now); |
| + } else { |
| + rec.audio.delete(id); |
| + } |
| + const connection = rec.connection.has(id) && now - rec.connection.get(id) >= RECONNECT_CONNECTION_AFTER; |
| + const audio = rec.audio.has(id) && now - rec.audio.get(id) >= RECONNECT_AUDIO_AFTER; |
| + if (connection || audio) troubled.add(id); |
| + } |
| + const stream = !!rec.streamSince && now - rec.streamSince >= RECONNECT_STREAM_AFTER; |
| + const everyone = troubled.size >= 2 && troubled.size === people.size; |
| + const wantCall = stream || everyone; |
| + if (wantCall) { |
| + rec.call = true; |
| + rec.callClear = 0; |
| + } else if (rec.call) { |
| + if (!rec.callClear) rec.callClear = now; |
| + if (now - rec.callClear >= RECONNECT_HOLD) { |
| + rec.call = false; |
| + rec.callClear = 0; |
| + } |
| + } |
| + const shown = new Set(Array.from(rec.peers).filter((id) => people.has(id))); |
| + if (!wantCall) { |
| + for (const id of troubled) { |
| + shown.add(id); |
| + rec.peerClear.delete(id); |
| + } |
| + } |
| + for (const id of Array.from(shown)) { |
| + if (!wantCall && troubled.has(id)) continue; |
| + if (wantCall) { |
| + shown.delete(id); |
| + rec.peerClear.delete(id); |
| + continue; |
| + } |
| + if (!rec.peerClear.has(id)) rec.peerClear.set(id, now); |
| + if (now - rec.peerClear.get(id) >= RECONNECT_HOLD) { |
| + shown.delete(id); |
| + rec.peerClear.delete(id); |
| + } |
| + } |
| + for (const id of Array.from(rec.peerClear.keys())) if (!shown.has(id)) rec.peerClear.delete(id); |
| + rec.peers = shown; |
| + } |
| + |
| + // watchReconnect feeds the watch what this tab can see, five times a |
| + // second, and redraws what changed. |
| + function watchReconnect() { |
| + if (state.stopped) return; |
| + const now = Date.now(); |
| + const people = new Map(); |
| + for (const [id, entry] of pcs) { |
| + const ice = entry.pc.iceConnectionState; |
| + const rec = hearing.get(id); |
| + const media = remoteMedia.get(id); |
| + people.set(id, { |
| + connectionBad: ice === 'disconnected' || ice === 'failed', |
| + // Two level polls without a packet: stopped, as far as a poll can tell. |
| + audioMissing: !!(rec && rec.packetAt && media && !media.muted && now - rec.packetAt > 500), |
| + }); |
| + } |
| + const wasCall = reconnect.call; |
| + const wasPeers = Array.from(reconnect.peers).sort().join(); |
| + reconnectStep(reconnect, now, !!streamDownSince && !!state.peer, people); |
| + if (reconnect.call !== wasCall) { |
| + console.info('calls: reconnecting ' + (reconnect.call ? 'shown (a problem that lasted)' : 'cleared')); |
| + renderStatus(); |
| + } |
| + if (Array.from(reconnect.peers).sort().join() !== wasPeers) { |
| + for (const id of pcs.keys()) drawHearing(id); |
| + } |
| + } |
| + |
| // noteShareSent counts the frames that left on this connection's |
| // screen since its escalation last moved on, across a new transceiver's |
| // counters starting again from nothing. |
| @@ -5154,7 +5277,9 @@ |
| h.box.hidden = !kind; |
| } |
| if (self) return; |
| - const notice = remoteNotice(name, media, state.peer, localMedia().muted); |
| + const notice = reconnect.peers.has(key) |
| + ? 'Reconnecting to ' + name + '\u2026' |
| + : remoteNotice(name, media, state.peer, localMedia().muted); |
| if (h.notice.textContent !== notice) { |
| h.notice.textContent = notice; |
| h.notice.hidden = !notice; |
| @@ -5477,6 +5602,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(reconnectWatchTimer); |
| clearInterval(levelTimer); |
| stopIceTimers(); |
| closeStream(); |
| @@ -5571,6 +5697,10 @@ |
| : 'Connecting to the call.'); |
| return; |
| } |
| + if (reconnect.call) { |
| + say('Reconnecting…'); |
| + return; |
| + } |
| const others = pcs.size; |
| if (!others) { |
| say('You are the only one here.', true); |
| @@ -6105,6 +6235,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(reconnectWatchTimer); |
| clearInterval(levelTimer); |
| stopIceTimers(); |
| if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, { keepalive: true }); |
| @@ -6775,6 +6906,8 @@ |
| shareRecovery, |
| // §15.22: what the ladder and the receiver's own mending have done. |
| shareRecoveries: Object.assign({ reattaches: screenReattaches }, shareCounts), |
| + // §15.23: whether "Reconnecting…" is said, for the call and by tile. |
| + reconnect: { call: reconnect.call, peers: Array.from(reconnect.peers), streamDown: !!streamDownSince }, |
| // Targets a 404 has marked stale: nothing is sent to them and |
| // no reset is asked for until a roster settles it. |
| stale: Array.from(stale), |
| @@ -6873,6 +7006,8 @@ |
| // camera that stopped encoding (Codex S4-5), so the walk runs |
| // either way and does whichever halves apply. |
| statsTimer = setInterval(pollFrames, 1000); |
| + // §15.23: no stats, only what the tab already knows. |
| + reconnectWatchTimer = setInterval(watchReconnect, RECONNECT_EVERY); |
| // §15.20's meters, five times a second and cheaper per turn: one |
| // receiver's and one sender's own stats, not the connection's walk. |
| levelTimer = setInterval(() => { |
diff --git a/native/README.md b/native/README.md| index e402337..bfefb36 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -567,6 +567,13 @@ Mac's capture does, so a still iPhone screen is no longer reported as stalled. T |
| browser does all of this too (its sharer's rebuild and share again included), and |
| has **Copy call diagnostics** at the foot of its Devices panel. |
| +"Reconnecting…" is said only for a problem that lasts (spec §15.23): the |
| +signalling stream down 4 s, a connection `disconnected`/`failed` 3 s, or no |
| +audio for 2 s from someone unmuted — that one on their tile, "Reconnecting to |
| +Samuel…" — and it stays a second past the recovery. The stream's own renewal |
| +every few minutes is never said. `ReconnectWatch`; the `connection` log |
| +category. |
| + |
| The gate for it wedges a receiver's screen decoder, which keyframes cannot mend: |
| `screen-decoder-wedge` in `CallsInterop` (run with `CALLS_DECODER_WEDGE=1`; a |
| wrapping decoder factory that only the gate turns on), and in Chromium an encoded |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index bc84396..21ef045 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -285,6 +285,9 @@ struct Input: Decodable { |
| "sharingScreen": call.sharingScreen, |
| // §15.22: the sharer's notice, word for word. |
| "screenRecovery": call.screenRecovery, |
| + // §15.23: "Reconnecting…" said for the call, and on how many tiles. |
| + "reconnecting": call.reconnecting, |
| + "reconnectingPeers": call.reconnectingPeers.count, |
| "syntheticScreenFrames": Interop.screen?.produced ?? 0, |
| "cameraOpens": Interop.camera.opens, |
| "cameraStatus": { |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index d1ce3a6..56f4107 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -82,6 +82,19 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// be described against it. |
| private var screenReceivedMoving: [String: ScreenReceiveReport] = [:] |
| private var screenReattaches = 0 |
| + /// "Reconnecting…", said only for a problem that lasts (spec §15.23): |
| + /// the whole call's, and the people whose tiles say it on their own. |
| + private var reconnectWatch = ReconnectWatch() |
| + @Published public private(set) var reconnecting = false |
| + @Published public private(set) var reconnectingPeers: Set<String> = [] |
| + /// The phase the notices are drawn for: the core's, except that its |
| + /// `reconnecting` is only said once ReconnectWatch says so, and is said |
| + /// for a second after it is over. |
| + public var noticePhase: String? { |
| + if reconnecting { return "reconnecting" } |
| + if state?.phase == "reconnecting" { return "admitted" } |
| + return state?.phase |
| + } |
| private var screenWatchFrozenUntil = 0.0 |
| @Published public private(set) var localTrack: RTCVideoTrack? |
| @Published public private(set) var muted = false |
| @@ -226,7 +239,11 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| if screenReattaches > 0 { lines.append("pictures attached again here: \(screenReattaches)") } |
| let log = AudioDiagnosticsStore.screen.lines(last: AudioDiagnosticsStore.lineLimit) |
| - return [("Screen", lines), ("Screen log (last \(AudioDiagnosticsStore.lineLimit) lines)", log.isEmpty ? ["(nothing logged)"] : log)] |
| + let connection = AudioDiagnosticsStore.connection.lines(last: 50) |
| + return [ |
| + ("Screen", lines), ("Screen log (last \(AudioDiagnosticsStore.lineLimit) lines)", log.isEmpty ? ["(nothing logged)"] : log), |
| + ("Connection log (last 50 lines)", connection.isEmpty ? ["(nothing logged)"] : connection), |
| + ] |
| } |
| private var ice: ICEConfig? |
| private var pendingMedia: [Message] = [] |
| @@ -562,6 +579,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| guard levelWatch != nil, let state else { return } |
| let me = state.peer |
| var present = Set<String>() |
| + var people: [String: ReconnectWatch.Peer] = [:] |
| for person in state.peers where person.peer != me { |
| guard let reading = readings[person.peer] else { continue } |
| present.insert(person.peer) |
| @@ -569,15 +587,21 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| audioArriving[person.peer] = bytes > (audioBytes[person.peer] ?? 0) |
| audioBytes[person.peer] = bytes |
| } |
| + people[person.peer] = ReconnectWatch.Peer( |
| + connectionBad: reading.connectionBad, |
| + audioMissing: audioArriving[person.peer] == false && person.media?.muted != true) |
| + var notices = AudioWords.notices(name: person.name, media: person.media, me: me, muted: muted) |
| + if reconnectingPeers.contains(person.peer) { notices.insert(ReconnectWords.peer(person.name), at: 0) } |
| remotePeakSinceSample = max(remotePeakSinceSample, reading.peak) |
| levels.updatePeer( |
| person.peer, name: person.name, |
| sample: PeerAudioSample( |
| reported: person.media, connected: reading.connected, |
| arriving: audioArriving[person.peer] ?? false, level: reading.peak), |
| - notices: AudioWords.notices(name: person.name, media: person.media, me: me, muted: muted), |
| + notices: notices, |
| now: now) |
| } |
| + watchReconnect(now: now, people: people) |
| levels.settle(present: present) |
| for id in Array(audioBytes.keys) where !present.contains(id) { |
| audioBytes.removeValue(forKey: id) |
| @@ -586,6 +610,23 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| reportNoSound(levels.noSoundFrom) |
| } |
| + /// One look by ReconnectWatch (spec §15.23), and the words it says. |
| + private func watchReconnect(now: Double, people: [String: ReconnectWatch.Peer]) { |
| + reconnectWatch.observe(at: now, streamDown: state?.phase == "reconnecting", people: people) |
| + if reconnectingPeers != reconnectWatch.peers { |
| + for id in reconnectWatch.peers.subtracting(reconnectingPeers) { CallLog.info("reconnecting to \(id.prefix(6)): shown") } |
| + reconnectingPeers = reconnectWatch.peers |
| + } |
| + guard reconnecting != reconnectWatch.call else { return } |
| + reconnecting = reconnectWatch.call |
| + CallLog.info(reconnecting ? "reconnecting: shown (a problem that lasted)" : "reconnecting: cleared") |
| + switch state?.phase { |
| + case "reconnecting": status = reconnecting ? ReconnectWords.call : "In the meeting" |
| + case "admitted": status = reconnecting ? ReconnectWords.call : "In the meeting" |
| + default: break |
| + } |
| + } |
| + |
| /// §15.20's receiving half: who is reaching this app as silence. The |
| /// core keeps only roster peers and reports on a change; this sends only |
| /// on a change too, in order, off the main thread. |
| @@ -662,10 +703,12 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| KnockNotice.announce( |
| knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost)) |
| switch state.phase { |
| - case "admitted": status = "In the meeting" |
| + case "admitted": status = reconnecting ? ReconnectWords.call : "In the meeting" |
| case "waiting": status = "Waiting to be let in" |
| case "full": status = "The meeting is full. Try joining again shortly." |
| - case "reconnecting": status = "Reconnecting…" |
| + // Said only once it has lasted (ReconnectWatch, spec §15.23): a |
| + // stream renewal is a reconnect of half a second nobody should see. |
| + case "reconnecting": if reconnecting { status = ReconnectWords.call } |
| case "ended": |
| status = |
| [ |
| @@ -1097,6 +1140,8 @@ extension Call { |
| self.standing = standing |
| self.state = try? JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) |
| self.status = status |
| + // A fixture's reconnecting is one that has lasted (spec §15.23). |
| + self.reconnecting = self.state?.phase == "reconnecting" |
| self.screens = screens |
| self.sharingScreen = sharingScreen |
| self.muted = muted |
diff --git a/native/apple/Sources/CallsKit/ChatView.swift b/native/apple/Sources/CallsKit/ChatView.swift| index 8f99bd6..814b481 100644 |
| --- a/native/apple/Sources/CallsKit/ChatView.swift |
| +++ b/native/apple/Sources/CallsKit/ChatView.swift |
| @@ -35,7 +35,7 @@ import SwiftUI |
| proxy.scrollTo("chat-bottom", anchor: .bottom) |
| } |
| } |
| - if call.state?.phase == "reconnecting" { |
| + if call.reconnecting { |
| Text("Reconnecting. You can send again when the call reconnects.") |
| .font(.footnote).foregroundStyle(.secondary) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 1e79c08..cdfc3f6 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -304,7 +304,7 @@ |
| private var notices: [CallNotice] { |
| CallNotices.notices( |
| - phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| + phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, |
| screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| } |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index 262c256..92857a4 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -401,7 +401,8 @@ import LiveKitWebRTC |
| (pc.iceConnectionState, stats ? pc.receivers.filter { $0.track?.kind == "audio" } : []) |
| } |
| var reading = PeerAudioReading( |
| - peak: peak, buffers: buffers, connected: state == .connected || state == .completed) |
| + peak: peak, buffers: buffers, connected: state == .connected || state == .completed, |
| + connectionBad: state == .disconnected || state == .failed) |
| if stats { |
| var bytes: UInt64 = 0 |
| for receiver in receivers { |
| @@ -461,6 +462,8 @@ struct PeerAudioReading: Equatable, Sendable { |
| var peak: Float |
| var buffers: Int |
| var connected: Bool |
| + /// ICE `disconnected` or `failed` (spec §15.23's three seconds). |
| + var connectionBad = false |
| var bytes: UInt64? |
| } |
diff --git a/native/apple/Sources/CallsKit/ReconnectWatch.swift b/native/apple/Sources/CallsKit/ReconnectWatch.swift| new file mode 100644 |
| index 0000000..023c902 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ReconnectWatch.swift |
| @@ -0,0 +1,125 @@ |
| +import Foundation |
| +import os |
| + |
| +/// When "Reconnecting…" is said, and to whom (spec §15.23). |
| +/// |
| +/// Paul, 2026-09-30: "During that call I also saw 'Reconnecting' appear |
| +/// occasionally over the video, but only for a second … I didn't notice any |
| +/// interruption in the call." The core puts the call in its `reconnecting` |
| +/// phase the moment its signalling stream ends, and the stream ends on |
| +/// purpose every few minutes (its renewal) and is resumed half a second |
| +/// later; the media never notices. So the words wait until a problem has |
| +/// lasted: |
| +/// |
| +/// - the signalling stream down for `streamAfter` (4 s) — the whole call; |
| +/// - a connection `disconnected` or `failed` for `connectionAfter` (3 s), |
| +/// or no audio arriving from someone who is not muted for `audioAfter` |
| +/// (2 s) — that person's tile, "Reconnecting to Samuel…", unless it is |
| +/// everyone (two or more), which is the whole call again. |
| +/// |
| +/// Once said, the words stay `holdAfter` (1 s) past the recovery, so they |
| +/// do not flicker. Pure: fed the facts as they are, with the time. |
| +struct ReconnectWatch: Equatable { |
| + static let streamAfter = 4.0 |
| + static let connectionAfter = 3.0 |
| + static let audioAfter = 2.0 |
| + static let holdAfter = 1.0 |
| + |
| + /// One person's connection as it is now. |
| + struct Peer: Equatable { |
| + /// ICE `disconnected` or `failed`. |
| + var connectionBad = false |
| + /// No audio packets arrived at the last look, and they are not muted. |
| + var audioMissing = false |
| + } |
| + |
| + private var streamDownSince: Double? |
| + private var connectionBadSince: [String: Double] = [:] |
| + private var audioMissingSince: [String: Double] = [:] |
| + private var callClearSince: Double? |
| + private var peerClearSince: [String: Double] = [:] |
| + |
| + /// Whether the whole call is said to be reconnecting. |
| + private(set) var call = false |
| + /// The people whose tiles say "Reconnecting to …". |
| + private(set) var peers: Set<String> = [] |
| + |
| + init() {} |
| + |
| + /// One look. `streamDown` is the core's `reconnecting` phase; `people` |
| + /// everyone in the call but you. |
| + mutating func observe(at now: Double, streamDown: Bool, people: [String: Peer]) { |
| + streamDownSince = streamDown ? (streamDownSince ?? now) : nil |
| + for id in Array(connectionBadSince.keys) where people[id] == nil { connectionBadSince.removeValue(forKey: id) } |
| + for id in Array(audioMissingSince.keys) where people[id] == nil { audioMissingSince.removeValue(forKey: id) } |
| + var troubled: Set<String> = [] |
| + for (id, p) in people { |
| + connectionBadSince[id] = p.connectionBad ? (connectionBadSince[id] ?? now) : nil |
| + audioMissingSince[id] = p.audioMissing ? (audioMissingSince[id] ?? now) : nil |
| + let connection = connectionBadSince[id].map { now - $0 >= Self.connectionAfter } ?? false |
| + let audio = audioMissingSince[id].map { now - $0 >= Self.audioAfter } ?? false |
| + if connection || audio { troubled.insert(id) } |
| + } |
| + let stream = streamDownSince.map { now - $0 >= Self.streamAfter } ?? false |
| + let everyone = troubled.count >= 2 && troubled.count == people.count |
| + let wantCall = stream || everyone |
| + let wantPeers = wantCall ? [] : troubled |
| + |
| + if wantCall { |
| + call = true |
| + callClearSince = nil |
| + } else if call { |
| + let since = callClearSince ?? now |
| + callClearSince = since |
| + if now - since >= Self.holdAfter { |
| + call = false |
| + callClearSince = nil |
| + } |
| + } |
| + |
| + var shown = peers.filter { people[$0] != nil } |
| + for id in wantPeers { |
| + shown.insert(id) |
| + peerClearSince.removeValue(forKey: id) |
| + } |
| + for id in shown where !wantPeers.contains(id) { |
| + // Covered by the whole call's words, or better: held, then gone. |
| + if wantCall { |
| + shown.remove(id) |
| + peerClearSince.removeValue(forKey: id) |
| + continue |
| + } |
| + let since = peerClearSince[id] ?? now |
| + peerClearSince[id] = since |
| + if now - since >= Self.holdAfter { |
| + shown.remove(id) |
| + peerClearSince.removeValue(forKey: id) |
| + } |
| + } |
| + for id in Array(peerClearSince.keys) where !shown.contains(id) { peerClearSince.removeValue(forKey: id) } |
| + peers = shown |
| + } |
| +} |
| + |
| +/// The words (spec §15.23). |
| +public enum ReconnectWords { |
| + public static let call = "Reconnecting…" |
| + public static func peer(_ name: String) -> String { "Reconnecting to \(name)\u{2026}" } |
| +} |
| + |
| +/// `log show --predicate 'subsystem == "net.amadan.oficina.calls" && category == "connection"' --info`, |
| +/// and the last lines in "Copy call diagnostics": when "Reconnecting…" was |
| +/// said and cleared. |
| +enum CallLog { |
| + static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "connection") |
| + |
| + static func info(_ message: String) { |
| + logger.info("\(message, privacy: .public)") |
| + AudioDiagnosticsStore.connection.log("info", message) |
| + } |
| +} |
| + |
| +extension AudioDiagnosticsStore { |
| + /// The connection log's lines, kept apart from the others. |
| + public static let connection = AudioDiagnosticsStore() |
| +} |
diff --git a/native/apple/Sources/CallsMac/Resources/MacIcon.png b/native/apple/Sources/CallsMac/Resources/MacIcon.png| index f0de939..1ba703e 100644 |
| Binary files a/native/apple/Sources/CallsMac/Resources/MacIcon.png and b/native/apple/Sources/CallsMac/Resources/MacIcon.png differ |
diff --git a/native/apple/Tests/CallsKitTests/ReconnectWatchTests.swift b/native/apple/Tests/CallsKitTests/ReconnectWatchTests.swift| new file mode 100644 |
| index 0000000..8d656ff |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/ReconnectWatchTests.swift |
| @@ -0,0 +1,102 @@ |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// "Reconnecting…" only for a problem that lasts (spec §15.23). Paul, |
| +/// 2026-09-30: "I saw 'Reconnecting' appear occasionally over the video, |
| +/// but only for a second … I didn't notice any interruption." |
| +final class ReconnectWatchTests: XCTestCase { |
| + private typealias Peer = ReconnectWatch.Peer |
| + private let fine = Peer() |
| + |
| + /// Runs the watch at 5 Hz from `from` to `to` seconds, and returns the |
| + /// times at which the whole call's words were showing. |
| + private func run( |
| + _ watch: inout ReconnectWatch, from: Double, to: Double, |
| + stream: (Double) -> Bool, people: (Double) -> [String: Peer] |
| + ) -> (call: [Double], peers: [Double: Set<String>]) { |
| + var call: [Double] = [] |
| + var peers: [Double: Set<String>] = [:] |
| + var t = from |
| + while t < to { |
| + watch.observe(at: t, streamDown: stream(t), people: people(t)) |
| + if watch.call { call.append(t) } |
| + if !watch.peers.isEmpty { peers[t] = watch.peers } |
| + t = (t * 10 + 2).rounded() / 10 |
| + } |
| + return (call, peers) |
| + } |
| + |
| + func testARoutineStreamRenewalIsNeverShown() { |
| + // The stream ends on purpose and is back half a second later, over and |
| + // over, and a slower resume of three and a half seconds as well. |
| + var watch = ReconnectWatch() |
| + let seen = run( |
| + &watch, from: 0, to: 60, |
| + stream: { t in (10..<10.6).contains(t) || (30..<33.5).contains(t) || (50..<50.4).contains(t) }, |
| + people: { _ in ["sam": self.fine] }) |
| + XCTAssertEqual(seen.call, []) |
| + XCTAssertTrue(seen.peers.isEmpty) |
| + } |
| + |
| + func testAStreamDownFourSecondsIsShownAndHeldASecondPastRecovery() { |
| + var watch = ReconnectWatch() |
| + let seen = run(&watch, from: 0, to: 20, stream: { (5..<12).contains($0) }, people: { _ in ["sam": self.fine] }) |
| + XCTAssertEqual(seen.call.first, 9, "down at 5, said at 9") |
| + XCTAssertEqual(seen.call.last, 12.8, "back at 12, said until a second after") |
| + } |
| + |
| + func testShortBlipsOnOnePersonAreNeverShown() { |
| + var watch = ReconnectWatch() |
| + let seen = run( |
| + &watch, from: 0, to: 30, stream: { _ in false }, |
| + people: { t in |
| + ["sam": Peer(connectionBad: (5..<7.5).contains(t), audioMissing: (15..<16.5).contains(t))] |
| + }) |
| + XCTAssertEqual(seen.call, []) |
| + XCTAssertTrue(seen.peers.isEmpty, "\(seen.peers)") |
| + } |
| + |
| + func testOnePersonsLastingTroubleIsSaidOnTheirTileAlone() { |
| + var watch = ReconnectWatch() |
| + let seen = run( |
| + &watch, from: 0, to: 30, stream: { _ in false }, |
| + people: { t in ["sam": Peer(connectionBad: (5..<15).contains(t)), "ana": self.fine] }) |
| + XCTAssertEqual(seen.call, [], "not the whole call") |
| + let times = seen.peers.keys.sorted() |
| + XCTAssertEqual(times.first, 8, "disconnected at 5, said at 8") |
| + XCTAssertEqual(times.last, 15.8, "held a second past recovery") |
| + XCTAssertTrue(seen.peers.values.allSatisfy { $0 == ["sam"] }) |
| + } |
| + |
| + func testAudioThatStopsFromSomeoneUnmutedIsSaidAfterTwoSeconds() { |
| + var watch = ReconnectWatch() |
| + let seen = run( |
| + &watch, from: 0, to: 20, stream: { _ in false }, |
| + people: { t in ["sam": Peer(audioMissing: (5..<10).contains(t)), "ana": self.fine] }) |
| + XCTAssertEqual(seen.peers.keys.sorted().first, 7) |
| + } |
| + |
| + func testEveryonesTroubleIsTheWholeCall() { |
| + var watch = ReconnectWatch() |
| + let seen = run( |
| + &watch, from: 0, to: 20, stream: { _ in false }, |
| + people: { t in |
| + let bad = (5..<12).contains(t) |
| + return ["sam": Peer(connectionBad: bad), "ana": Peer(connectionBad: bad)] |
| + }) |
| + XCTAssertEqual(seen.call.first, 8) |
| + XCTAssertTrue(seen.peers.isEmpty, "not two tiles as well") |
| + } |
| + |
| + func testTheWords() { |
| + XCTAssertEqual(ReconnectWords.call, "Reconnecting…") |
| + XCTAssertEqual(ReconnectWords.peer("Samuel"), "Reconnecting to Samuel…") |
| + } |
| + |
| + func testTheNoticeFollowsTheWatchNotThePhase() { |
| + let notices = CallNotices.notices( |
| + phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "", floor: false) |
| + XCTAssertFalse(notices.contains { $0.kind == .reconnecting }) |
| + } |
| +} |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index 4676f9b..6640a90 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -266,7 +266,7 @@ extension PhoneCall { |
| private var notices: [CallNotice] { |
| CallNotices.notices( |
| - phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| + phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, |
| screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| } |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 204a71e..59058f8 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -2607,9 +2607,30 @@ const run = { |
| const before = { a: await state(pageA), b: await state(pageB) }; |
| // Long enough for at least one renewal each at a six-second life, |
| - // and in practice two. |
| - await sleep(16000); |
| + // and in practice two. Watched four times a second the whole way: |
| + // a renewal is a stream ended on purpose and resumed, and §15.23 says |
| + // nobody sees it — not "Reconnecting…" over the call, not on a tile, |
| + // not in the title (Paul, 2026-09-30: "I saw 'Reconnecting' appear |
| + // occasionally over the video, but only for a second"). |
| + const said = []; |
| + let streamDowns = 0; |
| + for (const until = Date.now() + 16000; Date.now() < until;) { |
| + for (const [page, who] of [[pageA, 'A'], [pageB, 'B']]) { |
| + const r = await page.evaluate(() => ({ |
| + reconnect: window.meet.state().reconnect, |
| + status: (document.getElementById('status') || {}).textContent || '', |
| + pill: (document.getElementById('call-state') || {}).textContent || '', |
| + tiles: Array.from(document.querySelectorAll('.calls-tile-notice, [data-hear-notice]')).map((n) => n.textContent), |
| + })); |
| + if (r.reconnect.streamDown) streamDowns += 1; |
| + const text = [r.status, r.pill, ...r.tiles].join(' | '); |
| + if (r.reconnect.call || r.reconnect.peers.length || /Reconnecting/.test(text)) said.push(who + ': ' + text); |
| + } |
| + await sleep(250); |
| + } |
| const after = { a: await state(pageA), b: await state(pageB) }; |
| + check(said.length === 0, 'no renewal was said as "Reconnecting" (' + said.slice(0, 3).join('; ') + ')'); |
| + console.log('renewal: a stream was seen down (between its end and its resume) at ' + streamDowns + ' of the looks'); |
| check(after.a.peer === before.a.peer, |
| 'A kept its participation across the renewals (' + before.a.peer + ' -> ' + after.a.peer + ')'); |
diff --git a/test/browser/native-screen.mjs b/test/browser/native-screen.mjs| index 44b4ec8..c4551d3 100644 |
| --- a/test/browser/native-screen.mjs |
| +++ b/test/browser/native-screen.mjs |
| @@ -287,7 +287,22 @@ try { |
| await watchFor(SOAK, 'display'); |
| clearInterval(tick); |
| } else if (scenario === 'renewals' || scenario === 'resume') { |
| + // §15.23: a renewal or a resume is never said as "Reconnecting…", by |
| + // the app or the browser, while the core's phase does go through it. |
| + const said = []; |
| + let phases = 0; |
| + const saidTick = setInterval(async () => { |
| + if (sample?.phase === 'reconnecting') phases += 1; |
| + if (sample?.reconnecting || sample?.reconnectingPeers) said.push('app'); |
| + try { |
| + const r = (await page.evaluate(() => window.meet.state())).reconnect; |
| + if (r.call || r.peers.length) said.push('browser'); |
| + } catch {} |
| + }, 250); |
| await watchFor(SOAK, scenario); |
| + clearInterval(saidTick); |
| + console.log(`reconnecting: the app's core was mid-resume at ${phases} samples; said by ${JSON.stringify([...new Set(said)])}`); |
| + if (asserting) assert.equal(said.length, 0, 'a renewal or resume was said as Reconnecting: ' + said.join(',')); |
| console.log(`ICE restarts seen by the browser: ${restarts}`); |
| const ours = sample?.stats.iceRestarts || 0; |
| console.log(`ICE restarts the app started: ${ours}`); |
diff --git a/test/browser/room-reconnect.test.mjs b/test/browser/room-reconnect.test.mjs| new file mode 100644 |
| index 0000000..e5a3455 |
| --- /dev/null |
| +++ b/test/browser/room-reconnect.test.mjs |
| @@ -0,0 +1,105 @@ |
| +// room-reconnect.test.mjs — "Reconnecting…" only for a problem that |
| +// lasts (spec §15.23, 2026-09-30), gated without a browser. |
| +// |
| +// node --test test/browser/room-reconnect.test.mjs |
| +// |
| +// reconnectStep and reconnectRecord lifted out of the shipped room.js by |
| +// name, with the constants they read, as room-screen.test.mjs does. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const src = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8'); |
| + |
| +function lift(name) { |
| + const start = src.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| + let depth = 0; |
| + for (let i = src.indexOf('{', start); i < src.length; i++) { |
| + if (src[i] === '{') depth += 1; |
| + else if (src[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return src.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name); |
| +} |
| +function liftConst(name) { |
| + const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); |
| + assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| + return 'const ' + name + ' = ' + m[1] + ';'; |
| +} |
| +const CONSTS = ['RECONNECT_STREAM_AFTER', 'RECONNECT_CONNECTION_AFTER', 'RECONNECT_AUDIO_AFTER', 'RECONNECT_HOLD'] |
| + .map(liftConst).join('\n'); |
| +// eslint-disable-next-line no-new-func |
| +const { reconnectRecord, reconnectStep } = new Function(CONSTS + '\n' + lift('reconnectRecord') + '\n' + |
| + lift('reconnectStep') + '; return { reconnectRecord, reconnectStep };')(); |
| + |
| +const fine = { connectionBad: false, audioMissing: false }; |
| + |
| +// Five looks a second from `from` to `to` seconds; the times the whole |
| +// call's words showed, and the tiles' at each time. |
| +function run(rec, from, to, stream, people) { |
| + const call = []; |
| + const peers = new Map(); |
| + for (let t = from * 10; t < to * 10; t += 2) { |
| + const s = t / 10; |
| + reconnectStep(rec, s * 1000, stream(s), people(s)); |
| + if (rec.call) call.push(s); |
| + if (rec.peers.size) peers.set(s, Array.from(rec.peers).sort().join()); |
| + } |
| + return { call, peers }; |
| +} |
| + |
| +test('a routine stream renewal, and a slow one, are never said', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 60, (t) => (t >= 10 && t < 10.6) || (t >= 30 && t < 33.5), |
| + () => new Map([['sam', fine]])); |
| + assert.deepEqual(seen.call, []); |
| + assert.equal(seen.peers.size, 0); |
| +}); |
| + |
| +test('a stream down four seconds is said, and held a second past recovery', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 20, (t) => t >= 5 && t < 12, () => new Map([['sam', fine]])); |
| + assert.equal(seen.call[0], 9); |
| + assert.equal(seen.call[seen.call.length - 1], 12.8); |
| +}); |
| + |
| +test('short blips on one person are never said', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 30, () => false, (t) => new Map([['sam', { |
| + connectionBad: t >= 5 && t < 7.5, audioMissing: t >= 15 && t < 16.5, |
| + }]])); |
| + assert.deepEqual(seen.call, []); |
| + assert.equal(seen.peers.size, 0); |
| +}); |
| + |
| +test('one person\'s lasting trouble is said on their tile alone', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 30, () => false, |
| + (t) => new Map([['sam', { connectionBad: t >= 5 && t < 15, audioMissing: false }], ['ana', fine]])); |
| + assert.deepEqual(seen.call, []); |
| + const times = Array.from(seen.peers.keys()); |
| + assert.equal(times[0], 8); |
| + assert.equal(times[times.length - 1], 15.8); |
| + assert.ok(Array.from(seen.peers.values()).every((v) => v === 'sam')); |
| +}); |
| + |
| +test('audio that stops from someone unmuted is said after two seconds', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 20, () => false, |
| + (t) => new Map([['sam', { connectionBad: false, audioMissing: t >= 5 && t < 10 }], ['ana', fine]])); |
| + assert.equal(Array.from(seen.peers.keys())[0], 7); |
| +}); |
| + |
| +test('everyone\'s trouble is the whole call, not a tile each', () => { |
| + const rec = reconnectRecord(); |
| + const seen = run(rec, 0, 20, () => false, (t) => { |
| + const bad = { connectionBad: t >= 5 && t < 12, audioMissing: false }; |
| + return new Map([['sam', bad], ['ana', bad]]); |
| + }); |
| + assert.equal(seen.call[0], 8); |
| + assert.equal(seen.peers.size, 0); |
| +}); |
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs| index 7be3622..b12fd74 100644 |
| --- a/test/browser/room-share.test.mjs |
| +++ b/test/browser/room-share.test.mjs |
| @@ -1001,11 +1001,14 @@ test('the screen is read off its own outbound report, by mid', () => { |
| const reports = [ |
| { mid: '1', frameWidth: 640, frameHeight: 480, framesPerSecond: 30 }, |
| { mid: '2', frameWidth: 1920, frameHeight: 1080, framesPerSecond: 8, qualityLimitationReason: 'bandwidth', |
| - targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400 }, |
| + targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400, framesSent: 398, |
| + keyFramesEncoded: 3 }, |
| ]; |
| + // framesSent and keyFramesEncoded (§15.22): what the ladder counts as |
| + // having left for this person, and the diagnostics' keyframes. |
| assert.deepEqual(shareSendReport(reports, '2'), { |
| width: 1920, height: 1080, fps: 8, limitation: 'bandwidth', targetBitrate: 1200000, |
| - encoder: 'OpenH264', framesEncoded: 400, |
| + encoder: 'OpenH264', framesEncoded: 400, framesSent: 398, keyFramesEncoded: 3, |
| }); |
| assert.equal(shareSendReport(reports, ''), null, 'not sharing on this connection'); |
| assert.equal(shareSendReport(reports, '9'), null, 'not negotiated yet'); |