Merge branch 'fix/screen-share-freeze' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
36 files changed,
+2419
−95
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+264 −0native/README.md+37 −6native/apple/Sources/CallsInterop/Interop.swift+76 −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 −0native/ios/project.yml+1 −1protocol/messages.go+21 −6test/browser/native-screen.mjs+244 −0test/browser/room-mute.test.mjs+1 −0test/browser/room-screen.test.mjs+97 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore 6.855s ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 2.369s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub 1.887s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 9.627s ? amadan.net/oficina/calls/protocol [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 14.589s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub 8.830s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest 31.128s
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..e4cadaf 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,266 @@ 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 from %.3f, then frames again (b's camera decoded %d frames meanwhile)", label, gap.Round(time.Millisecond), |
| + float64(moved.UnixMilli())/1000, 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")) |
| + } |
| + // Bounded, not zero: between two apps an ICE restart now and |
| + // then (about one in ten here) stops the screen — and only the |
| + // screen, the camera keeps moving — for three or four seconds, |
| + // which was not traced further on 2026-09-28. 0.4.x restarted |
| + // ICE on every stream resume, every five minutes; now it is |
| + // every renewal, two and a half hours apart, and the watchdogs |
| + // are behind it. A freeze that outlasts them fails. |
| + if longest > 6*time.Second { |
| + 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..fe9013d 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,28 @@ 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 base = ProcessInfo.processInfo.environment["CALLS_WEBRTC_LOG"] { |
| + let path = "\(base).\(getpid())" |
| + FileManager.default.createFile(atPath: path, contents: nil) |
| + let handle = FileHandle(forWritingAtPath: path) |
| + let logger = LKRTCCallbackLogger() |
| + logger.severity = .info |
| + let everything = ProcessInfo.processInfo.environment["CALLS_WEBRTC_LOG_ALL"] == "1" |
| + logger.start { message in |
| + let lower = message.lowercased() |
| + guard everything || ["h264", "encod", "videotoolbox", "vtcompression", "resolution", "adapt", "quality"].contains(where: lower.contains) |
| + else { return } |
| + let stamp = String(format: "%.3f ", Date().timeIntervalSince1970) |
| + handle?.write(Data((stamp + 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 +182,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 +272,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/native/ios/project.yml b/native/ios/project.yml| index c1354ca..3382137 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.4.1' |
| + MARKETING_VERSION: '0.4.2' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
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'); |
| +}); |