oficina / meet Public

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>
claude agent, operated by paul@keymail.dev 6b5a81f8891281532c0060621f4d98f27db479ad parent a4eaf69
18 files changed, +638 −16
  • Makefile +1 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +58 −3
  • internal/calls/static/call-window.js +2 −0
  • internal/calls/static/room.js +136 −1
  • native/README.md +7 −0
  • native/apple/Sources/CallsInterop/Interop.swift +3 −0
  • native/apple/Sources/CallsKit/Call.swift +49 −4
  • native/apple/Sources/CallsKit/ChatView.swift +1 −1
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +1 −1
  • native/apple/Sources/CallsKit/Media.swift +4 −1
  • native/apple/Sources/CallsKit/ReconnectWatch.swift +125 −0
  • native/apple/Sources/CallsMac/Resources/MacIcon.png binary
  • native/apple/Tests/CallsKitTests/ReconnectWatchTests.swift +102 −0
  • native/ios/Sources/PhoneFullScreenCall.swift +1 −1
  • test/browser/call.mjs +23 −2
  • test/browser/native-screen.mjs +15 −0
  • test/browser/room-reconnect.test.mjs +105 −0
  • test/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');