Screen share freeze: fix the causes, and a watchdog that heals the rest
Paul, 2026-09-28 (Mac 0.4.0 to Vicky on an app): "after a while, my screen froze on her end ... I had to stop and re-share again." What the harness reproduced: - The apps restarted ICE on every connection on every /ice answer, and the core fetches one on every roster, so every stream resume (five minutes) was an ICE restart. Between two 0.4.x apps with renewals every ten seconds, the watcher's screen stopped for ~4 s at a time while the camera kept moving. Now only a due renewal restarts. - A Mac screen whose source is not a screencast (Motion, or a share started in it) is held to the H.264 level Chromium answers (3.1). A 16:10 display at Motion's 1280 cap is over it, and VideoToolbox refused every frame (-12902): 8-9 s freezes over and over, and a permanent one after switching to Text and detail. Now kept within 3,600 blocks. - ScreenCaptureKit stops dead when every surface is held; the capture kept three and pinned one. Now six. Self-healing (spec §15.21): - The sharer's ScreenWatchdog restarts a capture that reports nothing for 3 s, and for a connection that takes frames in and encodes or sends none, steps through a keyframe, a fresh (smaller) encoder and a new transceiver. Logged to os_log category "screen" and to "Copy call diagnostics". - Receivers (apps and browser) report whose screen decoded nothing for 4 s as media.screenStalled; the sharer runs the same steps and shows "Vicky's view of your screen stalled — recovering". Validated on the server like noSoundFrom; old servers get the report without it. - Panel: a second screen line with encoded/sent rates, keyframes and the watchdog's state. Gates: TestNativeMacScreenRecovery (native-browser) and TestNativeMacScreenPairRecovery (native-pair) cover renewals, resume, capture stall, encoder stall and a receiver's stall report; unit tests for the watchdogs, the ICE decision and the level cap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
35 files changed,
+2407
−94
Makefile+3 −2callscore/media_test.go+86 −0callscore/room.go+62 −2docs/superpowers/specs/2026-09-07-meet-design.md+71 −0internal/calls/app.go+9 −0internal/calls/hub/media.go+3 −1internal/calls/ice.go+4 −0internal/calls/signal.go+52 −36internal/calls/static/room.js+174 −4internal/callstest/media_test.go+44 −1internal/callstest/native_browser_test.go+256 −0native/README.md+37 −6native/apple/Sources/CallsInterop/Interop.swift+73 −1native/apple/Sources/CallsInterop/SyntheticVideo.swift+29 −2native/apple/Sources/CallsKit/AudioDiagnostics.swift+2 −2native/apple/Sources/CallsKit/AudioSelfTestView.swift+5 −2native/apple/Sources/CallsKit/Call.swift+150 −1native/apple/Sources/CallsKit/CallChrome.swift+4 −1native/apple/Sources/CallsKit/Capture.swift+30 −0native/apple/Sources/CallsKit/DevicePanel.swift+8 −1native/apple/Sources/CallsKit/MacCallView.swift+4 −0native/apple/Sources/CallsKit/MacFullWindowCall.swift+1 −1native/apple/Sources/CallsKit/MacScreenCapture.swift+133 −8native/apple/Sources/CallsKit/Media.swift+141 −8native/apple/Sources/CallsKit/ScreenSendPolicy.swift+42 −4native/apple/Sources/CallsKit/ScreenSendStats.swift+26 −0native/apple/Sources/CallsKit/ScreenWatchdog.swift+286 −0native/apple/Sources/CallsKit/Wire.swift+29 −1native/apple/Tests/CallsKitTests/ScreenSendTests.swift+31 −4native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift+236 −0native/bridge/bridge.go+13 −0protocol/messages.go+21 −6test/browser/native-screen.mjs+244 −0test/browser/room-mute.test.mjs+1 −0test/browser/room-screen.test.mjs+97 −0
diff --git a/Makefile b/Makefile| index 4b9a1ed..84c54ee 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -150,6 +150,7 @@ browser: |
| node --test test/browser/room-mute.test.mjs |
| 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-glow.test.mjs |
| node --test test/browser/room-layout.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| @@ -174,7 +175,7 @@ native-browser: native-mac |
| # The gate runs CallsInterop, which native-mac does not build; without |
| # this it runs whatever binary a previous build left behind. |
| swift build --package-path native/apple --product CallsInterop |
| - CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|BrowserAuthentication|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 25m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|MacScreenRecovery|BrowserAuthentication|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| # Two apps in one call, sharing screens to each other. Separate from |
| # native-browser because two unsigned apps on one Mac do not reliably |
| @@ -182,7 +183,7 @@ native-browser: native-mac |
| # It runs CallsInterop too, so it builds it for the same reason. |
| native-pair: native-mac |
| swift build --package-path native/apple --product CallsInterop |
| - CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 3m -run 'TestNativeMacScreenShare' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 20m -run 'TestNativeMacScreen(Share|PairRecovery)' -v ./internal/callstest/ |
| .PHONY: iphone iphone-device iphone-browser |
| iphone: |
diff --git a/callscore/media_test.go b/callscore/media_test.go| index a210efa..6c3010e 100644 |
| --- a/callscore/media_test.go |
| +++ b/callscore/media_test.go |
| @@ -214,3 +214,89 @@ func TestNoSoundFromIsReportedFromTheRoster(t *testing.T) { |
| t.Fatalf("cleared %+v", m) |
| } |
| } |
| + |
| +// §15.21: whose screen has stalled here is reported like noSoundFrom — |
| +// roster peers only, in roster order, a repeat is not a change — and it |
| +// travels beside the rest of the report. |
| +func TestScreenStalledIsReportedFromTheRoster(t *testing.T) { |
| + r, posted := mediaRoom(t) |
| + a, b := protocol.PeerID("aaaa"), protocol.PeerID("bbbb") |
| + if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: a, Gen: 1}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 { |
| + t.Fatalf("admission report %+v, want no stalled screens", m) |
| + } |
| + if err := r.SetScreenStalled([]protocol.PeerID{b, "self", "stranger"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 1 || m.ScreenStalled[0] != b { |
| + t.Fatalf("screenStalled %v, want [bbbb]", m.ScreenStalled) |
| + } |
| + if err := r.SetScreenStalled([]protocol.PeerID{b}); err != nil { |
| + t.Fatal(err) |
| + } |
| + nothingPosted(t, posted, "the same list is not a change") |
| + if err := r.SetNoSoundFrom([]protocol.PeerID{a}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 1 || len(m.NoSoundFrom) != 1 { |
| + t.Fatalf("both lists travel together: %+v", m) |
| + } |
| + if err := r.SetScreenStalled(nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 || len(m.NoSoundFrom) != 1 { |
| + t.Fatalf("cleared %+v", m) |
| + } |
| +} |
| + |
| +// A server from before §15.21 refuses a report naming stalled screens |
| +// with 400. The core sends the same report again without them, and every |
| +// report after that goes without them too, so mute and camera state are |
| +// never lost to an old server. |
| +func TestScreenStalledFallsBackOnAnOldServer(t *testing.T) { |
| + posted := make(chan protocol.Signal, 16) |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + var s protocol.Signal |
| + _ = json.NewDecoder(req.Body).Decode(&s) |
| + var m protocol.MediaState |
| + _ = json.Unmarshal(s.Payload, &m) |
| + posted <- s |
| + if len(m.ScreenStalled) > 0 { |
| + w.WriteHeader(http.StatusBadRequest) |
| + } |
| + })) |
| + t.Cleanup(srv.Close) |
| + r, err := New(srv.URL, "room", srv.Client(), func(Event) {}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.started = true |
| + r.state.Peer = "self" |
| + t.Cleanup(r.Close) |
| + b := protocol.PeerID("bbbb") |
| + if err := r.receive(roster(protocol.RosterPeer{Peer: "self"}, protocol.RosterPeer{Peer: b, Gen: 1})); err != nil { |
| + t.Fatal(err) |
| + } |
| + nextMedia(t, posted) |
| + if err := r.SetMedia(true, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + nextMedia(t, posted) |
| + if err := r.SetScreenStalled([]protocol.PeerID{b}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 1 { |
| + t.Fatalf("first report %+v, want the list", m) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 || !m.Muted { |
| + t.Fatalf("resent %+v, want the same report without the list", m) |
| + } |
| + if err := r.SetMedia(false, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := nextMedia(t, posted); len(m.ScreenStalled) != 0 || m.Muted { |
| + t.Fatalf("later report %+v, want it plain", m) |
| + } |
| +} |
diff --git a/callscore/room.go b/callscore/room.go| index 843e75d..25a56ee 100644 |
| --- a/callscore/room.go |
| +++ b/callscore/room.go |
| @@ -60,6 +60,9 @@ type Room struct { |
| // set it (§15.19). It outlives a fresh join: a restart does not |
| // unmute anybody. |
| media protocol.MediaState |
| + // plainScreen: the server refused a report naming stalled screens |
| + // (it predates §15.21), so reports go without them. |
| + plainScreen bool |
| } |
| func New(origin, room string, authenticated *http.Client, callback func(Event)) (*Room, error) { |
| @@ -288,6 +291,31 @@ func (r *Room) SetNoSoundFrom(peers []protocol.PeerID) error { |
| return r.reportMedia() |
| } |
| +// SetScreenStalled records whose shared screen has stopped moving here |
| +// (spec §15.21) and reports it with the rest of this client's media |
| +// state, exactly as SetNoSoundFrom does its list: roster peers only, in |
| +// roster order, bounded, and an empty list the ordinary report. |
| +func (r *Room) SetScreenStalled(peers []protocol.PeerID) error { |
| + want := make(map[protocol.PeerID]bool, len(peers)) |
| + for _, p := range peers { |
| + want[p] = true |
| + } |
| + r.mu.Lock() |
| + var next []protocol.PeerID |
| + for _, p := range r.state.Peers { |
| + if p.Peer != r.state.Peer && want[p.Peer] && len(next) < protocol.MaxScreenStalled { |
| + next = append(next, p.Peer) |
| + } |
| + } |
| + if (protocol.MediaState{ScreenStalled: next}).Equal(protocol.MediaState{ScreenStalled: r.media.ScreenStalled}) { |
| + r.mu.Unlock() |
| + return nil |
| + } |
| + r.media.ScreenStalled = next |
| + r.mu.Unlock() |
| + return r.reportMedia() |
| +} |
| + |
| func (r *Room) reportMedia() error { |
| r.mu.Lock() |
| report := r.state.Phase == "admitted" && !r.stopped |
| @@ -300,7 +328,13 @@ func (r *Room) reportMedia() error { |
| func (r *Room) sendMedia() error { |
| r.mu.Lock() |
| - b, err := json.Marshal(r.media) |
| + report := r.media |
| + if r.plainScreen { |
| + // A server from before §15.21 refuses the key; everything else |
| + // in the report still matters. |
| + report.ScreenStalled = nil |
| + } |
| + b, err := json.Marshal(report) |
| r.mu.Unlock() |
| if err != nil { |
| return err |
| @@ -308,6 +342,25 @@ func (r *Room) sendMedia() error { |
| return r.Send(protocol.TypeMedia, "", 0, string(b)) |
| } |
| +// refusedScreenStalled is a server from before §15.21 refusing a media |
| +// report because it named stalled screens: from then on the report goes |
| +// without them, and this one is sent again that way. The sharer never |
| +// hears, and recovers on its own watchdog alone. |
| +func (r *Room) refusedScreenStalled(s protocol.Signal) bool { |
| + var sent protocol.MediaState |
| + if json.Unmarshal(s.Payload, &sent) != nil || len(sent.ScreenStalled) == 0 { |
| + return false |
| + } |
| + r.mu.Lock() |
| + already := r.plainScreen |
| + r.plainScreen = true |
| + r.mu.Unlock() |
| + if !already { |
| + go func() { _ = r.sendMedia() }() |
| + } |
| + return true |
| +} |
| + |
| // mediaStaleLocked says whether the hub's record of this client — its |
| // own entry in the roster just applied — differs from what the host last |
| // set, or is missing, which is how a fresh participation, a restarted |
| @@ -315,7 +368,11 @@ func (r *Room) sendMedia() error { |
| func (r *Room) mediaStaleLocked() bool { |
| for _, p := range r.state.Peers { |
| if p.Peer == r.state.Peer { |
| - return p.Media == nil || !p.Media.Equal(r.media) |
| + want := r.media |
| + if r.plainScreen { |
| + want.ScreenStalled = nil |
| + } |
| + return p.Media == nil || !p.Media.Equal(want) |
| } |
| } |
| return false |
| @@ -591,6 +648,9 @@ func (r *Room) sendLoop(l *lane) { |
| if refusal.Status == 429 { |
| delay = max(delay, refusal.RetryAfter) |
| } else if refusal.Status < 500 { |
| + if refusal.Status == 400 && s.Type == protocol.TypeMedia && r.refusedScreenStalled(s) { |
| + break |
| + } |
| if refusal.Status == 401 || refusal.Status == 403 || refusal.Status == 302 || refusal.Status == 303 { |
| r.terminal("signin") |
| } |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index 224da8a..0457a9b 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2363,3 +2363,74 @@ person can see it rather than infer it: |
| **Not logged, not stored.** As §15.19: the report is the sender's claim, |
| logged only on refusal, and held on the in-memory participation. |
| + |
| +### 15.21 §15.19, §5.3, §5.4 — a shared screen that stops, and who says so (2026-09-28) |
| + |
| +**Why.** Paul, 2026-09-28, sharing from the Mac app (0.4.0) to Vicky on an |
| +app: "after a while, my screen froze on her end and she was no longer able |
| +to see my screen. I had to stop and re-share again." Nothing on either side |
| +said anything; the sharer could not see it and the watcher could not tell |
| +him. The server keeps no call logs (§0.1). What the harness measured: |
| + |
| +- The apps restarted ICE on every connection on every `/ice` answer, and |
| + the core fetches one on every roster — every stream resume, every five |
| + minutes at the stream's TTL — so a relay-configured call restarted ICE |
| + twelve times an hour instead of once in two and a half. Between two apps |
| + from before this section, with renewals every ten seconds, the watcher's |
| + screen stopped for about four seconds at a time while the camera beside |
| + it kept moving. |
| +- The Mac's hardware H.264 encoder, as WebRTC sets it up, holds a source |
| + that is not a screencast (the "Motion and video" mode's, and any share |
| + that started in it) to the level negotiated, and Chromium answers every |
| + H.264 offer at level 3.1: 3,600 blocks, 1280×720. A 16:10 display in |
| + motion mode is 1280×830 at its cap. VideoToolbox refused every frame at |
| + that size (-12902), for eight or nine seconds at a time until WebRTC's |
| + quality scaler stepped it down — and for good once the mode was switched |
| + to Text and detail, which does not let the size fall. Two apps negotiate |
| + High 5.2 and never meet it. |
| +- ScreenCaptureKit stops outright — no frames, no idle reports — when every |
| + surface in its queue is held, and 0.4.0 held one of three for the |
| + repeat of a still screen. |
| + |
| +**Decision.** The sender stops making the problem, the sender watches its |
| +own screen, and the receiver says when a screen has stopped for it. |
| + |
| +- The apps restart ICE only when the list they last gathered on is due for |
| + renewal (§5.3), or when a relay arrives for a connection built on the |
| + floor (§5.5); any other fresh list is adopted with `setConfiguration`. |
| +- A Mac screen whose source is not a screencast is scaled to fit level |
| + 3.1's blocks; the capture keeps six surfaces, not three. |
| +- **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 |
| + 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 |
| + "Copy call diagnostics". |
| +- **The receiver's report.** §15.19's `media` gains one more optional |
| + member, `screenStalled: [peer, …]`: the peers whose shared screen has |
| + decoded nothing new here for four seconds while they say they are |
| + sharing. Every sender repeats a still screen at least once a second, so |
| + that is not a still screen. It is bad news only, omitted when there is |
| + none, held for at most a minute from the last frame, at most fifteen |
| + 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: |
| + "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. |
| + |
| +**Compatibility.** Old clients ignore the member. A server from before this |
| +section refuses a report carrying it (400); the browser then drops the |
| +extras for the rest of the page (§15.20), and the apps' core resends the |
| +same report without `screenStalled` and leaves it out from then on, so a |
| +mute is never lost to an old server. The sharer's own watchdog works |
| +whatever the server is. Deploy the server before, or with, the clients. |
| + |
| +**Not logged, not stored.** As §15.19. |
diff --git a/internal/calls/app.go b/internal/calls/app.go| index c2f4718..32eef0b 100644 |
| --- a/internal/calls/app.go |
| +++ b/internal/calls/app.go |
| @@ -69,6 +69,9 @@ type app struct { |
| // them (SetStreamTimings) while stream handlers are reading them. |
| heartbeat atomic.Int64 |
| ttl atomic.Int64 |
| + // iceLifeTest, when set (tests, development only), is the life every |
| + // /ice answer is minted with (SetIceLife). |
| + iceLifeTest atomic.Int64 |
| // allow is the team, held as an atomic for the same reason: a test |
| // drops a member from it (SetAllowlist) while the tick goroutine is |
| @@ -126,6 +129,12 @@ func (h *TestHandle) SetStreamTimings(heartbeat, ttl time.Duration) { |
| h.a.ttl.Store(int64(ttl)) |
| } |
| +// SetIceLife shortens the relay credentials /ice mints, for every request |
| +// (the X-Calls-Test-Ice-TTL header does it for one): a gate that needs a |
| +// renewal mid-call cannot add a header to the native core's fetch. Zero |
| +// puts the production life back. Tests only; development only. |
| +func (h *TestHandle) SetIceLife(d time.Duration) { h.a.iceLifeTest.Store(int64(d)) } |
| + |
| // SetAllowlist replaces the team, so a test can drop a member and watch |
| // the next tick apply spec §2.4's transition to everyone that member |
| // holds a participation for. The addresses are normalized exactly as |
diff --git a/internal/calls/hub/media.go b/internal/calls/hub/media.go| index 22d3e66..5c85873 100644 |
| --- a/internal/calls/hub/media.go |
| +++ b/internal/calls/hub/media.go |
| @@ -13,7 +13,8 @@ import ( |
| // rebuilt from the sender's participation exactly as Fanout's is. |
| // |
| // §15.20 adds the receiving half to the same report — audioBlocked and |
| -// noSoundFrom, each present only when it has bad news — and nothing |
| +// noSoundFrom, each present only when it has bad news, and 2026-09-28's |
| +// screenStalled beside them — and nothing |
| // about the rules: it is still the sender's own claim, recorded and |
| // carried in rosters, and fanned out only when it changes. |
| // |
| @@ -59,6 +60,7 @@ func (h *Hub) SetMedia(ctx context.Context, sqid, session string, from PeerID, m |
| // caller still holds is shared with a roster. |
| next := m |
| next.NoSoundFrom = append([]PeerID(nil), m.NoSoundFrom...) |
| + next.ScreenStalled = append([]PeerID(nil), m.ScreenStalled...) |
| p.Media = &next |
| payload, err := json.Marshal(next) |
| if err != nil { |
diff --git a/internal/calls/ice.go b/internal/calls/ice.go| index 699e806..5e577c2 100644 |
| --- a/internal/calls/ice.go |
| +++ b/internal/calls/ice.go |
| @@ -115,6 +115,10 @@ func (a *app) ice(w http.ResponseWriter, r *http.Request) { |
| now := a.store.now() |
| life, renew := iceLife, iceRenewAfter |
| if a.cfg.development() { |
| + if d := time.Duration(a.iceLifeTest.Load()); d > 0 { |
| + life = min(max(d, minIceLife), iceLife) |
| + renew = life * 5 / 6 |
| + } |
| if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil { |
| // Clamped both ways: a header cannot hand time.NewTimer a |
| // zero or a negative any more than stream.go's own |
diff --git a/internal/calls/signal.go b/internal/calls/signal.go| index 7c7e837..22db7f5 100644 |
| --- a/internal/calls/signal.go |
| +++ b/internal/calls/signal.go |
| @@ -185,22 +185,23 @@ func exactKeys(raw json.RawMessage, want ...string) bool { |
| } |
| // maxMediaBytes bounds §15.19's payload before it is decoded. The |
| -// longest honest one — both booleans false, audioBlocked, and a full |
| -// noSoundFrom of fifteen 32-character peer ids (§15.20) — is a little |
| -// over 600 bytes; the rest is room for whitespace a client's encoder |
| -// might add, and nothing a peer can use to put more on anyone's stream: |
| -// what goes out is re-encoded from the validated values. |
| -const maxMediaBytes = 1024 |
| +// longest honest one — both booleans false, audioBlocked, and full |
| +// noSoundFrom and screenStalled lists of fifteen 32-character peer ids |
| +// each (§15.20, §15.21) — is a little over 1,150 bytes; the rest is room |
| +// for whitespace a client's encoder might add, and nothing a peer can use |
| +// to put more on anyone's stream: what goes out is re-encoded from the |
| +// validated values. |
| +const maxMediaBytes = 2048 |
| -// validateMedia is §15.19's shape as §15.20 extends it: "muted" and |
| -// "cameraOff", both booleans and both required; "audioBlocked", a |
| -// boolean, and "noSoundFrom", an array of at most fifteen distinct peer |
| -// ids, both optional. Keys byte-exact and each once, no other member, |
| -// judged on the raw bytes the same way validateFanout judges chat and |
| -// share (Codex S4-1). It returns the decoded state, or a status and an |
| -// outcome word for the refusal log. |
| +// validateMedia is §15.19's shape as §15.20 and §15.21 extend it: "muted" |
| +// and "cameraOff", both booleans and both required; "audioBlocked", a |
| +// boolean, and "noSoundFrom" and "screenStalled", each an array of at |
| +// most fifteen distinct peer ids, all optional. Keys byte-exact and each |
| +// once, no other member, judged on the raw bytes the same way |
| +// validateFanout judges chat and share (Codex S4-1). It returns the |
| +// decoded state, or a status and an outcome word for the refusal log. |
| // |
| -// A noSoundFrom id is checked for shape and not for presence: it is the |
| +// A listed id is checked for shape and not for presence: it is the |
| // sender's claim about peers it was told of, the recipients ignore an id |
| // they do not hold a tile for, and checking it against the room would |
| // only race the roster. |
| @@ -208,14 +209,15 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) { |
| if len(raw) > maxMediaBytes { |
| return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size" |
| } |
| - if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom"}) { |
| + if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled"}) { |
| return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| } |
| var p struct { |
| - Muted *bool `json:"muted"` |
| - CameraOff *bool `json:"cameraOff"` |
| - AudioBlocked json.RawMessage `json:"audioBlocked"` |
| - NoSoundFrom json.RawMessage `json:"noSoundFrom"` |
| + Muted *bool `json:"muted"` |
| + CameraOff *bool `json:"cameraOff"` |
| + AudioBlocked json.RawMessage `json:"audioBlocked"` |
| + NoSoundFrom json.RawMessage `json:"noSoundFrom"` |
| + ScreenStalled json.RawMessage `json:"screenStalled"` |
| } |
| if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil { |
| return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| @@ -228,27 +230,41 @@ func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) { |
| return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| } |
| } |
| - if p.NoSoundFrom != nil { |
| - var ids []hub.PeerID |
| - if err := json.Unmarshal(p.NoSoundFrom, &ids); err != nil || ids == nil || len(ids) > protocol.MaxNoSoundFrom { |
| - return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| - } |
| - seen := make(map[hub.PeerID]bool, len(ids)) |
| - for _, id := range ids { |
| - if !validPeerID(id) || seen[id] { |
| - return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| - } |
| - seen[id] = true |
| - } |
| - // Empty is the ordinary report, and is kept as nil so that it |
| - // compares equal to one that left the key out. |
| - if len(ids) > 0 { |
| - m.NoSoundFrom = ids |
| - } |
| + var ok bool |
| + if m.NoSoundFrom, ok = peerList(p.NoSoundFrom, protocol.MaxNoSoundFrom); !ok { |
| + return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| + } |
| + if m.ScreenStalled, ok = peerList(p.ScreenStalled, protocol.MaxScreenStalled); !ok { |
| + return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| } |
| return m, 0, "" |
| } |
| +// peerList is one of the media report's optional lists of peer ids: |
| +// absent is nil, and present is an array (never null) of at most max |
| +// well-formed ids with no repeats. Empty is the ordinary report, and is |
| +// kept as nil so that it compares equal to one that left the key out. |
| +func peerList(raw json.RawMessage, max int) ([]hub.PeerID, bool) { |
| + if raw == nil { |
| + return nil, true |
| + } |
| + var ids []hub.PeerID |
| + if err := json.Unmarshal(raw, &ids); err != nil || ids == nil || len(ids) > max { |
| + return nil, false |
| + } |
| + seen := make(map[hub.PeerID]bool, len(ids)) |
| + for _, id := range ids { |
| + if !validPeerID(id) || seen[id] { |
| + return nil, false |
| + } |
| + seen[id] = true |
| + } |
| + if len(ids) == 0 { |
| + return nil, true |
| + } |
| + return ids, true |
| +} |
| + |
| // memberKeys is exactKeys with optional members: every name in required |
| // exactly once, any name in optional at most once, each byte-exact, and |
| // nothing else. |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index f641cc7..fd8ea9d 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -263,6 +263,20 @@ |
| const MAX_NO_SOUND = 15; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| + // §15.21, the screen watchdogs (2026-09-28). A screen that has decoded |
| + // nothing new for SCREEN_STALL_AFTER while its sender says it is |
| + // sharing is stalled here, and reported as `screenStalled`; every |
| + // sender repeats a still screen at least once a second, so this long |
| + // without a frame is not a still screen. Held for at most |
| + // SCREEN_STALL_GIVE_UP, so a screen nobody can recover is not a report |
| + // that never ends. A sharer named in someone's report asks its encoder |
| + // for a keyframe, at most once every SCREEN_KEYFRAME_EVERY, and rests |
| + // SCREEN_KEYFRAME_REST after SCREEN_KEYFRAME_TRIES of them. |
| + const SCREEN_STALL_AFTER = 4000; |
| + const SCREEN_STALL_GIVE_UP = 60000; |
| + const SCREEN_KEYFRAME_EVERY = 4000; |
| + const SCREEN_KEYFRAME_TRIES = 3; |
| + const SCREEN_KEYFRAME_REST = 30000; |
| // §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. |
| @@ -319,6 +333,14 @@ |
| // from before them answers 400 to anything but the two booleans, and |
| // the first such answer turns them off for the life of the page. |
| let mediaExtras = true; |
| + // §15.21: share key -> {decoded, movedAt}, the receiver's watch; the |
| + // peers last reported stalled; peer -> {at, tries, restUntil}, the |
| + // sharer's keyframe answers; and the sharer's quiet notice. |
| + const screenSeen = new Map(); |
| + let screenStalled = []; |
| + const screenAsks = new Map(); |
| + let shareRecovery = ''; |
| + let screenStallTestUntil = 0; |
| // §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. |
| @@ -4139,6 +4161,14 @@ |
| } |
| if (ids.length) out.noSoundFrom = ids; |
| } |
| + if (Array.isArray(value.screenStalled)) { |
| + const ids = []; |
| + for (const id of value.screenStalled) { |
| + if (ids.length >= 15) break; |
| + if (typeof id === 'string' && /^[0-9a-f]{32}$/.test(id) && !ids.includes(id)) ids.push(id); |
| + } |
| + if (ids.length) out.screenStalled = ids; |
| + } |
| return out; |
| } |
| @@ -4154,6 +4184,7 @@ |
| let key = (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-'); |
| if (m.audioBlocked) key += 'b'; |
| if (m.noSoundFrom && m.noSoundFrom.length) key += ':' + m.noSoundFrom.slice().sort().join(','); |
| + if (m.screenStalled && m.screenStalled.length) key += '/' + m.screenStalled.slice().sort().join(','); |
| return key; |
| } |
| @@ -4171,11 +4202,13 @@ |
| // report is still exactly the two-key object every server takes. |
| // `plain` is a server that has refused the extras once. Pure, and |
| // lifted by name in the node test. |
| - function mediaPayload(mine, blocked, silentFrom, plain) { |
| + function mediaPayload(mine, blocked, silentFrom, plain, stalled) { |
| const out = { muted: !!mine.muted, cameraOff: !!mine.cameraOff }; |
| if (plain) return out; |
| if (blocked) out.audioBlocked = true; |
| if (silentFrom && silentFrom.length) out.noSoundFrom = silentFrom.slice(0, 15); |
| + // §15.21: whose screens have stalled here, bad news only, like the rest. |
| + if (stalled && stalled.length) out.screenStalled = stalled.slice(0, 15); |
| return out; |
| } |
| @@ -4191,7 +4224,7 @@ |
| // as muted or not matters more than being heard as unable to hear. |
| function reportMedia() { |
| if (!state.admitted || state.stopped || !state.peer) return; |
| - const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras); |
| + const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled); |
| const key = mediaKey(now); |
| if (key === mediaSent) return; |
| mediaSent = key; |
| @@ -4219,7 +4252,7 @@ |
| learnMedia(rp.peer, rp.media); |
| continue; |
| } |
| - const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras); |
| + const now = mediaPayload(localMedia(), audioBlocked, noSoundList(), !mediaExtras, screenStalled); |
| mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : ''; |
| } |
| reportMedia(); |
| @@ -4244,6 +4277,127 @@ |
| remoteMedia.set(id, next); |
| announceMedia(mediaChange(msg.name || names.get(id) || 'Someone', before, next)); |
| markTile(id, next); |
| + answerScreenStalls(); |
| + } |
| + |
| + // --------------------------------------------------------------- |
| + // The screen watchdogs (§15.21, 2026-09-28) |
| + // --------------------------------------------------------------- |
| + // |
| + // Paul, on the Mac app: "after a while, my screen froze on her end and |
| + // she was no longer able to see my screen. I had to stop and re-share |
| + // again." The receiver can see that happening and the sharer cannot, so |
| + // the receiver says so and the sharer answers with a keyframe — the |
| + // part of a stop and a share again that a frozen picture is missing. |
| + |
| + // judgeScreen is the receiver's verdict on one screen, from its decoded |
| + // count this poll: stalled, or not. `seen` is its record, updated here. |
| + // Pure over its arguments, and lifted by name in the node test. |
| + function judgeScreen(seen, decoded, now, frozen) { |
| + if (!seen.movedAt || (decoded > seen.decoded && !frozen)) { |
| + seen.decoded = decoded; |
| + seen.movedAt = now; |
| + return false; |
| + } |
| + const quiet = now - seen.movedAt; |
| + return quiet >= SCREEN_STALL_AFTER && quiet < SCREEN_STALL_GIVE_UP; |
| + } |
| + |
| + // watchScreens takes one poll's decoded counts, by share key, and |
| + // reports the list when it changes. |
| + function watchScreens(decodedByKey) { |
| + const now = Date.now(); |
| + const frozen = now < screenStallTestUntil; |
| + const stalled = []; |
| + for (const key of Array.from(screenSeen.keys())) { |
| + if (!decodedByKey.has(key)) screenSeen.delete(key); |
| + } |
| + for (const [key, decoded] of decodedByKey) { |
| + const id = key.slice(0, -SHARE_SUFFIX.length); |
| + const entry = pcs.get(id); |
| + if (!entry || !hasShare(entry)) { |
| + screenSeen.delete(key); |
| + continue; |
| + } |
| + let seen = screenSeen.get(key); |
| + if (!seen) { |
| + seen = { decoded: 0, movedAt: 0 }; |
| + screenSeen.set(key, seen); |
| + } |
| + if (judgeScreen(seen, decoded, now, frozen)) stalled.push(id); |
| + } |
| + stalled.sort(); |
| + if (stalled.join() === screenStalled.join()) return; |
| + for (const id of stalled) { |
| + if (!screenStalled.includes(id)) console.info('calls: screen from ' + id.slice(0, 6) + ' stalled here, asking for a keyframe'); |
| + } |
| + screenStalled = stalled; |
| + reportMedia(); |
| + } |
| + |
| + // answerScreenStalls is the sharer's half: every peer whose report |
| + // names us gets a keyframe from our screen's encoder, and the quiet |
| + // notice says whose view is being put right. |
| + function answerScreenStalls() { |
| + const me = state.peer; |
| + const names_ = []; |
| + const now = Date.now(); |
| + for (const [id, m] of remoteMedia) { |
| + const asking = !!(share && me && m && m.screenStalled && m.screenStalled.includes(me)); |
| + if (!asking) { |
| + screenAsks.delete(id); |
| + continue; |
| + } |
| + names_.push(names.get(id) || 'Someone'); |
| + const entry = pcs.get(id); |
| + let ask = screenAsks.get(id); |
| + if (!ask) { |
| + ask = { at: 0, tries: 0, restUntil: 0 }; |
| + screenAsks.set(id, ask); |
| + } |
| + if (!entry || now < ask.restUntil || now - ask.at < SCREEN_KEYFRAME_EVERY) continue; |
| + ask.at = now; |
| + ask.tries += 1; |
| + if (ask.tries >= SCREEN_KEYFRAME_TRIES) { |
| + ask.tries = 0; |
| + ask.restUntil = now + SCREEN_KEYFRAME_REST; |
| + } |
| + console.info('calls: keyframe for ' + id.slice(0, 6) + ', whose view of the screen stalled'); |
| + shareKeyframe(entry).catch(() => {}); |
| + } |
| + const text = recoveringWords(names_); |
| + if (text !== shareRecovery) { |
| + shareRecovery = text; |
| + renderStatus(); |
| + } |
| + } |
| + |
| + // recoveringWords is the sharer's notice. Pure, and lifted by name in |
| + // the node test. |
| + function recoveringWords(people) { |
| + if (!people.length) return ''; |
| + if (people.length === 1) return people[0] + '\u2019s view of your screen stalled \u2014 recovering'; |
| + if (people.length === 2) { |
| + return people[0] + '\u2019s and ' + people[1] + '\u2019s views of your screen stalled \u2014 recovering'; |
| + } |
| + return people.length + ' people\u2019s views of your screen stalled \u2014 recovering'; |
| + } |
| + |
| + // shareKeyframe switches this connection's screen encoding off and on |
| + // again, which WebRTC answers by starting it on a keyframe: there is |
| + // no API that asks for one directly. |
| + async function shareKeyframe(entry) { |
| + const sender = entry.shareTx && entry.shareTx.sender; |
| + if (!sender || !sender.getParameters) return; |
| + const off = sender.getParameters(); |
| + if (!off.encodings || !off.encodings.length) return; |
| + off.encodings[0].active = false; |
| + await sender.setParameters(off); |
| + await sleep(150); |
| + const on = sender.getParameters(); |
| + if (!on.encodings || !on.encodings.length) return; |
| + on.encodings[0].active = true; |
| + await sender.setParameters(on); |
| } |
| // mediaChange is the sentence a change earns, or '' for none. A first |
| @@ -5022,7 +5176,8 @@ |
| say('You are the only one here.'); |
| return; |
| } |
| - say(''); |
| + // §15.21: quiet, and only while it lasts. |
| + say(share ? shareRecovery : ''); |
| } |
| function isMember() { |
| @@ -5986,6 +6141,8 @@ |
| // callback was dropped is put back on the books (Codex S4-6's gate |
| // found one, and the counter is what every frames assertion reads). |
| rearmFrames(); |
| + // §15.21: every screen's decoded count, for the receiver's watch. |
| + const screens = new Map(); |
| for (const [id, entry] of pcs) { |
| try { |
| const stats = await entry.pc.getStats(); |
| @@ -6007,6 +6164,7 @@ |
| const key = frameKey(id, entry, report.mid || ''); |
| if (!key) return; |
| decoded.set(key, report.framesDecoded); |
| + if (key !== id) screens.set(key, report.framesDecoded); |
| if (!HAS_RVFC) frames.set(key, report.framesDecoded); |
| }); |
| for (const [key, count] of decoded) { |
| @@ -6025,6 +6183,8 @@ |
| // getStats on a closing connection: nothing to report. |
| } |
| } |
| + watchScreens(screens); |
| + answerScreenStalls(); |
| } |
| function hearingState() { |
| @@ -6154,6 +6314,12 @@ |
| } |
| return out; |
| }, |
| + // §15.21, for the gate: this tab's receiver watch treats every |
| + // screen as frozen for `ms`, so its stall report and the sharer's |
| + // keyframe can be driven end to end. |
| + stallScreenForTest(ms) { |
| + screenStallTestUntil = Date.now() + Math.max(0, Math.min(Number(ms) || 0, 60000)); |
| + }, |
| state() { |
| // signaling and connection are the two per-peer facts a gate |
| // cannot see from the DOM: whether negotiation settled, and |
| @@ -6177,6 +6343,10 @@ |
| // Rosters applied, so a gate can tell "this tab closed a |
| // connection" from "the server told this tab who is here". |
| rosters, |
| + // §15.21: whose screens this tab has reported stalled, and the |
| + // sharer's notice while someone's view of ours is being put right. |
| + screenStalled: screenStalled.slice(), |
| + shareRecovery, |
| // 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), |
diff --git a/internal/callstest/media_test.go b/internal/callstest/media_test.go| index 13951af..f62dc18 100644 |
| --- a/internal/callstest/media_test.go |
| +++ b/internal/callstest/media_test.go |
| @@ -113,6 +113,14 @@ func TestMediaShapes(t *testing.T) { |
| {"noSoundFrom a number", `{"muted":true,"cameraOff":false,"noSoundFrom":[1]}`}, |
| {"noSoundFrom a repeat", `{"muted":true,"cameraOff":false,"noSoundFrom":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, |
| {"noSoundFrom sixteen ids", `{"muted":true,"cameraOff":false,"noSoundFrom":[` + sixteenIDs() + `]}`}, |
| + // §15.21's screenStalled, held exactly as noSoundFrom is. |
| + {"screenStalled null", `{"muted":true,"cameraOff":false,"screenStalled":null}`}, |
| + {"screenStalled a bool", `{"muted":true,"cameraOff":false,"screenStalled":true}`}, |
| + {"screenStalled a short id", `{"muted":true,"cameraOff":false,"screenStalled":["abc"]}`}, |
| + {"screenStalled a repeat", `{"muted":true,"cameraOff":false,"screenStalled":["` + strings.Repeat("a", 32) + `","` + strings.Repeat("a", 32) + `"]}`}, |
| + {"screenStalled sixteen ids", `{"muted":true,"cameraOff":false,"screenStalled":[` + sixteenIDs() + `]}`}, |
| + {"screenStalled twice", `{"muted":true,"cameraOff":false,"screenStalled":[],"screenStalled":[]}`}, |
| + {"screenStalled snake case", `{"muted":true,"cameraOff":false,"screen_stalled":[]}`}, |
| } |
| for _, c := range bad { |
| t.Run(c.name, func(t *testing.T) { |
| @@ -142,7 +150,7 @@ func TestMediaShapes(t *testing.T) { |
| } |
| }) |
| t.Run("padded past the cap", func(t *testing.T) { |
| - payload := `{"muted":true,` + strings.Repeat(" ", 1024) + `"cameraOff":false}` |
| + payload := `{"muted":true,` + strings.Repeat(" ", 2048) + `"cameraOff":false}` |
| res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":`+payload+`}`) |
| if res.StatusCode != http.StatusRequestEntityTooLarge || !strings.Contains(string(rb), `"error":"too-large"`) { |
| t.Fatalf("%d %s, want 413 too-large", res.StatusCode, rb) |
| @@ -207,6 +215,41 @@ func TestMediaCarriesWhatTheReceiverCanHear(t *testing.T) { |
| } |
| } |
| +// Spec §15.21: a receiver whose view of someone's screen has stalled says |
| +// so in the same report, beside §15.20's lists; the hub carries it in the |
| +// canonical order, and the longest honest report — both lists full — is |
| +// accepted. |
| +func TestMediaCarriesStalledScreens(t *testing.T) { |
| + a, sqid, pa, sb := chatPair(t) |
| + sharer := strings.Repeat("0", 31) + "2" |
| + res, rb := a.signalRaw(sqid, `{"peer":"`+pa+`","type":"media","payload":{"screenStalled":["`+sharer+`"],"muted":false,"cameraOff":true}}`) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("media: %d %s", res.StatusCode, rb) |
| + } |
| + _, data, ok := sb.nextOf("media", 2*time.Second) |
| + want := `"payload":{"muted":false,"cameraOff":true,"screenStalled":["` + sharer + `"]}` |
| + if !ok || !strings.Contains(string(data), want) { |
| + t.Fatalf("envelope %s, want %s", data, want) |
| + } |
| + ids := strings.Join(strings.Split(sixteenIDs(), ",")[:15], ",") |
| + res, rb = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"audioBlocked":true,"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}}`) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("both lists full: %d %s", res.StatusCode, rb) |
| + } |
| + _, data, ok = sb.nextOf("media", 2*time.Second) |
| + if !ok || !strings.Contains(string(data), `"noSoundFrom":[`+ids+`],"screenStalled":[`+ids+`]}`) { |
| + t.Fatalf("full report %s", data) |
| + } |
| + res, _ = a.signal(sqid, pa, `{"type":"media","payload":{"muted":false,"cameraOff":false,"screenStalled":[]}}`) |
| + if res.StatusCode != http.StatusOK { |
| + t.Fatalf("clear: %d", res.StatusCode) |
| + } |
| + _, data, ok = sb.nextOf("media", 2*time.Second) |
| + if !ok || !strings.Contains(string(data), `"payload":{"muted":false,"cameraOff":false}`) { |
| + t.Fatalf("cleared report %s", data) |
| + } |
| +} |
| + |
| // A waiting guest has nobody to tell: its report is the usual 404, and |
| // it hears nothing of the room's. |
| func TestMediaFromAWaitingGuestIsTheUsual404(t *testing.T) { |
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go| index e4d346f..3b1808c 100644 |
| --- a/internal/callstest/native_browser_test.go |
| +++ b/internal/callstest/native_browser_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "strings" |
| "sync" |
| "testing" |
| "time" |
| @@ -350,3 +351,258 @@ func TestNativeMacScreenMotion(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// TestNativeMacScreenRecovery is Paul's report of 2026-09-28 — "after a |
| +// while, my screen froze on her end … I had to stop and re-share" — held |
| +// as a gate: the app shares its screen to a browser while the call is |
| +// disturbed underneath it, and the browser's received screen has to keep |
| +// decoding, or come back within a bound, with nobody sharing again. |
| +// test/browser/native-screen.mjs has the scenarios and their thresholds. |
| +// |
| +// renewals is the one that reproduced it: a relay configured (so /ice |
| +// carries a renewAt) and the stream renewed every ten seconds, which is |
| +// the production shape in miniature — every renewal answered with a |
| +// roster, every roster with a fresh /ice, and up to 0.4.1 every fresh |
| +// /ice with an ICE restart on every connection. |
| +func TestNativeMacScreenRecovery(t *testing.T) { |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with make native-mac first") |
| + } |
| + scenarios := []string{"renewals", "resume", "capture", "encoder", "loss"} |
| + if only := os.Getenv("SCREEN_SCENARIOS"); only != "" { |
| + scenarios = strings.Split(only, ",") |
| + } |
| + for _, scenario := range scenarios { |
| + t.Run(scenario, func(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + if scenario == "renewals" { |
| + if err := rig.ctl.SetRelay(browserRelayContract()); err != nil { |
| + t.Fatal(err) |
| + } |
| + rig.ctl.SetIceLife(20 * time.Second) |
| + } |
| + if scenario == "renewals" || scenario == "resume" { |
| + rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| + } |
| + room, _ := rig.createMeeting(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), 240*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-screen.mjs"), rig.origin, room, binary, scenario) |
| + cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| + input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| + cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| + out, err := cmd.StdoutPipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd.Stderr = cmd.Stdout |
| + if err := cmd.Start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + scanner := bufio.NewScanner(out) |
| + for scanner.Scan() { |
| + t.Log(scanner.Text()) |
| + } |
| + if err := cmd.Wait(); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestNativeMacScreenPairRecovery is TestNativeMacScreenRecovery between |
| +// two apps, which is what the report of 2026-09-28 actually was: Paul on |
| +// the Mac app sharing, Vicky watching on an app. App A shares, app B |
| +// watches, and B's received screen has to keep decoding, or come back |
| +// within a bound, through the same disturbances. |
| +// |
| +// SCREEN_PAIR_A and SCREEN_PAIR_B name other CallsInterop binaries for |
| +// either side — an older build, say — so mixed versions can be held to |
| +// the same rules; a scenario that needs a command the older side does |
| +// not have is skipped for that pairing. SCREEN_PAIR_DISPLAY=1 shares |
| +// this Mac's real display (Screen Recording permission needed) rather |
| +// than the synthetic screen, which puts ScreenCaptureKit and the |
| +// hardware encoder in the path. |
| +// |
| +// It is in `make native-pair`, and flaky for the same reason as |
| +// TestNativeMacScreenShare: two unsigned apps on one Mac do not always |
| +// connect at all. |
| +func TestNativeMacScreenPairRecovery(t *testing.T) { |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + current := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + binA, binB := current, current |
| + if v := os.Getenv("SCREEN_PAIR_A"); v != "" { |
| + binA = v |
| + } |
| + if v := os.Getenv("SCREEN_PAIR_B"); v != "" { |
| + binB = v |
| + } |
| + for _, b := range []string{binA, binB} { |
| + if _, err := os.Stat(b); err != nil { |
| + t.Fatalf("no interop executable at %s: build with make native-mac first", b) |
| + } |
| + } |
| + soak := 45 * time.Second |
| + if v, err := time.ParseDuration(os.Getenv("SCREEN_PAIR_SOAK")); err == nil { |
| + soak = v |
| + } |
| + scenarios := []string{"renewals", "capture", "encoder", "loss"} |
| + if only := os.Getenv("SCREEN_SCENARIOS"); only != "" { |
| + scenarios = strings.Split(only, ",") |
| + } |
| + newA, newB := binA == current, binB == current |
| + for _, scenario := range scenarios { |
| + t.Run(scenario, func(t *testing.T) { |
| + switch { |
| + case (scenario == "capture" || scenario == "encoder") && !newA: |
| + t.Skip("the sharing app predates the stall commands") |
| + case scenario == "loss" && (!newA || !newB): |
| + t.Skip("a stall report needs the new receiver and the new sharer") |
| + } |
| + rig := newBrowserRig(t) |
| + if scenario == "renewals" { |
| + if err := rig.ctl.SetRelay(browserRelayContract()); err != nil { |
| + t.Fatal(err) |
| + } |
| + rig.ctl.SetIceLife(20 * time.Second) |
| + rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| + } |
| + room, _ := rig.createMeeting(t) |
| + ctx, cancel := context.WithTimeout(t.Context(), soak+150*time.Second) |
| + defer cancel() |
| + // Two unsigned apps on one Mac fail to connect at all about two |
| + // times in five (see TestNativeMacScreenShare), before anything |
| + // this test is about has happened; that gets three tries. |
| + var a, b *nativeApp |
| + for attempt := 1; ; attempt++ { |
| + a = startNative(ctx, t, binA, rig, room, rig.cookieA, "a") |
| + waitNative(t, "the sharing app is in", func() bool { return a.sample().Phase == "admitted" }, a) |
| + b = startNative(ctx, t, binB, rig, room, rig.cookieB, "b") |
| + if pollNative(35*time.Second, func() bool { return a.stat("framesDecoded") > 15 && b.stat("framesDecoded") > 15 }) { |
| + break |
| + } |
| + a.leave(t) |
| + b.leave(t) |
| + if attempt == 3 { |
| + t.Fatal("the two apps never connected") |
| + } |
| + t.Logf("RETRY the two apps did not connect (attempt %d)", attempt) |
| + } |
| + defer a.leave(t) |
| + defer b.leave(t) |
| + a.command(t, "screen-mode "+envOr("SCREEN_MODE", "balanced")) |
| + if os.Getenv("SCREEN_PAIR_DISPLAY") == "1" && newA { |
| + a.command(t, "screen-display 6") |
| + } else { |
| + a.command(t, "screen-on") |
| + } |
| + waitNative(t, "the watching app decodes the screen", func() bool { |
| + return b.stat("remoteScreens") == 1 && b.stat("screenFramesDecoded") > 15 |
| + }, a, b) |
| + // The watch: B's screen frames, once a second. |
| + freezes, longest := 0, time.Duration(0) |
| + watch := func(d time.Duration, label string) { |
| + last, moved, frozen := b.stat("screenFramesDecoded"), time.Now(), false |
| + camera := b.stat("framesDecoded") |
| + for until := time.Now().Add(d); time.Now().Before(until); { |
| + time.Sleep(time.Second) |
| + now := b.stat("screenFramesDecoded") |
| + if now > last { |
| + if frozen { |
| + gap := time.Since(moved) |
| + t.Logf("FREEZE %s: %v, then frames again (b's camera decoded %d frames meanwhile)", label, gap.Round(time.Millisecond), b.stat("framesDecoded")-camera) |
| + freezes++ |
| + longest = max(longest, gap) |
| + frozen = false |
| + } |
| + moved = time.Now() |
| + camera = b.stat("framesDecoded") |
| + } else if !frozen && time.Since(moved) > 3*time.Second { |
| + frozen = true |
| + s := a.sample().Stats |
| + t.Logf("FROZEN %s: no screen frame at b for %v (a: captured %d encoded %d sent %d, watchdog k%d e%d r%d a%d)", label, |
| + time.Since(moved).Round(time.Millisecond), s["screenCaptureDelivered"], s["screenFramesEncoded"], s["screenFramesSent"], |
| + s["screenWatchdogKeyframes"], s["screenWatchdogEncoderRecoveries"], s["screenWatchdogCaptureRestarts"], s["screenWatchdogReAdds"]) |
| + } |
| + last = now |
| + } |
| + if frozen { |
| + t.Errorf("%s: b's screen froze and did not come back", label) |
| + } |
| + } |
| + recovered := func(label string) { |
| + base := b.stat("screenFramesDecoded") |
| + until := time.Now().Add(15 * time.Second) |
| + for time.Now().Before(until) { |
| + if b.stat("screenFramesDecoded") > base+10 { |
| + return |
| + } |
| + time.Sleep(250 * time.Millisecond) |
| + } |
| + t.Fatalf("%s: a=%+v b=%+v", label, a.sample(), b.sample()) |
| + } |
| + switch scenario { |
| + case "renewals": |
| + watch(soak, "renewals") |
| + t.Logf("ICE restarts: a started %d, b started %d", a.stat("iceRestarts"), b.stat("iceRestarts")) |
| + if newA && (a.stat("iceRestarts") < 1 || a.stat("iceRestarts") > uint64(soak/(16*time.Second))+1) { |
| + t.Errorf("the sharing app restarted ICE %d times; renewals fall due every ~17 s", a.stat("iceRestarts")) |
| + } |
| + if freezes > 0 { |
| + t.Errorf("b's screen froze %d times, longest %v", freezes, longest) |
| + } |
| + case "capture": |
| + watch(5*time.Second, "before") |
| + a.command(t, "screen-stall") |
| + recovered("a stalled capture is restarted") |
| + if a.stat("screenWatchdogCaptureRestarts") == 0 { |
| + t.Error("the watchdog did not say it restarted the capture") |
| + } |
| + case "encoder": |
| + watch(5*time.Second, "before") |
| + a.command(t, "screen-encoder-stall") |
| + time.Sleep(time.Second) |
| + recovered("a stalled encoder is recovered") |
| + if a.stat("screenWatchdogKeyframes")+a.stat("screenWatchdogEncoderRecoveries") == 0 { |
| + t.Error("the watchdog did not say it recovered the encoder") |
| + } |
| + case "loss": |
| + watch(5*time.Second, "before") |
| + before := a.stat("screenWatchdogKeyframes") |
| + b.command(t, "screen-freeze-test 8000") |
| + waitNative(t, "b reports a's screen stalled", func() bool { return b.stat("screenStalledHere") == 1 }, a, b) |
| + waitNative(t, "a hears it and answers with a keyframe", func() bool { |
| + return a.stat("screenStallReports") > 0 && a.stat("screenWatchdogKeyframes") > before |
| + }, a, b) |
| + waitNative(t, "b clears its report once frames move", func() bool { return b.stat("screenStalledHere") == 0 }, a, b) |
| + recovered("b decodes after the keyframe") |
| + } |
| + t.Logf("PASS %s: %d freezes, longest %v", scenario, freezes, longest) |
| + }) |
| + } |
| +} |
| + |
| +// pollNative is waitNative without the failure: whether ok held in time. |
| +func pollNative(d time.Duration, ok func() bool) bool { |
| + for until := time.Now().Add(d); time.Now().Before(until); time.Sleep(250 * time.Millisecond) { |
| + if ok() { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func envOr(key, fallback string) string { |
| + if v := os.Getenv(key); v != "" { |
| + return v |
| + } |
| + return fallback |
| +} |
diff --git a/native/README.md b/native/README.md| index 5f14254..ac68fb3 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -336,7 +336,8 @@ is arriving" (no buffers at all: the microphone was asked for and nothing came |
| back) from "sound is arriving, but it is silent". Bluetooth rates, which the |
| suppressor passes through, are metered and recorded like any other. |
| -**Copy audio diagnostics**, in Test audio and in the panel's ⓘ, puts text on |
| +**Copy audio diagnostics**, in Test audio (in the panel's ⓘ it is **Copy call |
| +diagnostics**, with the screen's account added), puts text on |
| the clipboard to paste into a message: the devices chosen and what they |
| resolve to, every CoreAudio device with its rate and transport, the engine |
| module's state and last few engine events with their formats, the capture |
| @@ -513,11 +514,33 @@ hardware encoder), with every other codec still offered, and has high priority; |
| the camera beside it is capped at 500 kbit/s and 360 lines, and given its |
| defaults back when the share stops. While sharing, the panel shows one line on |
| how the screen is actually leaving, e.g. "Screen: 1920×1080 at 8 fps, limited |
| -by bandwidth, 1.2 Mbit/s, direct (H264 hardware)". The Mac capture keeps three |
| -frames in its queue, drops a frame that arrives more than 100 ms late, and |
| -repeats the last frame once a second while the screen is still, so a keyframe |
| -can be made for a receiver that asks for one. The browser offers the same three |
| -choices, as `contentHint`, `degradationPreference` and sender parameters. |
| +by bandwidth, 1.2 Mbit/s, direct (H264 hardware)", and a second line with the |
| +encoded and sent frame rates, keyframes made and asked for, and what the screen |
| +watchdog is doing. The Mac capture keeps six surfaces in its queue (three, in |
| +0.4.x, left too few once the encoder and the repeat were holding theirs, and a |
| +capture with every surface held stops dead), drops a frame that arrives more than |
| +100 ms late, and repeats the last frame once a second while the screen is still, |
| +so a keyframe can be made for a receiver that asks for one. A screen whose source |
| +is not a screencast (Motion, or a share started in it) is kept within H.264 level |
| +3.1's 1280×720 worth of blocks: Chromium answers H.264 at that level, and the |
| +Mac's encoder refused larger frames outright (spec §15.21). The browser offers the |
| +same three choices, as `contentHint`, `degradationPreference` and sender |
| +parameters. |
| + |
| +**A screen that stops is put right without anyone sharing again** (spec §15.21, |
| +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 |
| +decoded nothing for four seconds says so in its `media` report |
| +(`screenStalled`), the sharer runs the same steps for that connection, 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 |
| +**Copy audio diagnostics**). The apps restart ICE only when a relay credential is |
| +due for renewal, not on every stream resume as 0.4.x did. |
| The numbers behind the modes come from `TestNativeMacScreenMotion` (in |
| `make native-browser`): the app shares a 1080p page scrolling at 30 fps with a |
| @@ -706,6 +729,14 @@ synthetic screen and the browser must show it under the app's name, then see its |
| received track *end* — once by the button and once by the capture ending on its |
| own. `make native-pair` puts two apps in one call with no browser: a screen |
| shared before the other app joined, both endings, and both apps sharing at once. |
| +`TestNativeMacScreenRecovery` (in `make native-browser`) and |
| +`TestNativeMacScreenPairRecovery` (in `make native-pair`) share a screen |
| +through relay renewals every twenty seconds and stream resumes every ten, a |
| +capture that stops, an encoder that stops, and a receiver that reports a stall, |
| +and require the watcher's screen to keep decoding or come back without a new |
| +share; `SCREEN_PAIR_A`/`SCREEN_PAIR_B` put an older build on either side, and |
| +`screen-display` shares this Mac's real display through ScreenCaptureKit and the |
| +hardware encoder (Screen Recording permission needed). |
| It is separate because two unsigned apps on one Mac do not reliably connect at |
| all (see `TestNativeMacScreenShare`), which looks like the machine rather than |
| the feature. |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index c38d11f..ac62373 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -1,6 +1,8 @@ |
| import AppKit |
| -import CallsKit |
| +@_spi(Fixtures) import CallsKit |
| import Foundation |
| +import LiveKitWebRTC |
| +import ScreenCaptureKit |
| import SwiftUI |
| // A separate test executable: synthetic video and fixture credentials are never |
| @@ -14,6 +16,8 @@ struct Input: Decodable { |
| } |
| @main struct Interop { |
| @MainActor static var screen: SyntheticScreen? |
| + @MainActor static var display: DisplayCapture? |
| + @MainActor static var webrtcLogger: LKRTCCallbackLogger? |
| @MainActor static let cameras = SyntheticCameras() |
| // A defaults domain of its own, so the gate's camera choices are never |
| // remembered into anyone's real preferences. |
| @@ -53,6 +57,25 @@ struct Input: Decodable { |
| let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| else { exit(2) } |
| let call = Call(playAudio: false, audioInput: SyntheticVoice()) |
| + if ProcessInfo.processInfo.environment["CALLS_SCREEN_WATCHDOG"] == "0" { Call.screenWatchdogEnabled = false } |
| + // WebRTC's own log, filtered to the video encoder, into a file: for |
| + // diagnosing the gate's screen scenarios by hand. Never credentials: |
| + // the filter keeps encoder lines only. |
| + if let path = ProcessInfo.processInfo.environment["CALLS_WEBRTC_LOG"] { |
| + FileManager.default.createFile(atPath: path, contents: nil) |
| + let handle = FileHandle(forWritingAtPath: path) |
| + let logger = LKRTCCallbackLogger() |
| + logger.severity = .info |
| + logger.start { message in |
| + let lower = message.lowercased() |
| + guard ["h264", "encod", "videotoolbox", "vtcompression", "resolution", "adapt", "quality"].contains(where: lower.contains) |
| + else { return } |
| + handle?.write(Data(message.utf8)) |
| + } |
| + Interop.webrtcLogger = logger |
| + } |
| + // 0.4.1's motion sizing, without the H.264 level cap, for measuring it. |
| + if ProcessInfo.processInfo.environment["CALLS_SCREEN_UNCAPPED"] == "1" { ScreenSendPolicy.levelCapped = false } |
| let preview = input.chatSnapshot.map { ChatPreview(call: call, path: $0) } |
| do { |
| try await call.join( |
| @@ -156,6 +179,40 @@ struct Input: Decodable { |
| print("{\"screenSharing\":false}") |
| fflush(stdout) |
| } |
| + // The synthetic screen stops producing anything, as a capture |
| + // with every surface held does; the watchdog has to restart it. |
| + if command == "screen-stall" { |
| + await MainActor.run { Interop.screen?.stall() } |
| + print("{\"screenStalled\":true}") |
| + fflush(stdout) |
| + } |
| + // Every screen encoding switched off behind the watchdog's back: |
| + // frames go in and nothing comes out. |
| + if command == "screen-encoder-stall" { |
| + await call.stallScreenEncoderForTest() |
| + print("{\"encoderStalled\":true}") |
| + fflush(stdout) |
| + } |
| + // The real thing: this Mac's main display through ScreenCaptureKit |
| + // and the hardware encoder, at the given queue depth. Needs Screen |
| + // Recording permission for the process running the gate. |
| + if command.hasPrefix("screen-display") { |
| + let depth = Int(command.split(separator: " ").dropFirst().first ?? "") ?? 6 |
| + await MainActor.run { MacScreenCapture.queueDepth = depth } |
| + let display = await DisplayCapture() |
| + await MainActor.run { Interop.display = display } |
| + await call.shareScreen(display) |
| + let on = await call.sharingScreen |
| + print("{\"screenSharing\":\(on)}") |
| + fflush(stdout) |
| + } |
| + // The receiving side's watch treats every screen as frozen. |
| + if command.hasPrefix("screen-freeze-test ") { |
| + let ms = Double(command.dropFirst("screen-freeze-test ".count)) ?? 0 |
| + await call.freezeScreenWatchForTest(seconds: ms / 1000) |
| + print("{\"screenFrozenForTest\":true}") |
| + fflush(stdout) |
| + } |
| if command == "screen-end" { |
| await Interop.screen?.end() |
| let on = await call.sharingScreen |
| @@ -212,6 +269,21 @@ struct Input: Decodable { |
| } |
| } |
| +/// The main display, captured by the shipping MacScreenCapture with no |
| +/// picker: the gate's way to put ScreenCaptureKit, the frame handling and |
| +/// the hardware encoder under test together. |
| +@MainActor final class DisplayCapture: ScreenCapture { |
| + let mac = MacScreenCapture() |
| + func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { |
| + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) |
| + guard let display = content.displays.first else { throw CancellationError() } |
| + try await mac.start(display: display, source: source, ended: ended) |
| + } |
| + func stop() { mac.stop() } |
| + var counts: ScreenCaptureCounts { mac.counts } |
| + func restart() async -> Bool { await mac.restart() } |
| +} |
| + |
| // Render the shipping chat view against real received messages. This fixture |
| // window sits off-screen and never activates or interrupts the user's call. |
| @MainActor private final class ChatPreview { |
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift| index 1ce3093..845c2c4 100644 |
| --- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| +++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift |
| @@ -120,6 +120,26 @@ import LiveKitWebRTC |
| } |
| /// Frames the moving page has produced, for the gate. |
| var produced: Int { page?.produced ?? 0 } |
| + /// A capture that has stalled: no frames, and nothing else either, until |
| + /// the watchdog restarts it (`screen-stall`). |
| + var stalled = false |
| + private var made: UInt64 = 0 |
| + private(set) var restarts = 0 |
| + var counts: ScreenCaptureCounts { |
| + let n = page.map { UInt64($0.produced) } ?? made |
| + return ScreenCaptureCounts(heartbeats: n, delivered: n) |
| + } |
| + func restart() async -> Bool { |
| + guard source != nil else { return false } |
| + restarts += 1 |
| + stalled = false |
| + page?.stalled = false |
| + return true |
| + } |
| + func stall() { |
| + stalled = true |
| + page?.stalled = true |
| + } |
| func stop() { |
| page?.stop() |
| task?.cancel() |
| @@ -135,7 +155,8 @@ import LiveKitWebRTC |
| ended?() |
| } |
| private func frame() { |
| - guard let source, let capturer else { return } |
| + guard let source, let capturer, !stalled else { return } |
| + made += 1 |
| // A 16:9 screen, wider than either synthetic camera, so the far side |
| // can tell by size alone which one it is decoding. |
| var pixel: CVPixelBuffer? |
| @@ -176,6 +197,12 @@ final class ScrollingPage: @unchecked Sendable { |
| private let lock = NSLock() |
| private var count = 0 |
| var produced: Int { lock.withLock { count } } |
| + private var stalledFlag = false |
| + /// Set by the gate's `screen-stall`: no frames until cleared. |
| + var stalled: Bool { |
| + get { lock.withLock { stalledFlag } } |
| + set { lock.withLock { stalledFlag = newValue } } |
| + } |
| init() { |
| let stride = Self.width * 4 |
| @@ -220,7 +247,7 @@ final class ScrollingPage: @unchecked Sendable { |
| let timer = DispatchSource.makeTimerSource(flags: .strict, queue: queue) |
| timer.schedule(deadline: .now(), repeating: .nanoseconds(33_333_333), leeway: .milliseconds(2)) |
| timer.setEventHandler { [weak self] in |
| - guard let self, let pixel = self.next() else { return } |
| + guard let self, !self.stalled, let pixel = self.next() else { return } |
| source.capturer( |
| capturer, |
| didCapture: RTCVideoFrame( |
diff --git a/native/apple/Sources/CallsKit/AudioDiagnostics.swift b/native/apple/Sources/CallsKit/AudioDiagnostics.swift| index a0f009f..d0997c1 100644 |
| --- a/native/apple/Sources/CallsKit/AudioDiagnostics.swift |
| +++ b/native/apple/Sources/CallsKit/AudioDiagnostics.swift |
| @@ -120,9 +120,9 @@ enum AudioDiagnosticsReport { |
| let build = info?["CFBundleVersion"] as? String ?? "dev" |
| let os = ProcessInfo.processInfo.operatingSystemVersionString |
| var out = [ |
| - "Calls audio diagnostics — \(context)", |
| + "Calls diagnostics — \(context)", |
| "Calls \(version) (\(build)), \(os), \(ISO8601DateFormatter().string(from: Date()))", |
| - "No audio is included: names, formats, states and levels only.", |
| + "No audio or pictures are included: names, formats, states, counts and levels only.", |
| ] |
| for (title, lines) in sections where !lines.isEmpty { |
| out.append("") |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestView.swift b/native/apple/Sources/CallsKit/AudioSelfTestView.swift| index 6ca7b83..a94e4d3 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| @@ -269,19 +269,22 @@ import SwiftUI |
| /// audio log lines and the recent levels — as text on the clipboard, to |
| /// paste into a message. Never any audio. |
| @MainActor struct CopyAudioDiagnosticsButton: View { |
| + /// "Copy audio diagnostics" in Test audio; in a call, where the screen's |
| + /// account goes in too, "Copy call diagnostics". |
| + var title = "Copy audio diagnostics" |
| let text: () -> String |
| @State private var copied = false |
| var body: some View { |
| HStack(spacing: 8) { |
| - Button("Copy audio diagnostics", systemImage: "doc.on.clipboard") { |
| + Button(title, systemImage: "doc.on.clipboard") { |
| AudioDiagnosticsReport.copy(text()) |
| copied = true |
| } |
| .buttonStyle(.borderless) |
| .font(.footnote) |
| .accessibilityIdentifier("copyAudioDiagnostics") |
| - .accessibilityHint("Copies device names, formats and levels, never audio, to paste into a message.") |
| + .accessibilityHint("Copies device names, formats, levels and screen sharing's account, never audio or pictures, to paste into a message.") |
| if copied { |
| Text("Copied").font(.footnote).foregroundStyle(.secondary) |
| .accessibilityIdentifier("copyAudioDiagnosticsDone") |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index bdaa5ac..1fe3512 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -61,6 +61,21 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| private var screenReports: [String: ScreenSendReport] = [:] |
| private var screenWatch: Task<Void, Never>? |
| + /// The sharer's watchdog (ScreenWatchdog) and what it is doing, for the |
| + /// panel's screen line. |
| + private var screenWatchdog = ScreenWatchdog() |
| + @Published public private(set) var screenWatchStatus = "" |
| + /// The panel's second screen line: encoded and sent rates, keyframes, |
| + /// and the watchdog (ScreenSendReport.detail). |
| + @Published public private(set) var screenDetail = "" |
| + /// The quiet notice while someone's view of this app's screen is being |
| + /// put right: "Vicky's view of your screen stalled — recovering". |
| + @Published public private(set) var screenRecovery = "" |
| + /// The receiver's watchdog, and the list last reported as |
| + /// `screenStalled` (spec §15.21). |
| + private var screenReceiveWatch = ScreenReceiveWatch() |
| + private var reportedStalled: [String] = [] |
| + private var screenWatchFrozenUntil = 0.0 |
| @Published public private(set) var localTrack: RTCVideoTrack? |
| @Published public private(set) var muted = false |
| @Published public private(set) var cameraOff = false |
| @@ -166,7 +181,32 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| public func audioDiagnosticsText() -> String { |
| - AudioDiagnosticsReport.make(context: "a call's Audio details", sections: audioDiagnosticSections()) |
| + AudioDiagnosticsReport.make( |
| + context: "a call's details", sections: audioDiagnosticSections() + screenDiagnosticSections()) |
| + } |
| + |
| + /// The screen's part of "Copy call diagnostics": how it is leaving, what |
| + /// the watchdog has done, and whose screens have stalled here. |
| + private func screenDiagnosticSections() -> [(String, [String])] { |
| + var lines: [String] = [] |
| + if sharingScreen { |
| + lines.append("Sharing (\(screenMode.rawValue))") |
| + for (peer, report) in screenReports.sorted(by: { $0.key < $1.key }) { |
| + lines.append("to \(name(of: peer)): \(report.summary)") |
| + lines.append(" frames captured \(report.framesCaptured), encoded \(report.framesEncoded), sent \(report.framesSent), keyframes \(report.keyFramesEncoded), asked for \(report.keyframeRequests), limitation \(report.limitation.isEmpty ? "none" : report.limitation)") |
| + } |
| + if let capture = media.screenCaptureCounts { |
| + lines.append("capture: \(capture.heartbeats ?? 0) reports, \(capture.delivered) frames delivered") |
| + } |
| + lines.append("watchdog: \(screenWatchdog.status)") |
| + } else { |
| + lines.append("Not sharing") |
| + } |
| + if !reportedStalled.isEmpty { |
| + lines.append("stalled here: " + reportedStalled.map { name(of: $0) }.joined(separator: ", ")) |
| + } |
| + let log = AudioDiagnosticsStore.screen.lines(last: 50) |
| + return [("Screen", lines), ("Screen log (last 50 lines)", log.isEmpty ? ["(nothing logged)"] : log)] |
| } |
| private var ice: ICEConfig? |
| private var pendingMedia: [Message] = [] |
| @@ -454,6 +494,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| let now = ProcessInfo.processInfo.systemUptime |
| self.sampleMic(now: now) |
| if tick % 2 == 0 { await self.samplePeers(stats: tick % 10 == 0, now: now) } |
| + if tick % 10 == 5 { await self.watchRemoteScreens(now: now) } |
| } |
| } |
| } |
| @@ -461,6 +502,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| private func endLevelWatch() { |
| levelWatch?.cancel() |
| levelWatch = nil |
| + screenReceiveWatch = ScreenReceiveWatch() |
| + reportedStalled = [] |
| levels.reset() |
| reportedNoSound = [] |
| audioBytes = [:] |
| @@ -714,6 +757,8 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| return |
| } |
| sharingScreen = true |
| + screenWatchdog.reset() |
| + ScreenLog.info("sharing started (\(screenMode.rawValue))") |
| send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "") |
| watchScreen() |
| } |
| @@ -732,20 +777,100 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| for (peer, report) in fresh { |
| measured[peer] = report.measured(since: self.screenReports[peer]) |
| } |
| + let previous = self.screenReports |
| self.screenReports = measured |
| let worst = ScreenSendReport.worst(Array(measured.values)) |
| self.screenReport = worst |
| // Motion mode scales from the source's own size, which only the |
| // statistics know (a window can be any shape, and re-picked). |
| if let worst { self.media.screenSource(width: worst.sourceWidth, height: worst.sourceHeight) } |
| + await self.runScreenWatchdog(measured, previous: previous) |
| } |
| } |
| } |
| + |
| + /// Off only for the gate's measurement of what happens without it. |
| + @_spi(Fixtures) public static var screenWatchdogEnabled = true |
| + |
| + /// One look by the sharer's watchdog, and whatever it asks for. |
| + private func runScreenWatchdog( |
| + _ reports: [String: ScreenSendReport], previous previousReports: [String: ScreenSendReport] |
| + ) async { |
| + guard sharingScreen, let capture = media.screenCaptureCounts else { return } |
| + let me = state?.peer ?? "" |
| + 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) |
| + } |
| + var actions = screenWatchdog.observe( |
| + at: ProcessInfo.processInfo.systemUptime, capture: capture, sending: sending, stalledBy: stalledBy) |
| + if !Self.screenWatchdogEnabled { actions = [] } |
| + for action in actions { |
| + switch action { |
| + case .restartCapture: |
| + ScreenLog.info("restarting the capture") |
| + let ok = await media.restartScreenCapture() |
| + if !ok { ScreenLog.error("the capture could not be restarted") } |
| + case .keyframe(let peer): |
| + ScreenLog.info("keyframe for \(ScreenWatchdog.short(peer))") |
| + media.forceScreenKeyframe(peer) |
| + case .recreateEncoder(let peer): |
| + ScreenLog.info("fresh encoder for \(ScreenWatchdog.short(peer))") |
| + media.recreateScreenEncoder(peer) |
| + case .reAdd(let peer): |
| + ScreenLog.info("screen re-added for \(ScreenWatchdog.short(peer))") |
| + media.reAddScreen(peer) |
| + // The new transceiver's track arrives unlabelled; say it again. |
| + Task { @MainActor [weak self] in |
| + try? await Task.sleep(nanoseconds: 2_000_000_000) |
| + guard let self, self.sharingScreen else { return } |
| + self.send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "") |
| + } |
| + } |
| + } |
| + screenWatchStatus = screenWatchdog.status |
| + if let (peer, worst) = reports.min(by: { $0.value.framesPerSecond < $1.value.framesPerSecond }) { |
| + screenDetail = worst.detail(since: previousReports[peer], watchdog: screenWatchdog.status) |
| + } |
| + let names = stalledBy.map { name(of: $0) }.sorted() |
| + screenRecovery = ScreenWords.recovering(names: names) |
| + } |
| private func endScreenWatch() { |
| screenWatch?.cancel() |
| screenWatch = nil |
| screenReports = [:] |
| screenReport = nil |
| + screenWatchdog.reset() |
| + screenWatchStatus = "" |
| + screenRecovery = "" |
| + screenDetail = "" |
| + } |
| + |
| + /// The receiver's watchdog, once a second from the level watch: whose |
| + /// screens have stopped moving here, reported as `screenStalled` when |
| + /// that changes (spec §15.21). |
| + private func watchRemoteScreens(now: Double) async { |
| + var stalled: [String] = [] |
| + if !remoteScreens.isEmpty { |
| + var frames = await media.remoteScreenFrames() |
| + // The gate's frozen picture: the counts held where they were. |
| + if now < screenWatchFrozenUntil { frames = frames.mapValues { _ in 0 } } |
| + let sharing = Set(screens) |
| + stalled = screenReceiveWatch.observe(at: now, decoded: frames.filter { sharing.contains($0.key) }) |
| + } else { |
| + screenReceiveWatch = ScreenReceiveWatch() |
| + } |
| + guard stalled != reportedStalled, let client = bridge else { return } |
| + for peer in stalled where !reportedStalled.contains(peer) { |
| + ScreenLog.info("screen from \(ScreenWatchdog.short(peer)) stalled here: asking for a keyframe") |
| + } |
| + for peer in reportedStalled where !stalled.contains(peer) { |
| + ScreenLog.info("screen from \(ScreenWatchdog.short(peer)) moving here again") |
| + } |
| + reportedStalled = stalled |
| + let body = json(stalled) ?? "[]" |
| + noSoundQueue.async { try? client.setScreenStalled(body) } |
| } |
| /// Stops sharing, from every way out: the button, the system's own stop |
| @@ -753,6 +878,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| public func stopSharingScreen() { |
| guard sharingScreen else { return } |
| sharingScreen = false |
| + ScreenLog.info("sharing stopped") |
| endScreenWatch() |
| media.stopScreen() |
| send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "") |
| @@ -778,6 +904,14 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// Caps the call's uplink estimate at `bps` (nil lifts it): the gate's |
| /// stand-in for a thin connection, which it has no other way to get. |
| public func limitUplink(_ bps: Int?) { media.limitUplink(bps) } |
| + /// For the gate: this app's receiver watch sees every screen frozen for |
| + /// `seconds`, so its stall report and the sharer's answer can be driven |
| + /// end to end. |
| + @_spi(Fixtures) public func freezeScreenWatchForTest(seconds: Double) { |
| + screenWatchFrozenUntil = ProcessInfo.processInfo.systemUptime + seconds |
| + } |
| + /// For the gate's encoder stall (see MediaEngine). |
| + @_spi(Fixtures) public func stallScreenEncoderForTest() { media.stallScreenEncoderForTest() } |
| public func sampleStats() async -> [String: UInt64] { |
| var values = await media.stats() |
| values["hasICE"] = ice == nil ? 0 : 1 |
| @@ -806,6 +940,21 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| values["screenRttMs"] = UInt64((screen.roundTripTime * 1000).rounded()) |
| values["screenPacketsLost"] = UInt64(max(0, screen.packetsLost)) |
| } |
| + // The screen watchdogs (ScreenWatchdog): what the sharer's has done, |
| + // how many stall reports it has heard, and whose screens this app has |
| + // reported stalled. |
| + values["screenWatchdogCaptureRestarts"] = UInt64(screenWatchdog.captureRestarts) |
| + values["screenWatchdogKeyframes"] = UInt64(screenWatchdog.keyframes) |
| + values["screenWatchdogEncoderRecoveries"] = UInt64(screenWatchdog.encoderRecoveries) |
| + values["screenWatchdogReAdds"] = UInt64(screenWatchdog.reAdds) |
| + values["screenStallReports"] = UInt64(screenWatchdog.stallReports) |
| + values["screenStalledHere"] = UInt64(reportedStalled.count) |
| + values["iceRestarts"] = media.iceRestarts |
| + values["screenKeyframes"] = screenReport?.keyFramesEncoded ?? 0 |
| + if let capture = media.screenCaptureCounts { |
| + values["screenCaptureHeartbeats"] = capture.heartbeats ?? 0 |
| + values["screenCaptureDelivered"] = capture.delivered |
| + } |
| values["rosterPeers"] = UInt64(state?.peers.count ?? 0) |
| values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed |
| values["noiseSuppressor"] = media.audio.denoiser.engineName == "RNNoise" ? 1 : 2 |
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift| index 86ede6f..1fa86b9 100644 |
| --- a/native/apple/Sources/CallsKit/CallChrome.swift |
| +++ b/native/apple/Sources/CallsKit/CallChrome.swift |
| @@ -364,7 +364,8 @@ public enum CallNotices { |
| /// own line ("In the meeting"), which is only a notice when it reports |
| /// a problem. |
| public static func notices( |
| - phase: String?, status: String, sharingScreen: Bool, screenNote: String, floor: Bool |
| + phase: String?, status: String, sharingScreen: Bool, screenNote: String, floor: Bool, |
| + screenRecovery: String = "" |
| ) -> [CallNotice] { |
| var out: [CallNotice] = [] |
| switch phase { |
| @@ -377,6 +378,8 @@ public enum CallNotices { |
| } |
| if sharingScreen { |
| out.append(CallNotice(kind: .sharing, text: "You are sharing your screen")) |
| + // Quiet: said while the watchdog puts it right, and gone after. |
| + if !screenRecovery.isEmpty { out.append(CallNotice(kind: .sharing, text: screenRecovery)) } |
| } else if !screenNote.isEmpty { |
| out.append(CallNotice(kind: .problem, text: screenNote)) |
| } |
diff --git a/native/apple/Sources/CallsKit/Capture.swift b/native/apple/Sources/CallsKit/Capture.swift| index 96926f0..3b773c2 100644 |
| --- a/native/apple/Sources/CallsKit/Capture.swift |
| +++ b/native/apple/Sources/CallsKit/Capture.swift |
| @@ -98,6 +98,36 @@ extension VideoCapture { |
| @MainActor public protocol ScreenCapture: AnyObject { |
| func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws |
| func stop() |
| + /// What the capture has produced so far, for the screen watchdog. |
| + var counts: ScreenCaptureCounts { get } |
| + /// Starts the same capture again — the same content into the same |
| + /// source — after the watchdog has seen it stall. False when this kind |
| + /// of capture cannot, or the attempt failed. |
| + func restart() async -> Bool |
| +} |
| + |
| +extension ScreenCapture { |
| + public var counts: ScreenCaptureCounts { ScreenCaptureCounts() } |
| + public func restart() async -> Bool { false } |
| +} |
| + |
| +/// A screen capture's running totals (see ScreenWatchdog). |
| +public struct ScreenCaptureCounts: Equatable, Sendable { |
| + /// Everything the capture has reported, still-screen reports included: |
| + /// ScreenCaptureKit reports "idle" at the frame rate while nothing on |
| + /// screen changes (measured 2026-09-28: 177 idle reports in six seconds |
| + /// of a still window), so a capture that reports nothing at all has |
| + /// stopped, not gone quiet. Nil for a capture that cannot tell the two |
| + /// apart — a ReplayKit broadcast sends nothing for a still screen — |
| + /// which turns the watchdog's capture check off for it. |
| + public var heartbeats: UInt64? |
| + /// Frames handed to the call's source, repeats of a still screen included. |
| + public var delivered: UInt64 = 0 |
| + |
| + public init(heartbeats: UInt64? = nil, delivered: UInt64 = 0) { |
| + self.heartbeats = heartbeats |
| + self.delivered = delivered |
| + } |
| } |
| // --------------------------------------------------------------------- |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index eacfeb3..1dc202b 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -320,13 +320,20 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("audioProcessing") |
| } |
| - CopyAudioDiagnosticsButton { call.audioDiagnosticsText() }.padding(.top, 6) |
| + CopyAudioDiagnosticsButton(title: "Copy call diagnostics") { call.audioDiagnosticsText() } |
| + .padding(.top, 6) |
| if call.sharingScreen { |
| Text("Screen sharing").font(.headline).padding(.top, 6) |
| Text(call.screenReport?.summary ?? "Screen: starting…") |
| .font(.callout).foregroundStyle(.secondary) |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("screenReport") |
| + if !call.screenDetail.isEmpty { |
| + Text(call.screenDetail) |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("screenDetail") |
| + } |
| } |
| } |
| .textSelection(.enabled) |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index 6769d5f..9d58731 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -104,6 +104,10 @@ |
| if call.sharingScreen { |
| Label("You are sharing your screen", systemImage: "rectangle.on.rectangle") |
| .font(.callout.bold()).foregroundStyle(.orange) |
| + if !call.screenRecovery.isEmpty { |
| + Text(call.screenRecovery).font(.callout).foregroundStyle(.secondary) |
| + .accessibilityIdentifier("screenRecovery") |
| + } |
| } else if !call.screenNote.isEmpty { |
| Text(call.screenNote).font(.callout).foregroundStyle(.secondary) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index f350f4f..5887419 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -305,7 +305,7 @@ |
| private var notices: [CallNotice] { |
| CallNotices.notices( |
| phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| - screenNote: call.screenNote, floor: call.floor) |
| + screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| } |
| private var topOverlay: some View { |
diff --git a/native/apple/Sources/CallsKit/MacScreenCapture.swift b/native/apple/Sources/CallsKit/MacScreenCapture.swift| index ee6d3c7..2ee3714 100644 |
| --- a/native/apple/Sources/CallsKit/MacScreenCapture.swift |
| +++ b/native/apple/Sources/CallsKit/MacScreenCapture.swift |
| @@ -4,6 +4,7 @@ |
| import Foundation |
| import ScreenCaptureKit |
| import LiveKitWebRTC |
| + import os |
| /// A display or a window, chosen with the system's own picker and |
| /// delivered into the call's screen source. |
| @@ -20,15 +21,35 @@ |
| /// The capture ends on its own when the person stops it from the menu |
| /// bar's sharing indicator, or the shared window closes; either one |
| /// reaches `ended`, which the call treats exactly like its own button. |
| + /// |
| + /// It can also be started again on the same content (`restart`), which |
| + /// is what the screen watchdog asks for when the capture stops reporting |
| + /// anything at all (see ScreenWatchdog), and what a capture error other |
| + /// than the person stopping it gets before the share is given up. |
| @MainActor public final class MacScreenCapture: NSObject, ScreenCapture { |
| private var stream: SCStream? |
| private var frames: ScreenFrames? |
| + private var filter: SCContentFilter? |
| + private var source: RTCVideoSource? |
| private var ended: (@MainActor () -> Void)? |
| private var choosing: CheckedContinuation<SCContentFilter, Error>? |
| private let queue = DispatchQueue(label: "calls.screen", qos: .userInteractive) |
| + // Counts from the captures this one has replaced, so the totals the |
| + // watchdog reads run on across a restart. |
| + private var earlier = ScreenCaptureCounts(heartbeats: 0, delivered: 0) |
| + // Restarts after a capture error, for `didStopWithError`'s budget. |
| + private var errorRestarts: [Date] = [] |
| + private var restarting = false |
| public override init() {} |
| + public var counts: ScreenCaptureCounts { |
| + let now = frames?.counts ?? ScreenCaptureCounts(heartbeats: 0, delivered: 0) |
| + return ScreenCaptureCounts( |
| + heartbeats: (earlier.heartbeats ?? 0) + (now.heartbeats ?? 0), |
| + delivered: earlier.delivered + now.delivered) |
| + } |
| + |
| public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { |
| let picker = SCContentSharingPicker.shared |
| var configuration = SCContentSharingPickerConfiguration() |
| @@ -53,18 +74,65 @@ |
| release() |
| throw error |
| } |
| + do { |
| + try await begin(filter: filter, source: source) |
| + } catch { |
| + release() |
| + throw error |
| + } |
| + self.ended = ended |
| + } |
| + |
| + /// A given display, with no picker: the interop gate's real capture, |
| + /// which is the only way to put ScreenCaptureKit and the hardware |
| + /// encoder under test together. Needs Screen Recording permission for |
| + /// the process, which the picker path never does. |
| + @_spi(Fixtures) public func start( |
| + display: SCDisplay, source: RTCVideoSource, ended: @escaping @MainActor () -> Void |
| + ) async throws { |
| + try await begin(filter: SCContentFilter(display: display, excludingWindows: []), source: source) |
| + self.ended = ended |
| + } |
| + |
| + private func begin(filter: SCContentFilter, source: RTCVideoSource) async throws { |
| let frames = ScreenFrames(source: source, queue: queue) |
| let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self) |
| do { |
| try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue) |
| try await stream.startCapture() |
| } catch { |
| - release() |
| + frames.stop() |
| throw error |
| } |
| self.stream = stream |
| self.frames = frames |
| - self.ended = ended |
| + self.filter = filter |
| + self.source = source |
| + } |
| + |
| + /// The same content into the same source, on a capture of its own: |
| + /// a fresh stream with a fresh pool of surfaces. What stopping and |
| + /// sharing again did by hand, without the picker and without the |
| + /// call hearing about it — the transceivers and tracks stay as they |
| + /// are, and only the frames behind them are new. |
| + public func restart() async -> Bool { |
| + guard !restarting, let filter, let source, let old = stream else { return false } |
| + restarting = true |
| + defer { restarting = false } |
| + earlier = counts |
| + stream = nil |
| + frames?.stop() |
| + frames = nil |
| + try? await old.stopCapture() |
| + guard filter === self.filter, self.source === source else { return false } |
| + do { |
| + try await begin(filter: filter, source: source) |
| + ScreenLog.info("capture restarted") |
| + return true |
| + } catch { |
| + ScreenLog.error("capture restart failed: \(Self.describe(error))") |
| + return false |
| + } |
| } |
| public func stop() { |
| @@ -73,6 +141,8 @@ |
| self.stream = nil |
| frames?.stop() |
| frames = nil |
| + filter = nil |
| + source = nil |
| release() |
| stream?.stopCapture { _ in } |
| } |
| @@ -127,9 +197,22 @@ |
| /// costs the encoder for nothing. What gives way under pressure is the |
| /// "Optimise for" mode's (ScreenSendPolicy). |
| /// |
| - /// Three surfaces in the queue, not five: a frame waiting behind two |
| - /// others is already a tenth of a second late, and a screen that |
| - /// arrives late is exactly the complaint this was tuned against. |
| + /// Six surfaces in the queue. The queue depth is not a queue of frames |
| + /// waiting to be sent — a late frame is dropped on arrival (see |
| + /// ScreenFrames) — it is the pool of surfaces the capture draws into, |
| + /// and every surface the encoder is still holding, or this capture is |
| + /// keeping for its repeat, is one the capture cannot use. When they |
| + /// are all held the capture stops outright: no frames and no idle |
| + /// reports either, until one comes back. Measured on 2026-09-28 at a |
| + /// depth of three, holding three surfaces stopped a display capture |
| + /// after three frames, and holding two did not. 0.4.0 went from five |
| + /// to three and began holding one for the repeat, which left the |
| + /// hardware encoder two frames in flight before the screen froze |
| + /// (Paul, 2026-09-28: "after a while, my screen froze on her end"). |
| + /// The depth above. Settable only for the interop gate, which measures |
| + /// the depth 0.4.1 shipped with against this one. |
| + @_spi(Fixtures) public static var queueDepth = 6 |
| + |
| static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration { |
| let configuration = SCStreamConfiguration() |
| let rect = filter.contentRect |
| @@ -140,7 +223,7 @@ |
| configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30) |
| configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange |
| configuration.showsCursor = true |
| - configuration.queueDepth = 3 |
| + configuration.queueDepth = queueDepth |
| return configuration |
| } |
| } |
| @@ -175,6 +258,7 @@ |
| guard let current = self.stream, stream == nil || stream === current else { return } |
| let next = await Self.withoutCalls(filter) |
| guard current === self.stream else { return } |
| + self.filter = next |
| current.updateContentFilter(next) { _ in } |
| current.updateConfiguration(Self.configuration(for: next)) { _ in } |
| } |
| @@ -190,12 +274,46 @@ |
| } |
| extension MacScreenCapture: SCStreamDelegate { |
| + /// The person stopping the share from the menu bar, or the shared |
| + /// window or display going away, ends it: that is what they asked for, |
| + /// or all there is. Anything else — the capture service interrupted, |
| + /// the system stopping the stream across a sleep or a display change — |
| + /// is started again on the same content, up to three times a minute, |
| + /// before the share is given up. Up to 0.4.1 every error ended the |
| + /// share, and a person whose capture had merely hiccuped was left to |
| + /// notice and share again. |
| nonisolated public func stream(_ stream: SCStream, didStopWithError error: Error) { |
| Task { @MainActor [weak self] in |
| guard let self, stream === self.stream else { return } |
| - self.finished() |
| + ScreenLog.info("capture stopped: \(Self.describe(error))") |
| + let now = Date() |
| + self.errorRestarts.removeAll { now.timeIntervalSince($0) > 60 } |
| + guard Self.recoverable(error), self.errorRestarts.count < 3 else { |
| + self.finished() |
| + return |
| + } |
| + self.errorRestarts.append(now) |
| + if await !self.restart() { self.finished() } |
| + } |
| + } |
| + |
| + /// Whether a capture error is worth starting again after. |
| + nonisolated static func recoverable(_ error: Error) -> Bool { |
| + let error = error as NSError |
| + guard error.domain == SCStreamErrorDomain else { return true } |
| + switch SCStreamError.Code(rawValue: error.code) { |
| + case .userStopped?, .noCaptureSource?, .noWindowList?, .noDisplayList?, .userDeclined?, |
| + .missingEntitlements?: |
| + return false |
| + default: |
| + return true |
| } |
| } |
| + |
| + nonisolated static func describe(_ error: Error) -> String { |
| + let error = error as NSError |
| + return "\(error.domain) \(error.code)" |
| + } |
| } |
| struct ScreenShareRefused: Error {} |
| @@ -226,6 +344,9 @@ |
| // and is usually still holding anyway. |
| private var last: CVPixelBuffer? |
| private var lastSent: UInt64 = 0 |
| + // Read on the main actor by the watchdog; written on `queue`. |
| + private let tally = OSAllocatedUnfairLock(initialState: ScreenCaptureCounts(heartbeats: 0, delivered: 0)) |
| + var counts: ScreenCaptureCounts { tally.withLock { $0 } } |
| static let stale = 0.1 |
| static let repeatAfter: UInt64 = 1_000_000_000 |
| @@ -251,7 +372,10 @@ |
| func stream( |
| _ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType |
| ) { |
| - guard type == .screen, sample.isValid, let pixels = sample.imageBuffer else { return } |
| + guard type == .screen, sample.isValid else { return } |
| + // Anything at all, idle reports included, is the capture alive. |
| + tally.withLock { $0.heartbeats = ($0.heartbeats ?? 0) + 1 } |
| + guard let pixels = sample.imageBuffer else { return } |
| // Only complete frames carry pixels. An idle screen sends status |
| // frames and nothing else; the repeat below covers it. |
| guard |
| @@ -281,6 +405,7 @@ |
| // otherwise be followed by an older one. |
| guard ns > lastSent else { return } |
| lastSent = ns |
| + tally.withLock { $0.delivered += 1 } |
| source.capturer( |
| capturer, |
| didCapture: RTCVideoFrame( |
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift| index df3ea7d..aa6739c 100644 |
| --- a/native/apple/Sources/CallsKit/Media.swift |
| +++ b/native/apple/Sources/CallsKit/Media.swift |
| @@ -131,7 +131,8 @@ import LiveKitWebRTC |
| screenAttempt += 1 |
| let attempt = screenAttempt |
| screenSize = (0, 0) |
| - let source = factory.videoSource(forScreenCast: ScreenSendPolicy.screencast(screenMode)) |
| + screenIsScreencast = ScreenSendPolicy.screencast(screenMode) |
| + let source = factory.videoSource(forScreenCast: screenIsScreencast) |
| try await capture.start(source: source, ended: ended) |
| guard attempt == screenAttempt, localScreen == nil else { |
| capture.stop() |
| @@ -150,10 +151,54 @@ import LiveKitWebRTC |
| guard let capture = screenCapture else { return } |
| screenCapture = nil |
| localScreen = nil |
| - for peer in peers.values { peer.removeScreen() } |
| + for peer in peers.values { |
| + peer.removeScreen() |
| + peer.screenStopped() |
| + } |
| capture.stop() |
| } |
| + // ------------------------------------------------------------- |
| + // The screen watchdog's hands (ScreenWatchdog, run by Call) |
| + // ------------------------------------------------------------- |
| + |
| + /// What the capture has produced so far; nothing while not sharing. |
| + var screenCaptureCounts: ScreenCaptureCounts? { screenCapture?.counts } |
| + |
| + /// A fresh capture of the same content, into the same source. |
| + func restartScreenCapture() async -> Bool { |
| + guard let capture = screenCapture else { return false } |
| + return await capture.restart() |
| + } |
| + func forceScreenKeyframe(_ peer: String) { peers[peer]?.forceScreenKeyframe() } |
| + func recreateScreenEncoder(_ peer: String) { peers[peer]?.recreateScreenEncoder() } |
| + func reAddScreen(_ peer: String) { peers[peer]?.reAddScreen() } |
| + |
| + /// For the gate: every screen encoding switched off behind the |
| + /// watchdog's back, which is what an encoder that has stopped looks |
| + /// like from outside it — frames go in, and nothing comes out. |
| + func stallScreenEncoderForTest() { |
| + for peer in peers.values { peer.setScreenActive(false) } |
| + } |
| + |
| + /// Frames decoded so far from each peer's screen, for the peers whose |
| + /// screen is arriving here (the receiver's watchdog). |
| + func remoteScreenFrames() async -> [String: UInt64] { |
| + var out: [String: UInt64] = [:] |
| + for (id, peer) in peers { |
| + guard let pc = peer.pc, peer.receivingScreen else { continue } |
| + let report = await PeerCalls.statistics(pc) |
| + var decoded: UInt64 = 0 |
| + for stat in report.statistics.values where stat.type == "inbound-rtp" { |
| + let mid = stat.values["mid"] as? String ?? "" |
| + guard peer.isScreen(mid: mid) else { continue } |
| + decoded += (stat.values["framesDecoded"] as? NSNumber)?.uint64Value ?? 0 |
| + } |
| + out[id] = decoded |
| + } |
| + return out |
| + } |
| + |
| /// Puts every connection's screen and camera senders where the policy |
| /// wants them; a no-op for any that already are. |
| func applySendPolicy() { |
| @@ -166,8 +211,12 @@ import LiveKitWebRTC |
| applySendPolicy() |
| } |
| var screenPlan: EncodingPlan { |
| - ScreenSendPolicy.screen(screenMode, width: screenSize.width, height: screenSize.height) |
| + ScreenSendPolicy.screen( |
| + screenMode, width: screenSize.width, height: screenSize.height, screencast: screenIsScreencast) |
| } |
| + /// Whether this share's source was made as a screencast: fixed when the |
| + /// share starts, whatever the mode is changed to afterwards. |
| + private var screenIsScreencast = true |
| func close() { |
| stopScreen() |
| @@ -210,7 +259,12 @@ import LiveKitWebRTC |
| guard let from = message.from, let peer = peers[from] else { return } |
| peer.share(on: message.payload?.on == true) |
| } |
| - func updateICE(_ config: ICEConfig) { for p in peers.values { p.updateICE(config) } } |
| + /// ICE restarts this app has started for a renewal, for the gate. |
| + var iceRestarts: UInt64 = 0 |
| + func updateICE(_ config: ICEConfig) { |
| + let now = Int64(Date().timeIntervalSince1970) |
| + for p in peers.values { p.updateICE(config, now: now) } |
| + } |
| /// The five audio-health fields, gathered alongside the byte counts. |
| /// |
| /// Bytes say audio arrived; they say nothing about what it sounded like. |
| @@ -386,6 +440,9 @@ struct PeerAudioReading: Equatable, Sendable { |
| private var ignoreOffer = false |
| private var candidates: [Candidate] = [] |
| private var lastICE: ICEConfig? |
| + /// The list this connection last gathered on — built with, or last |
| + /// restarted onto — whose `renewAt` says when it needs a restart. |
| + private var gatheredICE: ICEConfig? |
| // §5.4, receiving. What this peer's camera and microphone came in on, |
| // and whether it has said it is sharing; the receiver its screen |
| // arrived on, so that only *that* receiver going away takes the tile |
| @@ -426,6 +483,7 @@ struct PeerAudioReading: Equatable, Sendable { |
| func open(ice: ICEConfig) { |
| guard let engine else { return } |
| lastICE = ice |
| + gatheredICE = ice |
| let factory = engine.factory |
| let config = configuration(ice) |
| let audio = engine.localAudio |
| @@ -512,7 +570,8 @@ struct PeerAudioReading: Equatable, Sendable { |
| func applySendPolicy() { |
| guard let pc, !closed, let engine else { return } |
| let screen = screenTx?.sender |
| - let screenPlan = engine.screenPlan |
| + var screenPlan = engine.screenPlan |
| + if nudge > 1 { screenPlan.scaleResolutionDownBy = (screenPlan.scaleResolutionDownBy ?? 1) * nudge } |
| let cameraTrack = engine.localVideo |
| let camera = ScreenSendPolicy.camera(sharing: screenTx != nil) |
| AudioWork.post { |
| @@ -747,15 +806,89 @@ struct PeerAudioReading: Equatable, Sendable { |
| candidates = [] |
| for c in saved { try await add(c) } |
| } |
| - func updateICE(_ ice: ICEConfig) { |
| - guard ice.renewAt != lastICE?.renewAt else { return } |
| + /// A fresh `/ice` answer (§5.2, §5.3). The core fetches one on every |
| + /// roster, and a roster answers every stream resume — every five |
| + /// minutes at the stream's own TTL — so most answers are not renewals |
| + /// at all. Up to 0.4.1 every one of them restarted ICE on every |
| + /// connection, twelve times an hour, each one a new path for the |
| + /// screen and camera to find their way back onto. Now only a renewal |
| + /// that is due restarts (ICERenewal.restart); anything else is adopted |
| + /// quietly, for whatever this connection gathers next. |
| + func updateICE(_ ice: ICEConfig, now: Int64) { |
| + guard ice.renewAt != lastICE?.renewAt || ice.iceServers.count != lastICE?.iceServers.count else { return } |
| lastICE = ice |
| + let restart = ICERenewal.restart(gathered: gatheredICE, next: ice, now: now) |
| + if restart { gatheredICE = ice } |
| enqueue { [weak self] in |
| guard let self, let pc = self.pc else { return } |
| let config = self.configuration(ice) |
| - if await PeerCalls.read(pc, { $0.setConfiguration(config) }) { try await self.offer(restart: true) } |
| + guard await PeerCalls.read(pc, { $0.setConfiguration(config) }) else { return } |
| + if restart { |
| + ScreenLog.info("ICE restart with \(ScreenWatchdog.short(self.id)): renewal due") |
| + self.engine?.iceRestarts += 1 |
| + try await self.offer(restart: true) |
| + } |
| + } |
| + } |
| + |
| + // ------------------------------------------------------------- |
| + // The screen watchdog's hands, for this connection |
| + // ------------------------------------------------------------- |
| + |
| + /// Whether this peer's screen is arriving here. |
| + var receivingScreen: Bool { screenReceiver != nil && !screenMid.isEmpty } |
| + |
| + /// The screen's encoding switched off and on again. WebRTC starts a |
| + /// stream that comes back on with a keyframe, which is the one thing a |
| + /// receiver stuck on a broken picture needs; there is no API on this |
| + /// build of WebRTC to ask for one directly. |
| + func forceScreenKeyframe() { |
| + setScreenActive(false) |
| + Task { @MainActor [weak self] in |
| + try? await Task.sleep(nanoseconds: 150_000_000) |
| + self?.setScreenActive(true) |
| + } |
| + } |
| + |
| + func setScreenActive(_ active: Bool) { |
| + guard let sender = screenTx?.sender else { return } |
| + AudioWork.post { |
| + let parameters = sender.parameters |
| + guard let encoding = parameters.encodings.first, encoding.isActive != active else { return } |
| + encoding.isActive = active |
| + sender.parameters = parameters |
| } |
| } |
| + |
| + /// 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. |
| + func recreateScreenEncoder() { |
| + guard screenTx != nil else { return } |
| + nudge = min(nudge * 1.5, 4) |
| + ScreenLog.info("screen to \(ScreenWatchdog.short(id)) now sent at 1/\(String(format: "%.2f", nudge)) of its size") |
| + applySendPolicy() |
| + setScreenActive(true) |
| + } |
| + private var nudge = 1.0 |
| + |
| + /// What stopping and sharing again did, on this connection alone: its |
| + /// screen transceiver stopped and renegotiated, and a new one added as |
| + /// soon as that settles (`settled` adds the screen to a connection that |
| + /// has none while the app is sharing). |
| + func reAddScreen() { |
| + guard screenTx != nil else { return } |
| + removeScreen() |
| + } |
| + /// The share has stopped: the next one starts at full size. |
| + func screenStopped() { nudge = 1 } |
| + |
| nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { |
| Task { @MainActor [weak self] in |
| guard let self else { return } |
diff --git a/native/apple/Sources/CallsKit/ScreenSendPolicy.swift b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift| index b159dd1..dda0220 100644 |
| --- a/native/apple/Sources/CallsKit/ScreenSendPolicy.swift |
| +++ b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift |
| @@ -78,7 +78,7 @@ struct EncodingPlan: Equatable { |
| /// changed mid-share, the camera capped and restored, all without a |
| /// renegotiation for perfect negotiation to wobble over (see room.js's |
| /// tuneAudioSender for the same reasoning on the browser side). |
| -enum ScreenSendPolicy { |
| +public enum ScreenSendPolicy { |
| /// A screen longer than this on its long side is scaled down to it in |
| /// motion mode: 720p's worth of pixels is what 30 fps fits in. |
| static let motionLongestSide = 1280.0 |
| @@ -97,12 +97,50 @@ enum ScreenSendPolicy { |
| /// on a thinner link than the screencast did. |
| static func screencast(_ mode: ScreenShareMode) -> Bool { mode != .motion } |
| + /// The most 16×16 blocks a frame from a source that is *not* marked as |
| + /// a screencast may have: H.264 level 3.1's 3,600, which is 1280×720. |
| + /// |
| + /// Chromium answers every H.264 offer at level 3.1, and the Mac's |
| + /// hardware encoder, as this build of WebRTC sets it up, holds a camera- |
| + /// like stream to the level it negotiated: a larger frame is refused by |
| + /// VideoToolbox (kVTParameterErr, -12902) and nothing is encoded at all. |
| + /// A screencast source is set up another way and is not held to it — |
| + /// measured 2026-09-28 at 1920×1246, steady. So this is the motion |
| + /// mode's cap, and the cap for any mode on a share that *started* in |
| + /// motion mode (its source cannot become a screencast later). |
| + /// |
| + /// It is Paul's freeze of 2026-09-28 ("after a while, my screen froze on |
| + /// her end"): a 16:10 display in motion mode is 1280×830 at its 1280 cap, |
| + /// 4,160 blocks. It went out at 960×620 while the link was still |
| + /// ramping, and the moment WebRTC let it up to full size, the encoder |
| + /// stopped — for eight or nine seconds at a time, until WebRTC's quality |
| + /// scaler stepped it down again, over and over; and for good if the mode |
| + /// was then switched to Text and detail, which does not let the size |
| + /// fall. A 16:9 page at 1280×720 is exactly 3,600, which is why the |
| + /// motion gate's synthetic 1080p page never met it. |
| + static let realtimeMacroblocks = 3600 |
| + /// Off only for the gate, which measures 0.4.1's sizing against it. |
| + @_spi(Fixtures) public static var levelCapped = true |
| + |
| /// The screen's encoding, for a source of the given size (zero while |
| - /// it is not known yet, which leaves the scale alone). |
| - static func screen(_ mode: ScreenShareMode, width: Int = 0, height: Int = 0) -> EncodingPlan { |
| + /// it is not known yet, which leaves the scale alone). `screencast` is |
| + /// whether the source was made as one (see `realtimeMacroblocks`). |
| + static func screen( |
| + _ mode: ScreenShareMode, width: Int = 0, height: Int = 0, screencast: Bool? = nil |
| + ) -> EncodingPlan { |
| let longest = Double(max(width, height)) |
| + let capped = levelCapped && !(screencast ?? Self.screencast(mode)) && width > 0 && height > 0 |
| func fit(_ side: Double) -> Double? { |
| - longest > side ? longest / side : nil |
| + var scale = longest > side ? longest / side : 1 |
| + if capped { |
| + // Down until the frame's blocks fit the level, as WebRTC will |
| + // size it: each side divided by the scale, rounded down. |
| + func blocks(_ s: Double) -> Int { |
| + ((Int(Double(width) / s) + 15) / 16) * ((Int(Double(height) / s) + 15) / 16) |
| + } |
| + while blocks(scale) > realtimeMacroblocks { scale *= 1.01 } |
| + } |
| + return scale > 1 ? scale : nil |
| } |
| switch mode { |
| case .detail: |
diff --git a/native/apple/Sources/CallsKit/ScreenSendStats.swift b/native/apple/Sources/CallsKit/ScreenSendStats.swift| index 6bc8847..79fc6d7 100644 |
| --- a/native/apple/Sources/CallsKit/ScreenSendStats.swift |
| +++ b/native/apple/Sources/CallsKit/ScreenSendStats.swift |
| @@ -22,6 +22,10 @@ public struct ScreenSendReport: Equatable, Sendable { |
| public var framesSent: UInt64 = 0 |
| public var framesEncoded: UInt64 = 0 |
| public var framesCaptured: UInt64 = 0 |
| + /// Keyframes the encoder has made, and the keyframe requests (PLI and |
| + /// FIR) that have come back from the receiver for this stream. |
| + public var keyFramesEncoded: UInt64 = 0 |
| + public var keyframeRequests: UInt64 = 0 |
| /// "none", "bandwidth", "cpu" or "other", as WebRTC names them. |
| public var limitation = "" |
| /// Seconds spent under each limitation since the stream began. |
| @@ -86,6 +90,26 @@ public struct ScreenSendReport: Equatable, Sendable { |
| return line |
| } |
| + /// The second line, under `summary`, while sharing: what the encoder |
| + /// and the watchdog are doing, e.g. "Encoded 29 fps, sent 29 fps, |
| + /// keyframes 4 (3 asked for); watchdog: watching". |
| + public func detail(since previous: ScreenSendReport?, watchdog: String) -> String { |
| + var encodedRate = framesPerSecond |
| + var sentRate = framesPerSecond |
| + if let previous, timestamp > previous.timestamp, framesEncoded >= previous.framesEncoded, |
| + framesSent >= previous.framesSent |
| + { |
| + let seconds = timestamp - previous.timestamp |
| + encodedRate = Double(framesEncoded - previous.framesEncoded) / seconds |
| + sentRate = Double(framesSent - previous.framesSent) / seconds |
| + } |
| + var line = |
| + "Encoded \(Int(encodedRate.rounded())) fps, sent \(Int(sentRate.rounded())) fps, keyframes \(keyFramesEncoded)" |
| + + " (\(keyframeRequests) asked for)" |
| + if !watchdog.isEmpty { line += "; watchdog: \(watchdog)" } |
| + return line |
| + } |
| + |
| static func megabits(_ bps: UInt64) -> String { |
| let mbit = Double(bps) / 1_000_000 |
| return mbit >= 1 ? String(format: "%.1f Mbit/s", mbit) : "\(Int((Double(bps) / 1000).rounded())) kbit/s" |
| @@ -128,6 +152,8 @@ extension ScreenSendReport { |
| r.framesPerSecond = double(outbound["framesPerSecond"]) |
| r.framesSent = UInt64(int(outbound["framesSent"])) |
| r.framesEncoded = UInt64(int(outbound["framesEncoded"])) |
| + r.keyFramesEncoded = UInt64(int(outbound["keyFramesEncoded"])) |
| + r.keyframeRequests = UInt64(int(outbound["pliCount"]) + int(outbound["firCount"])) |
| r.limitation = outbound["qualityLimitationReason"] as? String ?? "" |
| if let durations = outbound["qualityLimitationDurations"] as? [String: Any] { |
| r.limitationSeconds = durations.mapValues { double($0) } |
diff --git a/native/apple/Sources/CallsKit/ScreenWatchdog.swift b/native/apple/Sources/CallsKit/ScreenWatchdog.swift| new file mode 100644 |
| index 0000000..eabdc35 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ScreenWatchdog.swift |
| @@ -0,0 +1,286 @@ |
| +import Foundation |
| +import os |
| + |
| +// The screen watchdogs: the sharer's, which notices its own screen |
| +// stopping on the way out and puts it right, and the receiver's, which |
| +// notices someone else's screen stopping on the way in and says so. |
| +// |
| +// Written for Paul's report of 2026-09-28 (Mac 0.4.1): "When I was |
| +// screen-sharing, after a while, my screen froze on her end and she was |
| +// no longer able to see my screen. I had to stop and re-share again." A |
| +// stop and a share again is a fresh capture, a fresh transceiver, a fresh |
| +// encoder and a keyframe; the sharer's watchdog does each of those in |
| +// turn, cheapest first, without anyone noticing or pressing anything. |
| + |
| +/// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "screen"' --info`. |
| +/// Every line is also kept in memory (the last 200) for "Copy call |
| +/// diagnostics". |
| +enum ScreenLog { |
| + static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "screen") |
| + |
| + static func info(_ message: String) { |
| + logger.info("\(message, privacy: .public)") |
| + AudioDiagnosticsStore.screen.log("info", message) |
| + } |
| + |
| + static func error(_ message: String) { |
| + logger.error("\(message, privacy: .public)") |
| + AudioDiagnosticsStore.screen.log("error", message) |
| + } |
| +} |
| + |
| +extension AudioDiagnosticsStore { |
| + /// The screen log's lines, kept apart from the audio log's so neither |
| + /// pushes the other out. |
| + public static let screen = AudioDiagnosticsStore() |
| +} |
| + |
| +/// One connection's screen on the way out, as the watchdog reads it. |
| +struct ScreenSendCounts: Equatable, Sendable { |
| + var encoded: UInt64 = 0 |
| + var sent: UInt64 = 0 |
| + var keyframes: UInt64 = 0 |
| +} |
| + |
| +/// What the sharer's watchdog asks for. Each is cheaper than the next. |
| +enum ScreenRecovery: Equatable, Sendable { |
| + /// A fresh capture of the same content into the same source. |
| + case restartCapture |
| + /// A keyframe on this connection's screen: the encoding switched off |
| + /// and on again, which WebRTC answers by starting the stream on one. |
| + case keyframe(String) |
| + /// A fresh encoder on this connection: the screen's size nudged, which |
| + /// makes WebRTC set the hardware encoder up again from nothing. |
| + case recreateEncoder(String) |
| + /// What stopping and sharing again did: this connection's screen |
| + /// transceiver stopped and a new one added, renegotiated. |
| + case reAdd(String) |
| +} |
| + |
| +/// The sharer's watchdog: pure, fed once a second while sharing. |
| +/// |
| +/// Three things can freeze a screen on its way out, and each has its own |
| +/// sign: |
| +/// |
| +/// - **The capture stops.** ScreenCaptureKit reports even a still screen |
| +/// (as "idle"), so a capture that reports nothing for three seconds has |
| +/// stopped — a capture with every surface held by the encoder does |
| +/// 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. |
| +/// 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. |
| +struct ScreenWatchdog: Equatable { |
| + static let captureStallAfter = 3.0 |
| + static let captureBackoffFirst = 5.0 |
| + static let captureBackoffMost = 60.0 |
| + static let encoderStallAfter = 3.0 |
| + static let encoderStepEvery = 3.0 |
| + static let reportStepEvery = 4.0 |
| + static let holdOff = 30.0 |
| + |
| + struct Peer: Equatable { |
| + var last = ScreenSendCounts() |
| + var movedAt: Double |
| + /// Delivered frames when `movedAt` was last set: a connection is only |
| + /// stalled if frames went in meanwhile. |
| + var deliveredAt: UInt64 |
| + var step = 0 |
| + var stepAt = 0.0 |
| + var reported = false |
| + var restingUntil = 0.0 |
| + } |
| + |
| + private(set) var peers: [String: Peer] = [:] |
| + private var heartbeats: UInt64? |
| + private var heartbeatAt = 0.0 |
| + private var delivered: UInt64 = 0 |
| + private var captureRetryAt = 0.0 |
| + private var captureBackoff = ScreenWatchdog.captureBackoffFirst |
| + private(set) var captureStalled = false |
| + |
| + // For the readout and the gate. |
| + private(set) var captureRestarts = 0 |
| + private(set) var keyframes = 0 |
| + private(set) var encoderRecoveries = 0 |
| + private(set) var reAdds = 0 |
| + private(set) var stallReports = 0 |
| + /// One line on what the watchdog is doing, for the device panel. |
| + private(set) var status = "watching" |
| + |
| + init() {} |
| + |
| + /// Forgets everything: a share starting, or a restart that should be |
| + /// given a clean look. |
| + mutating func reset() { self = ScreenWatchdog() } |
| + |
| + /// One look. `capture` is the capture's running totals; `sending` each |
| + /// connection's screen on the way out, by peer id, for the connections |
| + /// that have one negotiated; `stalledBy` the peers whose latest report |
| + /// says our screen has stalled for them. Returns what to do now. |
| + mutating func observe( |
| + at now: Double, capture: ScreenCaptureCounts, sending: [String: ScreenSendCounts], |
| + stalledBy: Set<String> |
| + ) -> [ScreenRecovery] { |
| + var out: [ScreenRecovery] = [] |
| + // The capture. |
| + let fresh = heartbeats == nil |
| + if let beats = capture.heartbeats { |
| + if fresh || beats != heartbeats { |
| + heartbeats = beats |
| + heartbeatAt = now |
| + if captureStalled { |
| + captureStalled = false |
| + captureBackoff = Self.captureBackoffFirst |
| + ScreenLog.info("capture reporting again") |
| + } |
| + } else if now - heartbeatAt >= Self.captureStallAfter { |
| + if !captureStalled { |
| + captureStalled = true |
| + ScreenLog.info("capture stalled: nothing reported for \(Int(now - heartbeatAt)) s") |
| + } |
| + if now >= captureRetryAt { |
| + out.append(.restartCapture) |
| + captureRestarts += 1 |
| + captureRetryAt = now + captureBackoff |
| + captureBackoff = min(captureBackoff * 2, Self.captureBackoffMost) |
| + } |
| + } |
| + } |
| + delivered = capture.delivered |
| + // Each connection. |
| + // A connection that has no screen negotiated for the moment — one the |
| + // 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) |
| + if counts.encoded < peer.last.encoded || counts.sent < peer.last.sent { |
| + // A new transceiver's counters, starting again from nothing. |
| + peer.last = counts |
| + peer.movedAt = now |
| + peer.deliveredAt = delivered |
| + } |
| + let moved = counts.encoded > peer.last.encoded && counts.sent > peer.last.sent |
| + let wasReported = peer.reported |
| + let reported = stalledBy.contains(id) |
| + if reported && !wasReported { stallReports += 1 } |
| + peer.reported = reported |
| + peer.last = counts |
| + if moved && !reported { |
| + if peer.step > 0 { |
| + ScreenLog.info("screen to \(Self.short(id)) moving again after step \(peer.step)") |
| + peer.step = 0 |
| + } |
| + peer.movedAt = now |
| + peer.deliveredAt = delivered |
| + } else if moved { |
| + peer.movedAt = now |
| + peer.deliveredAt = delivered |
| + } |
| + let starved = !moved && 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 { |
| + case 0: |
| + out.append(.keyframe(id)) |
| + keyframes += 1 |
| + case 1: |
| + out.append(.recreateEncoder(id)) |
| + encoderRecoveries += 1 |
| + default: |
| + out.append(.reAdd(id)) |
| + reAdds += 1 |
| + } |
| + peer.step += 1 |
| + peer.stepAt = now |
| + ScreenLog.info("screen to \(Self.short(id)) \(why): step \(peer.step)") |
| + if peer.step >= 3 { |
| + peer.step = 0 |
| + peer.restingUntil = now + Self.holdOff |
| + } |
| + } |
| + } else if !starved && !reported && peer.step > 0 && now - peer.stepAt >= Self.encoderStepEvery { |
| + peer.step = 0 |
| + } |
| + peers[id] = peer |
| + } |
| + status = describe() |
| + return out |
| + } |
| + |
| + /// Whether anyone's view of the screen is being recovered right now. |
| + var recovering: Bool { captureStalled || peers.values.contains { $0.step > 0 || $0.reported } } |
| + |
| + private func describe() -> String { |
| + if captureStalled { return "capture stalled, restarting (\(captureRestarts))" } |
| + if let (_, p) = peers.first(where: { $0.value.step > 0 }) { |
| + return ["", "keyframe asked for", "encoder recreated", "re-added"][min(p.step, 3)] |
| + } |
| + if peers.values.contains(where: \.reported) { return "a receiver reports a stall" } |
| + let total = captureRestarts + keyframes + encoderRecoveries + reAdds |
| + return total == 0 ? "watching" : "watching, \(total) recover\(total == 1 ? "y" : "ies") so far" |
| + } |
| + |
| + static func short(_ peer: String) -> String { String(peer.prefix(6)) } |
| +} |
| + |
| +/// The receiver's watchdog: whose screens have stopped moving here. |
| +/// |
| +/// A peer's screen is stalled when nothing new has been decoded from it |
| +/// for four seconds while it says it is sharing. Both apps and the browser |
| +/// repeat a still screen at least once a second, so four seconds without |
| +/// a frame is not a still screen. The list is reported as `screenStalled` |
| +/// (spec §15.21) and the sharer recovers; it clears as soon as frames |
| +/// move, and a report is never held for more than a minute — a screen |
| +/// the sharer cannot recover is not made into a report that never ends. |
| +struct ScreenReceiveWatch: Equatable { |
| + static let stallAfter = 4.0 |
| + static let giveUpAfter = 60.0 |
| + |
| + struct Seen: Equatable { |
| + var decoded: UInt64 |
| + var movedAt: Double |
| + } |
| + private(set) var seen: [String: Seen] = [:] |
| + |
| + init() {} |
| + |
| + /// `decoded` is frames decoded so far from each peer's screen, for the |
| + /// peers that have one arriving. Returns the stalled peers, sorted. |
| + mutating func observe(at now: Double, decoded: [String: UInt64]) -> [String] { |
| + for id in seen.keys where decoded[id] == nil { seen.removeValue(forKey: id) } |
| + var stalled: [String] = [] |
| + for (id, count) in decoded { |
| + guard var s = seen[id], count <= s.decoded else { |
| + seen[id] = Seen(decoded: count, movedAt: now) |
| + continue |
| + } |
| + s.decoded = count |
| + seen[id] = s |
| + let quiet = now - s.movedAt |
| + if quiet >= Self.stallAfter && quiet < Self.giveUpAfter { stalled.append(id) } |
| + } |
| + return stalled.sorted() |
| + } |
| +} |
| + |
| +/// The words for the sharer's quiet notice. |
| +public enum ScreenWords { |
| + /// "Vicky’s view of your screen stalled — recovering", or nothing when |
| + /// nobody's has. |
| + public static func recovering(names: [String]) -> String { |
| + switch names.count { |
| + case 0: return "" |
| + case 1: return "\(names[0])\u{2019}s view of your screen stalled \u{2014} recovering" |
| + case 2: return "\(names[0])\u{2019}s and \(names[1])\u{2019}s views of your screen stalled \u{2014} recovering" |
| + default: return "\(names.count) people\u{2019}s views of your screen stalled \u{2014} recovering" |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Wire.swift b/native/apple/Sources/CallsKit/Wire.swift| index 2b18c25..2c20d31 100644 |
| --- a/native/apple/Sources/CallsKit/Wire.swift |
| +++ b/native/apple/Sources/CallsKit/Wire.swift |
| @@ -21,14 +21,19 @@ public struct PeerMedia: Decodable, Equatable, Sendable { |
| public let audioBlocked: Bool? |
| /// The peers whose audio reaches it as silence. Nil (absent) is none. |
| public let noSoundFrom: [String]? |
| + /// The peers whose shared screen has stopped moving for it (§15.21). |
| + /// A sharer that finds itself here recovers its screen. Nil is none. |
| + public let screenStalled: [String]? |
| public init( |
| - muted: Bool, cameraOff: Bool, audioBlocked: Bool? = nil, noSoundFrom: [String]? = nil |
| + muted: Bool, cameraOff: Bool, audioBlocked: Bool? = nil, noSoundFrom: [String]? = nil, |
| + screenStalled: [String]? = nil |
| ) { |
| self.muted = muted |
| self.cameraOff = cameraOff |
| self.audioBlocked = audioBlocked |
| self.noSoundFrom = noSoundFrom |
| + self.screenStalled = screenStalled |
| } |
| } |
| public struct RoomState: Decodable { |
| @@ -82,6 +87,29 @@ struct ICEConfig: Decodable { |
| let renewAt: Int64 |
| let expiresAt: Int64 |
| } |
| +/// §5.3 for the apps: when a fresh `/ice` answer should restart ICE on a |
| +/// connection, rather than be adopted for whatever it gathers next. |
| +/// |
| +/// The core fetches the list on every roster (every stream resume, five |
| +/// minutes apart at most) as well as at `renewAt`, so a connection hears a |
| +/// fresh list at the latest five minutes after its own renewal falls due — |
| +/// well inside the half hour between `renewAt` and expiry. |
| +enum ICERenewal { |
| + /// - Parameters: |
| + /// - gathered: the list the connection was built with, or last restarted onto. |
| + /// - next: the list just fetched. |
| + /// - now: unix seconds. |
| + static func restart(gathered: ICEConfig?, next: ICEConfig, now: Int64) -> Bool { |
| + // The floor has nothing to renew onto. |
| + guard !next.iceServers.isEmpty else { return false } |
| + guard let gathered else { return true } |
| + // Built on the floor while the first fetch was out, and a relay since: |
| + // adopt it now, as the browser's adoptServers does. |
| + if gathered.iceServers.isEmpty { return true } |
| + return gathered.renewAt > 0 && now >= gathered.renewAt |
| + } |
| +} |
| + |
| func json(_ value: Any) -> String? { |
| guard JSONSerialization.isValidJSONObject(value), |
| let data = try? JSONSerialization.data(withJSONObject: value) |
diff --git a/native/apple/Tests/CallsKitTests/ScreenSendTests.swift b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift| index 4ebdda3..04f2cc2 100644 |
| --- a/native/apple/Tests/CallsKitTests/ScreenSendTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift |
| @@ -27,14 +27,41 @@ final class ScreenSendTests: XCTestCase { |
| XCTAssertEqual(plan.maxFramerate, 30) |
| XCTAssertEqual(plan.scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001, "1920 down to 1280") |
| XCTAssertTrue(plan.highPriority) |
| - // A portrait window is scaled by its long side, too. |
| - XCTAssertEqual( |
| - ScreenSendPolicy.screen(.motion, width: 1000, height: 1600).scaleResolutionDownBy ?? 0, 1.25, |
| - accuracy: 0.0001) |
| + // A portrait window is scaled by its long side, too — and then a |
| + // little further, to H.264 level 3.1's 3,600 blocks (see |
| + // realtimeMacroblocks): 800×1280 would be 4,000. |
| + let portrait = ScreenSendPolicy.screen(.motion, width: 1000, height: 1600).scaleResolutionDownBy ?? 0 |
| + XCTAssertGreaterThan(portrait, 1.25) |
| + XCTAssertLessThanOrEqual(blocks(1000, 1600, portrait), 3600) |
| // Something already small enough is left alone. |
| XCTAssertNil(ScreenSendPolicy.screen(.motion, width: 1280, height: 720).scaleResolutionDownBy) |
| } |
| + /// Paul's freeze of 2026-09-28: a 16:10 MacBook display in motion mode |
| + /// was 1280×830 at its cap, over level 3.1's 3,600 blocks, and the Mac's |
| + /// encoder refused every frame at that size. Motion now fits the level; |
| + /// a screencast source (every other mode) is not held to it. |
| + func testMotionFitsH264Level31() { |
| + for (w, h) in [(3024, 1964), (1920, 1246), (2560, 1600), (1920, 1200), (1470, 956), (1920, 1080)] { |
| + let scale = ScreenSendPolicy.screen(.motion, width: w, height: h).scaleResolutionDownBy ?? 1 |
| + XCTAssertLessThanOrEqual(blocks(w, h, scale), 3600, "\(w)×\(h) at 1/\(scale)") |
| + XCTAssertGreaterThan(blocks(w, h, scale), 3200, "\(w)×\(h) is not shrunk further than it has to be") |
| + } |
| + // 1080p at 1280×720 is exactly the level, and unchanged. |
| + XCTAssertEqual(ScreenSendPolicy.screen(.motion, width: 1920, height: 1080).scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001) |
| + // Balanced and detail are screencasts and keep their full size. |
| + XCTAssertNil(ScreenSendPolicy.screen(.balanced, width: 1920, height: 1246).scaleResolutionDownBy) |
| + XCTAssertNil(ScreenSendPolicy.screen(.detail, width: 1920, height: 1200).scaleResolutionDownBy) |
| + // A share that started in motion keeps a camera-like source whatever |
| + // the mode becomes, and stays within the level. |
| + let switched = ScreenSendPolicy.screen(.detail, width: 1920, height: 1246, screencast: false) |
| + XCTAssertLessThanOrEqual(blocks(1920, 1246, switched.scaleResolutionDownBy ?? 1), 3600) |
| + } |
| + |
| + private func blocks(_ w: Int, _ h: Int, _ scale: Double) -> Int { |
| + ((Int(Double(w) / scale) + 15) / 16) * ((Int(Double(h) / scale) + 15) / 16) |
| + } |
| + |
| func testBalancedIsTheDefaultAndKeepsTheFrameRateAtFullSize() { |
| XCTAssertEqual(ScreenShareMode.allCases.first, .balanced) |
| let plan = ScreenSendPolicy.screen(.balanced, width: 1920, height: 1080) |
diff --git a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift| new file mode 100644 |
| index 0000000..2f9a9cb |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift |
| @@ -0,0 +1,236 @@ |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// The screen watchdogs (ScreenWatchdog.swift): the sharer's, which puts |
| +/// its own screen right, and the receiver's, which says when someone |
| +/// else's has stopped. Written for Paul's report of 2026-09-28. |
| +final class ScreenWatchdogTests: XCTestCase { |
| + private func counts(_ beats: UInt64, _ delivered: UInt64) -> ScreenCaptureCounts { |
| + ScreenCaptureCounts(heartbeats: beats, delivered: delivered) |
| + } |
| + private func sending(_ n: UInt64) -> [String: ScreenSendCounts] { |
| + ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] |
| + } |
| + |
| + func testAHealthyShareAsksForNothing() { |
| + var dog = ScreenWatchdog() |
| + for t in 0..<30 { |
| + let n = UInt64(t * 30) |
| + XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) |
| + } |
| + XCTAssertEqual(dog.status, "watching") |
| + XCTAssertFalse(dog.recovering) |
| + } |
| + |
| + func testAStillScreenIsNotAStall() { |
| + // ScreenCaptureKit reports a still screen as idle, and the capture |
| + // repeats the last frame once a second: heartbeats keep coming and |
| + // frames trickle, encoded as they go. |
| + var dog = ScreenWatchdog() |
| + for t in 0..<30 { |
| + let actions = dog.observe( |
| + at: Double(t), capture: counts(UInt64(t * 30), UInt64(t)), sending: sending(UInt64(t)), stalledBy: []) |
| + XCTAssertEqual(actions, []) |
| + } |
| + } |
| + |
| + func testACaptureThatStopsReportingIsRestartedWithAGrowingWait() { |
| + var dog = ScreenWatchdog() |
| + var restarts: [Int] = [] |
| + for t in 0..<40 { |
| + let beats: UInt64 = t < 5 ? UInt64(t * 30) : 120 |
| + let actions = dog.observe(at: Double(t), capture: counts(beats, beats), sending: sending(beats), stalledBy: []) |
| + if actions.contains(.restartCapture) { restarts.append(t) } |
| + } |
| + // Stalled from t=4 on: first restart three seconds later, then 5, 10, 20 s apart. |
| + XCTAssertEqual(restarts, [7, 12, 22]) |
| + XCTAssertTrue(dog.captureStalled) |
| + XCTAssertEqual(dog.captureRestarts, 3) |
| + // And it comes back. |
| + XCTAssertEqual(dog.observe(at: 40, capture: counts(500, 500), sending: sending(500), stalledBy: []), []) |
| + XCTAssertFalse(dog.captureStalled) |
| + } |
| + |
| + func testACaptureThatCannotTellIdleFromStalledIsNotWatched() { |
| + // A ReplayKit broadcast sends nothing for a still screen. |
| + var dog = ScreenWatchdog() |
| + for t in 0..<20 { |
| + let actions = dog.observe( |
| + at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 7), sending: sending(7), stalledBy: []) |
| + XCTAssertEqual(actions, []) |
| + } |
| + } |
| + |
| + func testAnEncoderThatStopsGetsAKeyframeThenAFreshEncoderThenANewTransceiver() { |
| + var dog = ScreenWatchdog() |
| + var log: [(Int, ScreenRecovery)] = [] |
| + for t in 0..<45 { |
| + 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: []) { |
| + log.append((t, a)) |
| + } |
| + } |
| + XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky"), .keyframe("vicky")]) |
| + // Three 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]) |
| + XCTAssertEqual(dog.keyframes, 2) |
| + XCTAssertEqual(dog.encoderRecoveries, 1) |
| + XCTAssertEqual(dog.reAdds, 1) |
| + } |
| + |
| + func testAnEncoderThatRecoversAfterTheKeyframeIsLeftAlone() { |
| + var dog = ScreenWatchdog() |
| + var actions: [ScreenRecovery] = [] |
| + for t in 0..<30 { |
| + let delivered = UInt64(t * 30) |
| + let encoded: UInt64 = (5..<9).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 testNothingGoingInIsNotAnEncoderStall() { |
| + // No frames delivered (a broadcast of a still screen): nothing to encode. |
| + var dog = ScreenWatchdog() |
| + for t in 0..<20 { |
| + let actions = dog.observe( |
| + at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 50), sending: sending(50), stalledBy: []) |
| + XCTAssertEqual(actions, []) |
| + } |
| + } |
| + |
| + func testAReceiversReportRunsTheSameStepsFourSecondsApart() { |
| + var dog = ScreenWatchdog() |
| + var log: [(Int, ScreenRecovery)] = [] |
| + for t in 0..<20 { |
| + let n = UInt64(t * 30) |
| + let stalledBy: Set<String> = t >= 3 ? ["vicky"] : [] |
| + for a in dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) { |
| + log.append((t, a)) |
| + } |
| + } |
| + XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky")]) |
| + XCTAssertEqual(log.map(\.0), [3, 7, 11]) |
| + XCTAssertEqual(dog.stallReports, 1, "one report, however long it lasts") |
| + XCTAssertTrue(dog.recovering) |
| + } |
| + |
| + func testAReportThatClearsEndsTheRecovery() { |
| + var dog = ScreenWatchdog() |
| + var actions: [ScreenRecovery] = [] |
| + for t in 0..<20 { |
| + let n = UInt64(t * 30) |
| + let stalledBy: Set<String> = (3..<5).contains(t) ? ["vicky"] : [] |
| + actions += dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) |
| + } |
| + XCTAssertEqual(actions, [.keyframe("vicky")]) |
| + XCTAssertFalse(dog.recovering) |
| + XCTAssertTrue(dog.status.hasPrefix("watching, 1 recovery")) |
| + } |
| + |
| + func testAReAddedTransceiverStartingFromZeroIsNotAStall() { |
| + var dog = ScreenWatchdog() |
| + for t in 0..<10 { |
| + let n = UInt64(t * 30) |
| + XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) |
| + } |
| + // The connection's counters start again at zero on a new transceiver. |
| + for t in 10..<20 { |
| + let n = UInt64(t * 30) |
| + let fresh = UInt64((t - 10) * 30) |
| + XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(fresh), stalledBy: []), []) |
| + } |
| + } |
| + |
| + // MARK: - The receiver |
| + |
| + func testAScreenThatStopsMovingIsReportedAfterFourSecondsAndForAMinuteAtMost() { |
| + var watch = ScreenReceiveWatch() |
| + var reported: [Int] = [] |
| + for t in 0..<80 { |
| + let decoded: UInt64 = t < 10 ? UInt64(t * 10) : 90 |
| + if !watch.observe(at: Double(t), decoded: ["paul": decoded]).isEmpty { reported.append(t) } |
| + } |
| + XCTAssertEqual(reported.first, 13, "last moved at 9: stalled from 13") |
| + XCTAssertEqual(reported.last, 68, "and no longer than a minute after it last moved") |
| + } |
| + |
| + func testAMovingScreenIsNeverReported() { |
| + var watch = ScreenReceiveWatch() |
| + for t in 0..<30 { |
| + XCTAssertEqual(watch.observe(at: Double(t), decoded: ["paul": UInt64(t)]), []) |
| + } |
| + } |
| + |
| + func testAScreenThatGoesIsForgotten() { |
| + var watch = ScreenReceiveWatch() |
| + for t in 0..<10 { _ = watch.observe(at: Double(t), decoded: ["paul": 5]) } |
| + XCTAssertEqual(watch.observe(at: 10, decoded: ["paul": 5]), ["paul"]) |
| + XCTAssertEqual(watch.observe(at: 11, decoded: [:]), []) |
| + // Back again, a fresh start. |
| + XCTAssertEqual(watch.observe(at: 12, decoded: ["paul": 5]), []) |
| + } |
| + |
| + // MARK: - Words and wires |
| + |
| + func testTheSharersNotice() { |
| + XCTAssertEqual(ScreenWords.recovering(names: []), "") |
| + XCTAssertEqual(ScreenWords.recovering(names: ["Vicky"]), "Vicky\u{2019}s view of your screen stalled \u{2014} recovering") |
| + XCTAssertEqual( |
| + ScreenWords.recovering(names: ["Tony", "Vicky"]), |
| + "Tony\u{2019}s and Vicky\u{2019}s views of your screen stalled \u{2014} recovering") |
| + XCTAssertEqual(ScreenWords.recovering(names: ["A", "B", "C"]), "3 people\u{2019}s views of your screen stalled \u{2014} recovering") |
| + } |
| + |
| + func testTheStalledListIsReadFromTheRoster() throws { |
| + let json = #"{"muted":false,"cameraOff":false,"screenStalled":["0123456789abcdef0123456789abcdef"]}"# |
| + let media = try JSONDecoder().decode(PeerMedia.self, from: Data(json.utf8)) |
| + XCTAssertEqual(media.screenStalled, ["0123456789abcdef0123456789abcdef"]) |
| + let plain = try JSONDecoder().decode(PeerMedia.self, from: Data(#"{"muted":true,"cameraOff":false}"#.utf8)) |
| + XCTAssertNil(plain.screenStalled) |
| + } |
| + |
| + func testTheDetailLineSaysWhatTheEncoderAndTheWatchdogAreDoing() { |
| + var before = ScreenSendReport() |
| + before.timestamp = 10 |
| + before.framesEncoded = 100 |
| + before.framesSent = 100 |
| + var now = before |
| + now.timestamp = 12 |
| + now.framesEncoded = 160 |
| + now.framesSent = 158 |
| + now.keyFramesEncoded = 3 |
| + now.keyframeRequests = 2 |
| + XCTAssertEqual( |
| + now.detail(since: before, watchdog: "watching"), |
| + "Encoded 30 fps, sent 29 fps, keyframes 3 (2 asked for); watchdog: watching") |
| + } |
| + |
| + // MARK: - ICE renewal (§5.3) |
| + |
| + private func ice(_ renewAt: Int64, servers: Int = 1) -> ICEConfig { |
| + ICEConfig( |
| + iceServers: (0..<servers).map { _ in .init(urls: ["turn:relay"], username: "u", credential: "c") }, |
| + renewAt: renewAt, expiresAt: renewAt + 1800) |
| + } |
| + |
| + func testAFreshListRestartsOnlyWhenARenewalIsDue() { |
| + // Every resume brings a fresh list, five minutes apart; 0.4.1 |
| + // restarted ICE on every one of them. |
| + let built = ice(10_000) |
| + XCTAssertFalse(ICERenewal.restart(gathered: built, next: ice(10_300), now: 1_300)) |
| + XCTAssertFalse(ICERenewal.restart(gathered: built, next: ice(10_600), now: 9_999)) |
| + XCTAssertTrue(ICERenewal.restart(gathered: built, next: ice(19_000), now: 10_000)) |
| + XCTAssertTrue(ICERenewal.restart(gathered: built, next: ice(19_000), now: 10_290)) |
| + } |
| + |
| + func testTheFloorHasNothingToRenewAndARelayArrivingIsAdopted() { |
| + XCTAssertFalse(ICERenewal.restart(gathered: ice(0, servers: 0), next: ice(0, servers: 0), now: 5)) |
| + XCTAssertTrue(ICERenewal.restart(gathered: ice(0, servers: 0), next: ice(10_000), now: 5)) |
| + XCTAssertFalse(ICERenewal.restart(gathered: ice(10_000), next: ice(0, servers: 0), now: 20_000)) |
| + } |
| +} |
diff --git a/native/bridge/bridge.go b/native/bridge/bridge.go| index 1803359..0c1acb2 100644 |
| --- a/native/bridge/bridge.go |
| +++ b/native/bridge/bridge.go |
| @@ -127,6 +127,19 @@ func (c *Client) SetNoSoundFrom(peersJSON string) error { |
| return c.room.SetNoSoundFrom(ids) |
| } |
| +// SetScreenStalled is spec §15.21's receiving half: the peers whose shared |
| +// screen has stopped moving here, as a JSON array of peer ids. Reported |
| +// with the rest of this app's media state; "[]" or "" clears it. |
| +func (c *Client) SetScreenStalled(peersJSON string) error { |
| + var ids []protocol.PeerID |
| + if peersJSON != "" { |
| + if err := json.Unmarshal([]byte(peersJSON), &ids); err != nil { |
| + return errors.New("invalid peer list") |
| + } |
| + } |
| + return c.room.SetScreenStalled(ids) |
| +} |
| + |
| // RegisterDevice hands the server an Apple device token so a knock can |
| // reach this device when the app is closed. Best effort: the caller |
| // ignores the error beyond logging, because a device that did not |
diff --git a/protocol/messages.go b/protocol/messages.go| index 231a737..df779e6 100644 |
| --- a/protocol/messages.go |
| +++ b/protocol/messages.go |
| @@ -133,6 +133,12 @@ type MediaState struct { |
| // connected, reporting themselves unmuted, and flat for several |
| // seconds. At most MaxNoSoundFrom, each a peer id, none repeated. |
| NoSoundFrom []PeerID `json:"noSoundFrom,omitempty"` |
| + // ScreenStalled (2026-09-28): the peers whose shared screen has stopped |
| + // moving here while they say they are sharing — no frame decoded for |
| + // several seconds. The sharer reads its own id in it as a request to |
| + // recover (a keyframe first). Bad news only, omitted when there is |
| + // none, at most MaxScreenStalled, each a peer id, none repeated. |
| + ScreenStalled []PeerID `json:"screenStalled,omitempty"` |
| } |
| // MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most |
| @@ -140,16 +146,25 @@ type MediaState struct { |
| // could be. |
| const MaxNoSoundFrom = 15 |
| -// Equal compares two reports field by field; NoSoundFrom is compared in |
| -// order, which is fine because the hub only asks whether a report |
| +// MaxScreenStalled bounds MediaState.ScreenStalled for the same reason. |
| +const MaxScreenStalled = 15 |
| + |
| +// Equal compares two reports field by field; the peer lists are compared |
| +// in order, which is fine because the hub only asks whether a report |
| // changed, and a reordered list is a report worth passing on. |
| func (m MediaState) Equal(o MediaState) bool { |
| - if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked || |
| - len(m.NoSoundFrom) != len(o.NoSoundFrom) { |
| + if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked { |
| + return false |
| + } |
| + return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled) |
| +} |
| + |
| +func samePeers(a, b []PeerID) bool { |
| + if len(a) != len(b) { |
| return false |
| } |
| - for i := range m.NoSoundFrom { |
| - if m.NoSoundFrom[i] != o.NoSoundFrom[i] { |
| + for i := range a { |
| + if a[i] != b[i] { |
| return false |
| } |
| } |
diff --git a/test/browser/native-screen.mjs b/test/browser/native-screen.mjs| new file mode 100644 |
| index 0000000..637b9d0 |
| --- /dev/null |
| +++ b/test/browser/native-screen.mjs |
| @@ -0,0 +1,244 @@ |
| +// native-screen.mjs — the app shares its (synthetic) screen to a browser |
| +// for a long stretch while the call is disturbed underneath it, and the |
| +// browser watches the received screen for freezes. |
| +// |
| +// Written for Paul's report of 2026-09-28 (Mac 0.4.1): "after a while, my |
| +// screen froze on her end and she was no longer able to see my screen. I |
| +// had to stop and re-share again." Every scenario here asserts the same |
| +// thing: the receiver's screen keeps decoding, or comes back within a |
| +// bound, without anyone sharing again. |
| +// |
| +// Scenarios, one per run (argv[4]): |
| +// renewals the relay on, the stream renewed every ten seconds (which |
| +// is when the app used to restart ICE on every connection) and |
| +// relay credentials that need renewing every twenty seconds, |
| +// so real renewals — ICE restarts — happen mid-share |
| +// resume the streams renewed every ten seconds with no relay, so |
| +// every resume is a resume and nothing else |
| +// capture the synthetic screen stops producing frames (a capture that |
| +// has stalled): the app's watchdog must restart it |
| +// encoder the app's encoder stops putting frames out (the screen |
| +// sender deactivated under it): the watchdog must recover it |
| +// loss the browser stops painting and asks for a keyframe |
| +// (screenStalled): the app must answer with one |
| +// |
| +// Run by TestNativeMacScreenRecovery (make native-browser). |
| +import { chromium } from 'playwright'; |
| +import { spawn } from 'node:child_process'; |
| +import { createInterface } from 'node:readline'; |
| +import assert from 'node:assert/strict'; |
| + |
| +const [origin, room, binary, scenario] = process.argv.slice(2); |
| +const SOAK = Number(process.env.SCREEN_SOAK_SECONDS || 60); |
| +// The longest the browser's screen may go without a new frame and still |
| +// count as healthy. A freeze is anything longer. |
| +const FREEZE_MS = Number(process.env.SCREEN_FREEZE_MS || 3000); |
| +// How long recovery may take, from the fault to frames again. |
| +const RECOVER_S = Number(process.env.SCREEN_RECOVER_SECONDS || 15); |
| +const asserting = process.env.SCREEN_ASSERT !== '0'; |
| + |
| +const browser = await chromium.launch({ headless: true, args: [ |
| + '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', |
| + '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns', |
| + '--mute-audio', |
| +] }); |
| +const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| +await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| +const page = await context.newPage(); |
| +await page.addInitScript(() => { |
| + const Native = window.RTCPeerConnection; |
| + window.__nativeInteropPCs = new Set(); |
| + window.RTCPeerConnection = class extends Native { |
| + constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); } |
| + }; |
| +}); |
| +let native; |
| +let failed = false; |
| +try { |
| + await page.goto(`${origin}/m/${room}`); |
| + await page.waitForFunction(() => !!window.meet); |
| + native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| + native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| + let sample = null; |
| + let exited = false; |
| + const replies = []; |
| + native.on('exit', () => { exited = true; }); |
| + createInterface({ input: native.stdout }).on('line', (line) => { |
| + try { |
| + const value = JSON.parse(line); |
| + if (value.stats) sample = value; |
| + else replies.push(value); |
| + } catch {} |
| + }); |
| + native.stderr.resume(); |
| + const wait = async (predicate, label, seconds = 35) => { |
| + const until = Date.now() + seconds * 1000; |
| + while (Date.now() < until) { |
| + if (exited) throw new Error('native executable exited'); |
| + if (await predicate()) return; |
| + await new Promise((r) => setTimeout(r, 250)); |
| + } |
| + throw new Error(label + ': ' + JSON.stringify(sample)); |
| + }; |
| + await wait(() => sample?.stats.framesDecoded > 15, 'native receives the browser'); |
| + const nativePeer = sample.peer; |
| + const key = nativePeer + '#share'; |
| + // What the browser is receiving on the app's screen, from the |
| + // connection's own statistics: the screen is the inbound video that is |
| + // not the camera's mid. |
| + const received = () => page.evaluate(async (peer) => { |
| + const s = window.meet.state(); |
| + const frames = Object.fromEntries(s.frames); |
| + const out = { frames: frames[peer + '#share'] || 0, camera: frames[peer] || 0, shares: s.shares, ufrags: [], screen: null }; |
| + for (const pc of window.__nativeInteropPCs) { |
| + if (pc.connectionState === 'closed') continue; |
| + const remote = pc.remoteDescription?.sdp || ''; |
| + const m = remote.match(/a=ice-ufrag:(\S+)/); |
| + if (m) out.ufrags.push(m[1]); |
| + const tx = pc.getTransceivers().filter((t) => t.receiver.track?.kind === 'video' && t.direction === 'recvonly'); |
| + const mids = new Set(tx.map((t) => t.mid)); |
| + for (const r of (await pc.getStats()).values()) { |
| + if (r.type === 'inbound-rtp' && r.kind === 'video' && mids.has(r.mid)) { |
| + out.screen = { decoded: r.framesDecoded, received: r.framesReceived, pli: r.pliCount, fir: r.firCount, |
| + freezes: r.freezeCount, keyframes: r.keyFramesDecoded, lost: r.packetsLost, mid: r.mid }; |
| + } |
| + } |
| + } |
| + return out; |
| + }, nativePeer); |
| + |
| + // The mode first: it is remembered across runs of the interop binary, |
| + // and the screen's source is made for the mode the share starts in. |
| + native.stdin.write(`screen-mode ${process.env.SCREEN_MODE || 'balanced'}\n`); |
| + if (scenario === 'display') native.stdin.write(`screen-display ${process.env.SCREEN_DEPTH || 6}\n`); |
| + else native.stdin.write('screen-on\n'); |
| + await wait(() => sample?.sharingScreen === true, 'native says it is sharing'); |
| + await wait(async () => (await received()).frames > 15, 'the browser decodes the app’s screen'); |
| + console.log('PASS the browser decodes the app’s screen'); |
| + |
| + // The watch: once a second, how long since the screen's frame count |
| + // last moved. Every freeze longer than FREEZE_MS is recorded with what |
| + // was going on around it. |
| + const freezes = []; |
| + let last = await received(); |
| + let lastMoved = Date.now(); |
| + let restarts = 0; |
| + const seenUfrags = new Set(last.ufrags); |
| + const watchFor = async (seconds, label) => { |
| + const until = Date.now() + seconds * 1000; |
| + let frozen = null; |
| + while (Date.now() < until) { |
| + if (exited) throw new Error('native executable exited'); |
| + await new Promise((r) => setTimeout(r, 1000)); |
| + const now = await received(); |
| + for (const u of now.ufrags) if (!seenUfrags.has(u)) { seenUfrags.add(u); restarts += 1; } |
| + if (now.frames > last.frames) { |
| + if (frozen) { |
| + frozen.ms = Date.now() - frozen.since; |
| + freezes.push(frozen); |
| + console.log(`FREEZE ${label}: ${frozen.ms} ms, then frames again (${JSON.stringify(now.screen)})`); |
| + frozen = null; |
| + } |
| + lastMoved = Date.now(); |
| + } else if (!frozen && Date.now() - lastMoved > FREEZE_MS) { |
| + frozen = { since: lastMoved, label, at: now.screen, native: sample?.stats }; |
| + console.log(`FROZEN ${label}: no screen frame for ${Date.now() - lastMoved} ms ${JSON.stringify(now.screen)} native=${JSON.stringify(pick(sample?.stats))}`); |
| + } |
| + last = now; |
| + } |
| + if (frozen) { |
| + frozen.ms = Date.now() - frozen.since; |
| + frozen.open = true; |
| + freezes.push(frozen); |
| + } |
| + }; |
| + const pick = (s) => s && Object.fromEntries(Object.entries(s).filter(([k]) => /^screen|Watchdog|peerConnections/.test(k))); |
| + const recovered = async (label) => { |
| + const base = (await received()).frames; |
| + await wait(async () => (await received()).frames > base + 10, label, RECOVER_S); |
| + }; |
| + |
| + if (scenario === 'display') { |
| + if (process.env.SCREEN_UPLINK) native.stdin.write(`uplink ${process.env.SCREEN_UPLINK}\n`); |
| + if (process.env.SCREEN_MODE_LATER) native.stdin.write(`screen-mode ${process.env.SCREEN_MODE_LATER}\n`); |
| + let prev = null; |
| + const until = Date.now() + SOAK * 1000; |
| + const tick = setInterval(() => { |
| + const s = sample?.stats || {}; |
| + const now = { beats: s.screenCaptureHeartbeats, delivered: s.screenCaptureDelivered, encoded: s.screenFramesEncoded, sent: s.screenFramesSent }; |
| + if (prev) console.log(`display: +${now.beats - prev.beats} reports, +${now.delivered - prev.delivered} delivered, +${now.encoded - prev.encoded} encoded, +${now.sent - prev.sent} sent, ${s.screenSendWidth}x${s.screenSendHeight}, limit ${s.screenLimitation} target ${s.screenTargetBitrate} kf ${s.screenKeyframes} enc ${s.screenEncodeMsTenths / 10} ms, watchdog r${s.screenWatchdogCaptureRestarts} k${s.screenWatchdogKeyframes} e${s.screenWatchdogEncoderRecoveries} a${s.screenWatchdogReAdds}`); |
| + prev = now; |
| + }, Number(process.env.SCREEN_TICK_MS || 5000)); |
| + await watchFor(SOAK, 'display'); |
| + clearInterval(tick); |
| + } else if (scenario === 'renewals' || scenario === 'resume') { |
| + await watchFor(SOAK, scenario); |
| + console.log(`ICE restarts seen by the browser: ${restarts}`); |
| + const ours = sample?.stats.iceRestarts || 0; |
| + console.log(`ICE restarts the app started: ${ours}`); |
| + if (asserting && scenario === 'renewals') { |
| + // Credentials renewed every ~17 s: restarts mid-share, from both |
| + // sides, and the app's own nowhere near one per stream renewal |
| + // (every 10 s), which is what 0.4.1 did. |
| + assert.ok(restarts >= 2, 'relay credentials were renewed by ICE restarts mid-share: ' + restarts); |
| + assert.ok(ours >= 1 && ours <= Math.ceil(SOAK / 16) + 1, 'the app restarts ICE only when a renewal is due: ' + ours); |
| + } |
| + if (asserting && scenario === 'resume') assert.equal(restarts, 0, 'a resume alone restarts nothing'); |
| + } else if (scenario === 'capture') { |
| + await watchFor(5, 'before capture stall'); |
| + native.stdin.write('screen-stall\n'); |
| + await wait(() => replies.some((r) => r.screenStalled === true), 'the synthetic screen stops'); |
| + await recovered('the app restarts a stalled capture and the browser decodes again'); |
| + await wait(() => (sample?.stats.screenWatchdogCaptureRestarts || 0) > 0, 'the watchdog says it restarted the capture', 5); |
| + console.log('PASS a stalled capture was restarted without a re-share'); |
| + } else if (scenario === 'encoder') { |
| + await watchFor(5, 'before encoder stall'); |
| + native.stdin.write('screen-encoder-stall\n'); |
| + await wait(() => replies.some((r) => r.encoderStalled === true), 'the encoder stops'); |
| + await recovered('the app recovers a stalled encoder and the browser decodes again'); |
| + await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) + (sample?.stats.screenWatchdogEncoderRecoveries || 0) > 0, |
| + 'the watchdog says it recovered the encoder', 5); |
| + console.log('PASS a stalled encoder was recovered without a re-share'); |
| + } else if (scenario === 'loss') { |
| + await watchFor(5, 'before loss'); |
| + // The browser's watch treats the app's screen as frozen, as it would |
| + // a picture stuck after lost packets, and reports it (screenStalled); |
| + // the app must hear it and answer with a keyframe, which the |
| + // browser's decoder counts. |
| + const before = sample?.stats.screenWatchdogKeyframes || 0; |
| + const keyframes = (await received()).screen?.keyframes || 0; |
| + await page.evaluate(() => window.meet.stallScreenForTest(8000)); |
| + await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 1, |
| + 'the browser reports the app\u2019s screen stalled', 10); |
| + await wait(() => (sample?.stats.screenStallReports || 0) > 0, 'the app hears the browser\u2019s stall report', 10); |
| + await wait(() => (sample?.stats.screenWatchdogKeyframes || 0) > before, 'the app forces a keyframe for it', 10); |
| + await wait(async () => ((await received()).screen?.keyframes || 0) > keyframes, 'the browser decodes the keyframe', 10); |
| + await wait(async () => (await page.evaluate(() => window.meet.state().screenStalled)).length === 0, |
| + 'the browser clears its report once frames move', 20); |
| + await recovered('the browser decodes again'); |
| + console.log('PASS a receiver\u2019s stall report was answered with a keyframe and cleared'); |
| + } else { |
| + throw new Error('unknown scenario ' + scenario); |
| + } |
| + |
| + // Whatever the scenario, the screen has to be decoding at the end. |
| + await recovered('the app’s screen is still decoding at the end'); |
| + const open = freezes.filter((f) => f.open); |
| + console.log(`SUMMARY ${scenario}: ${freezes.length} freezes, longest ${Math.max(0, ...freezes.map((f) => f.ms))} ms, restarts ${restarts}`); |
| + if (asserting && (scenario === 'renewals' || scenario === 'resume')) { |
| + assert.equal(open.length, 0, 'the screen froze and never came back'); |
| + assert.equal(freezes.length, 0, 'the screen froze during ' + scenario + ': ' + JSON.stringify(freezes.map((f) => f.ms))); |
| + } |
| + console.log(`PASS ${scenario}: the app’s screen kept arriving`); |
| +} catch (err) { |
| + failed = true; |
| + console.log('FAIL', err.message); |
| +} finally { |
| + if (native) { |
| + native.stdin.write('leave\n'); |
| + await new Promise((r) => setTimeout(r, 1000)); |
| + native.kill('SIGTERM'); |
| + } |
| + await browser.close(); |
| + if (failed) process.exit(1); |
| +} |
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs| index ed51af4..20dfcb8 100644 |
| --- a/test/browser/room-mute.test.mjs |
| +++ b/test/browser/room-mute.test.mjs |
| @@ -170,6 +170,7 @@ function reporter({ admitted = true, local = { muted: false, cameraOff: false }, |
| '\nlet mediaSent = "";' + |
| '\nlet mediaExtras = true;' + |
| '\nlet audioBlocked = false;' + |
| + '\nlet screenStalled = [];' + |
| '\nconst noSoundList = () => env.noSound;' + |
| '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent, extras: () => mediaExtras,' + |
| '\n block: (on) => { audioBlocked = on; } };', |
diff --git a/test/browser/room-screen.test.mjs b/test/browser/room-screen.test.mjs| new file mode 100644 |
| index 0000000..652228d |
| --- /dev/null |
| +++ b/test/browser/room-screen.test.mjs |
| @@ -0,0 +1,97 @@ |
| +// room-screen.test.mjs — room.js's screen watchdogs (spec §15.21, |
| +// 2026-09-28), gated without a browser. |
| +// |
| +// node --test test/browser/room-screen.test.mjs |
| +// |
| +// Lifted out of the shipped room.js by name, as room-hearing.test.mjs |
| +// does, with the constants they read. |
| +// |
| +// judgeScreen the receiver's verdict on one screen: stalled after |
| +// four seconds without a new frame, for a minute at most |
| +// recoveringWords the sharer's quiet notice |
| +// mediaFrom, mediaKey, mediaPayload the report's screenStalled list |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| +const src = readFileSync(roomJS, 'utf8'); |
| + |
| +function lift(source, name) { |
| + const start = source.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 = source.indexOf('{', start); i < source.length; i++) { |
| + if (source[i] === '{') depth += 1; |
| + else if (source[i] === '}') { |
| + depth -= 1; |
| + if (depth === 0) return source.slice(start, i + 1); |
| + } |
| + } |
| + assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| +} |
| + |
| +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 = ['SCREEN_STALL_AFTER', 'SCREEN_STALL_GIVE_UP'].map(liftConst).join('\n'); |
| +const build = (name) => |
| + // eslint-disable-next-line no-new-func |
| + new Function(CONSTS + '\n' + lift(src, name) + '; return ' + name + ';')(); |
| + |
| +const judgeScreen = build('judgeScreen'); |
| +const recoveringWords = build('recoveringWords'); |
| +const mediaFrom = build('mediaFrom'); |
| +const mediaPayload = build('mediaPayload'); |
| +// eslint-disable-next-line no-new-func |
| +const mediaKey = new Function(lift(src, 'mediaKey') + '; return mediaKey;')(); |
| + |
| +const ID = '0123456789abcdef0123456789abcdef'; |
| + |
| +test('a screen is stalled four seconds after its last frame, and for a minute at most', () => { |
| + const seen = { decoded: 0, movedAt: 0 }; |
| + const stalledAt = []; |
| + for (let t = 0; t <= 80; t++) { |
| + const decoded = t < 10 ? t * 10 : 90; |
| + if (judgeScreen(seen, decoded, t * 1000, false)) stalledAt.push(t); |
| + } |
| + assert.equal(stalledAt[0], 13); |
| + assert.equal(stalledAt[stalledAt.length - 1], 68); |
| +}); |
| + |
| +test('a moving screen is never stalled, and a frozen test window is', () => { |
| + const seen = { decoded: 0, movedAt: 0 }; |
| + for (let t = 0; t < 20; t++) assert.equal(judgeScreen(seen, t, t * 1000, false), false); |
| + const frozen = []; |
| + for (let t = 20; t < 30; t++) if (judgeScreen(seen, t, t * 1000, true)) frozen.push(t); |
| + assert.equal(frozen[0], 23, 'the gate\'s frozen window counts as no new frames'); |
| + assert.equal(judgeScreen(seen, 40, 30000, false), false, 'and frames moving again clears it'); |
| +}); |
| + |
| +test('the sharer\'s notice names whose view stalled', () => { |
| + assert.equal(recoveringWords([]), ''); |
| + assert.equal(recoveringWords(['Vicky']), 'Vicky’s view of your screen stalled — recovering'); |
| + assert.equal(recoveringWords(['Tony', 'Vicky']), 'Tony’s and Vicky’s views of your screen stalled — recovering'); |
| + assert.equal(recoveringWords(['A', 'B', 'C']), '3 people’s views of your screen stalled — recovering'); |
| +}); |
| + |
| +test('screenStalled travels only with bad news, and never to a server that refused it', () => { |
| + const mine = { muted: false, cameraOff: false }; |
| + assert.deepEqual(mediaPayload(mine, false, null, false, []), { muted: false, cameraOff: false }); |
| + assert.deepEqual(mediaPayload(mine, false, null, false, [ID]), { muted: false, cameraOff: false, screenStalled: [ID] }); |
| + assert.deepEqual(mediaPayload(mine, false, null, true, [ID]), { muted: false, cameraOff: false }); |
| + // The four-argument calls from before still mean what they meant. |
| + assert.deepEqual(mediaPayload(mine, true, [ID], false), { muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: [ID] }); |
| +}); |
| + |
| +test('a report\'s screenStalled is read forgivingly and changes the key', () => { |
| + const m = mediaFrom({ muted: false, cameraOff: false, screenStalled: [ID, 'nope', ID, 7] }); |
| + assert.deepEqual(m.screenStalled, [ID]); |
| + assert.equal(mediaFrom({ muted: false, cameraOff: false, screenStalled: [] }).screenStalled, undefined); |
| + assert.notEqual(mediaKey(m), mediaKey({ muted: false, cameraOff: false })); |
| + assert.notEqual(mediaKey({ muted: false, cameraOff: false, noSoundFrom: [ID] }), mediaKey(m), |
| + 'the same peer silent is not the same report as the same peer stalled'); |
| +}); |