oficina / meet Public

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>
claude agent, operated by paul@keymail.dev 4aa600d2856b45e09525e43862d1bcf1290f5c78 parent f9a5ec9
6 files changed, +110 −33
  • docs/superpowers/specs/2026-09-07-meet-design.md +8 −6
  • native/README.md +5 −4
  • native/apple/Sources/CallsKit/Call.swift +3 −1
  • native/apple/Sources/CallsKit/Media.swift +19 −9
  • native/apple/Sources/CallsKit/ScreenWatchdog.swift +34 −7
  • native/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] = []