Screen watchdog: a receiver's report alone gets keyframes; ten-second warm-up; link-limited is not a stall
Measured with the motion gate: Text and detail after a thin link was shrunk to 1280x720 by a report about a slow link. A report while this side's screen is leaving normally is answered with keyframes only; the fresh encoder and new transceiver need the screen stopped here too. No encoder verdict in a connection's first ten seconds, or while WebRTC names bandwidth as the limit; a fresh encoder's smaller size lasts 30 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+110
−33
docs/superpowers/specs/2026-09-07-meet-design.md+8 −6native/README.md+5 −4native/apple/Sources/CallsKit/Call.swift+3 −1native/apple/Sources/CallsKit/Media.swift+19 −9native/apple/Sources/CallsKit/ScreenWatchdog.swift+34 −7native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift+41 −6
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.985s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 9.624s ? amadan.net/oficina/calls/protocol [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 9.377s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 31.304s
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 0457a9b..58989e6 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2403,9 +2403,9 @@ own screen, and the receiver says when a screen has stopped for it. |
| - **The sharer's watchdog**, once a second while sharing: a capture that |
| reports nothing for three seconds is started again on the same content |
| (5, 10, 20… up to 60 seconds apart); a connection whose screen takes |
| - frames in and encodes or sends none for three seconds gets, three seconds |
| - apart, a keyframe (the encoding switched off and on), a fresh encoder |
| - (the screen sent a third smaller, for the rest of that share), and a new |
| + frames in and encodes or sends none for five seconds, while WebRTC does |
| + not name the link as what limits it, gets, three seconds apart, a keyframe (the encoding switched off and on), a fresh encoder |
| + (the screen sent a third smaller, for thirty seconds), and a new |
| transceiver (what stopping and sharing again did, renegotiated as §5.4 |
| does), then rests thirty seconds. Every step is logged (`os_log` |
| subsystem `net.amadan.oficina.calls`, category `screen`) and kept for |
| @@ -2419,9 +2419,11 @@ own screen, and the receiver says when a screen has stopped for it. |
| distinct well-formed peer ids, validated exactly as `noSoundFrom` is, and |
| re-encoded after it (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`, |
| `screenStalled`). The raw payload cap is 2 KB, room for both lists full. |
| -- **A sharer named in anyone's report recovers** as for its own stall — a |
| - keyframe first, four seconds between steps, because the report is a few |
| - seconds old when it lands — and says so, quietly, while it lasts: |
| +- **A sharer named in anyone's report recovers**: a keyframe for that |
| + connection, four seconds apart (the report is a few seconds old when it |
| + lands), three times and then a rest; the fresh encoder and the new |
| + transceiver only if its own screen has stopped leaving as well — a |
| + report about a slow link is not the encoder's fault. It says so, quietly, while it lasts: |
| "Vicky's view of your screen stalled — recovering". The browser, which |
| has no fresh encoder or transceiver to offer short of a new share, sends |
| keyframes only. |
diff --git a/native/README.md b/native/README.md| index 22ba344..9bb8fc7 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -533,11 +533,12 @@ parameters. |
| after Paul's "my screen froze on her end … I had to stop and re-share", 28 Sep). |
| The sharer's watchdog (`ScreenWatchdog`) looks once a second: a capture that |
| reports nothing for three seconds is restarted on the same content; a connection |
| -that takes frames in and encodes or sends none for three seconds gets a keyframe, |
| -then a fresh encoder (the screen a third smaller), then a new transceiver, three |
| -seconds apart. A receiver — app or browser — whose view of someone's screen has |
| +that takes frames in and encodes or sends none for five seconds (past its first |
| +ten, and not held back by the link) gets a keyframe, then a fresh encoder (the screen a third |
| +smaller for thirty seconds), then a new transceiver, three seconds apart. A receiver — app or browser — whose view of someone's screen has |
| decoded nothing for four seconds says so in its `media` report |
| -(`screenStalled`), the sharer runs the same steps for that connection, and shows |
| +(`screenStalled`), the sharer sends that connection keyframes (and the fuller |
| +steps only if its own screen has stopped leaving too), and shows |
| "Vicky's view of your screen stalled — recovering" while it does. Each step is in |
| `log show --predicate 'subsystem == "net.amadan.oficina.calls" && category == |
| "screen"'` and in **Copy call diagnostics** (the panel's ⓘ; Test audio's is still |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index c8f2225..80d24aa 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -801,7 +801,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| let stalledBy = Set( |
| (state?.peers ?? []).filter { $0.peer != me && ($0.media?.screenStalled ?? []).contains(me) }.map(\.peer)) |
| let sending = reports.mapValues { |
| - ScreenSendCounts(encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded) |
| + ScreenSendCounts( |
| + encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded, |
| + bandwidthLimited: $0.limitation == "bandwidth") |
| } |
| var actions = screenWatchdog.observe( |
| at: ProcessInfo.processInfo.systemUptime, capture: capture, sending: sending, stalledBy: stalledBy) |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index aa6739c..609fe81 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -860,22 +860,32 @@ struct PeerAudioReading: Equatable, Sendable { |
| } |
| } |
| - /// A fresh encoder: the screen sent smaller from now on. A |
| - /// change of size is what makes WebRTC set its encoder up again from |
| - /// nothing — for H.264 on a Mac, a new VideoToolbox session, which is |
| - /// the part that can go bad under a sleep or a GPU switch and hold the |
| - /// frames it was given. And it stays smaller, for the rest of this |
| - /// share on this connection: the other way an encoder stops is a frame |
| - /// it cannot take at all (1080p's block count, see `fitted`), and a size |
| - /// put back would stop it again. A third off each side each time, down |
| - /// to a quarter of the size, is a price worth a picture that moves. |
| + /// A fresh encoder: the screen sent smaller for a while. A change of |
| + /// size is what makes WebRTC set its encoder up again from nothing — for |
| + /// H.264 on a Mac, a new VideoToolbox session, which is the part that |
| + /// can go bad under a sleep or a GPU switch and hold the frames it was |
| + /// given. The other way an encoder stops is a frame it cannot take at |
| + /// all (the level's block count, see ScreenSendPolicy), which a smaller |
| + /// size is also the way out of. A third off each side each time, down to |
| + /// a quarter; full size again after thirty seconds, and if that stops it |
| + /// again the watchdog comes back round, after its rest. |
| func recreateScreenEncoder() { |
| guard screenTx != nil else { return } |
| nudge = min(nudge * 1.5, 4) |
| + nudges += 1 |
| + let mine = nudges |
| ScreenLog.info("screen to \(ScreenWatchdog.short(id)) now sent at 1/\(String(format: "%.2f", nudge)) of its size") |
| applySendPolicy() |
| setScreenActive(true) |
| + Task { @MainActor [weak self] in |
| + try? await Task.sleep(nanoseconds: 30_000_000_000) |
| + guard let self, self.nudges == mine, self.nudge > 1 else { return } |
| + self.nudge = 1 |
| + ScreenLog.info("screen to \(ScreenWatchdog.short(self.id)) back to full size") |
| + self.applySendPolicy() |
| + } |
| } |
| + private var nudges = 0 |
| private var nudge = 1.0 |
| /// What stopping and sharing again did, on this connection alone: its |
diff --git a/native/apple/Sources/CallsKit/ScreenWatchdog.swift b/native/apple/Sources/CallsKit/ScreenWatchdog.swift| index eabdc35..8286145 100644 |
| --- a/native/apple/Sources/CallsKit/ScreenWatchdog.swift |
| +++ b/native/apple/Sources/CallsKit/ScreenWatchdog.swift |
| @@ -40,6 +40,11 @@ struct ScreenSendCounts: Equatable, Sendable { |
| var encoded: UInt64 = 0 |
| var sent: UInt64 = 0 |
| var keyframes: UInt64 = 0 |
| + /// WebRTC says the link is what is holding the screen back. Frames stop |
| + /// being encoded for a while when the estimate collapses, and that is |
| + /// the link's to put right, not the encoder's: the watchdog leaves it |
| + /// alone (a receiver whose picture stops still reports it). |
| + var bandwidthLimited = false |
| } |
| /// What the sharer's watchdog asks for. Each is cheaper than the next. |
| @@ -68,21 +73,33 @@ enum ScreenRecovery: Equatable, Sendable { |
| /// exactly that. Asked for: a fresh capture, with a growing wait |
| /// between tries. |
| /// - **The encoder stops.** Frames go into the source and none come out |
| -/// encoded (or none are sent) for three seconds, on one connection. |
| +/// encoded (or none are sent) for five seconds, on one connection, while |
| +/// the link is not what is holding it back. |
| /// Asked for, three seconds apart while it lasts: a keyframe, a fresh |
| /// encoder, then the transceiver itself; then a pause of thirty seconds |
| /// before starting again. |
| /// - **A receiver says so.** Someone's `screenStalled` names this peer |
| -/// (spec §15.21). The same three steps for that connection, four |
| -/// seconds apart, because the report is a few seconds old when it lands. |
| +/// (spec §15.21). A keyframe for that connection, four seconds apart |
| +/// (the report is a few seconds old when it lands), three times and then |
| +/// a rest — and the fuller steps only if the screen has stopped leaving |
| +/// here too. |
| struct ScreenWatchdog: Equatable { |
| static let captureStallAfter = 3.0 |
| static let captureBackoffFirst = 5.0 |
| static let captureBackoffMost = 60.0 |
| - static let encoderStallAfter = 3.0 |
| + /// Five seconds, not three: a link that has just been capped can hold a |
| + /// large keyframe back for that long in Text and detail, and a forced |
| + /// keyframe there only makes it longer (the motion gate, 2026-09-28). |
| + static let encoderStallAfter = 5.0 |
| static let encoderStepEvery = 3.0 |
| static let reportStepEvery = 4.0 |
| static let holdOff = 30.0 |
| + /// No encoder verdict in a connection's first ten seconds: a screen |
| + /// kept at full size (Text and detail) on a link still ramping up from |
| + /// WebRTC's 300 kbit/s start encodes almost nothing for several seconds, |
| + /// and that is not a stall (the motion gate, 2026-09-28). A receiver's |
| + /// report is still answered then. |
| + static let warmUp = 10.0 |
| struct Peer: Equatable { |
| var last = ScreenSendCounts() |
| @@ -94,6 +111,7 @@ struct ScreenWatchdog: Equatable { |
| var stepAt = 0.0 |
| var reported = false |
| var restingUntil = 0.0 |
| + var since = 0.0 |
| } |
| private(set) var peers: [String: Peer] = [:] |
| @@ -158,7 +176,7 @@ struct ScreenWatchdog: Equatable { |
| // watchdog has just re-added, above all — keeps its record, rest and |
| // all, so the steps do not start again from nothing on the new one. |
| for (id, counts) in sending { |
| - var peer = peers[id] ?? Peer(last: counts, movedAt: now, deliveredAt: delivered) |
| + var peer = peers[id] ?? Peer(last: counts, movedAt: now, deliveredAt: delivered, since: now) |
| if counts.encoded < peer.last.encoded || counts.sent < peer.last.sent { |
| // A new transceiver's counters, starting again from nothing. |
| peer.last = counts |
| @@ -182,12 +200,21 @@ struct ScreenWatchdog: Equatable { |
| peer.movedAt = now |
| peer.deliveredAt = delivered |
| } |
| - let starved = !moved && now - peer.movedAt >= Self.encoderStallAfter && delivered > peer.deliveredAt |
| + let starved = |
| + !moved && !counts.bandwidthLimited && now - peer.since >= Self.warmUp |
| + && now - peer.movedAt >= Self.encoderStallAfter |
| + && delivered > peer.deliveredAt |
| if (starved || reported) && now >= peer.restingUntil { |
| let every = reported && !starved ? Self.reportStepEvery : Self.encoderStepEvery |
| if peer.step == 0 || now - peer.stepAt >= every { |
| let why = starved ? "not encoding or sending" : "reported stalled by the receiver" |
| - switch peer.step { |
| + // A receiver's report while this side's screen is leaving |
| + // normally is not the encoder's fault — a slow link, a lost |
| + // packet — and a keyframe is all it can use: a smaller or a new |
| + // encoder would only take the picture down for nothing (Text and |
| + // detail after a thin link, the motion gate, 2026-09-28). Only a |
| + // screen that has stopped here as well goes further. |
| + switch starved ? peer.step : 0 { |
| case 0: |
| out.append(.keyframe(id)) |
| keyframes += 1 |
diff --git a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift| index 1a75ea8..3e125e3 100644 |
| --- a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift |
| @@ -66,7 +66,7 @@ final class ScreenWatchdogTests: XCTestCase { |
| func testAnEncoderThatStopsGetsAKeyframeThenAFreshEncoderThenANewTransceiver() { |
| var dog = ScreenWatchdog() |
| var log: [(Int, ScreenRecovery)] = [] |
| - for t in 0..<45 { |
| + for t in 0..<47 { |
| let delivered = UInt64(t * 30) |
| let encoded: UInt64 = t < 5 ? delivered : 120 |
| for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) { |
| @@ -74,9 +74,10 @@ final class ScreenWatchdogTests: XCTestCase { |
| } |
| } |
| XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky"), .keyframe("vicky")]) |
| - // Three seconds after it stopped (t=4), three seconds apart, then a |
| + // Five seconds after it stopped (t=4), three seconds apart, then a |
| // rest of thirty seconds before starting over. |
| - XCTAssertEqual(log.map(\.0), [7, 10, 13, 43]) |
| + // (not before the connection's first ten seconds are up) |
| + XCTAssertEqual(log.map(\.0), [10, 13, 16, 46]) |
| XCTAssertEqual(dog.keyframes, 2) |
| XCTAssertEqual(dog.encoderRecoveries, 1) |
| XCTAssertEqual(dog.reAdds, 1) |
| @@ -87,13 +88,34 @@ final class ScreenWatchdogTests: XCTestCase { |
| var actions: [ScreenRecovery] = [] |
| for t in 0..<30 { |
| let delivered = UInt64(t * 30) |
| - let encoded: UInt64 = (5..<9).contains(t) ? 120 : delivered |
| + let encoded: UInt64 = (5..<11).contains(t) ? 120 : delivered |
| actions += dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) |
| } |
| XCTAssertEqual(actions, [.keyframe("vicky")]) |
| XCTAssertFalse(dog.recovering) |
| } |
| + func testNothingEncodedInTheFirstTenSecondsIsAWarmUp() { |
| + var dog = ScreenWatchdog() |
| + for t in 0..<10 { |
| + let delivered = UInt64(t * 30) |
| + XCTAssertEqual(dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(1), stalledBy: []), []) |
| + } |
| + XCTAssertEqual(dog.observe(at: 10, capture: counts(300, 300), sending: sending(1), stalledBy: []), [.keyframe("vicky")]) |
| + } |
| + |
| + func testALinkThatHoldsTheScreenBackIsNotAnEncoderStall() { |
| + // The estimate collapses and WebRTC stops encoding for a while: the |
| + // link's business, and a smaller encoder would only stay smaller. |
| + var dog = ScreenWatchdog() |
| + for t in 0..<20 { |
| + let delivered = UInt64(t * 30) |
| + let encoded: UInt64 = t < 5 ? delivered : 120 |
| + let counts = ["vicky": ScreenSendCounts(encoded: encoded, sent: encoded, keyframes: 1, bandwidthLimited: t >= 5)] |
| + XCTAssertEqual(dog.observe(at: Double(t), capture: self.counts(delivered, delivered), sending: counts, stalledBy: []), []) |
| + } |
| + } |
| + |
| func testNothingGoingInIsNotAnEncoderStall() { |
| // No frames delivered (a broadcast of a still screen): nothing to encode. |
| var dog = ScreenWatchdog() |
| @@ -104,7 +126,7 @@ final class ScreenWatchdogTests: XCTestCase { |
| } |
| } |
| - func testAReceiversReportRunsTheSameStepsFourSecondsApart() { |
| + func testAReceiversReportWhileTheScreenLeavesNormallyGetsKeyframesOnly() { |
| var dog = ScreenWatchdog() |
| var log: [(Int, ScreenRecovery)] = [] |
| for t in 0..<20 { |
| @@ -114,12 +136,25 @@ final class ScreenWatchdogTests: XCTestCase { |
| log.append((t, a)) |
| } |
| } |
| - XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky")]) |
| + XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .keyframe("vicky"), .keyframe("vicky")]) |
| XCTAssertEqual(log.map(\.0), [3, 7, 11]) |
| XCTAssertEqual(dog.stallReports, 1, "one report, however long it lasts") |
| XCTAssertTrue(dog.recovering) |
| } |
| + func testAReceiversReportWhileTheScreenHasStoppedHereTooGoesFurther() { |
| + var dog = ScreenWatchdog() |
| + var actions: [ScreenRecovery] = [] |
| + for t in 0..<30 { |
| + let delivered = UInt64(t * 30) |
| + let encoded: UInt64 = t < 5 ? delivered : 120 |
| + actions += dog.observe( |
| + at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), |
| + stalledBy: t >= 6 ? ["vicky"] : []) |
| + } |
| + XCTAssertTrue(actions.contains(.recreateEncoder("vicky"))) |
| + } |
| + |
| func testAReportThatClearsEndsTheRecovery() { |
| var dog = ScreenWatchdog() |
| var actions: [ScreenRecovery] = [] |