From 2f7e9d295cd93c8fe2a302d4bf3698ae556b172c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:02:41 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m1/room-screen-joined.md Quest-Claim: 73a06ce36e0337df From e6fd9daaa32f4165ecc9798b5a87ca74e5c6170b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 10 Nov 2026 17:13:18 -0800 Subject: [PATCH 2/3] fix(room): a screen share publishes only while joined Co-Authored-By: Claude Opus 5.5 --- js/room/src/local.test.ts | 8 +++++--- js/room/src/local.ts | 18 +++++++++++++----- js/room/verify/README.md | 13 ++++++------- js/room/verify/Room.tla | 5 ++--- quest/m1/README.md | 1 - quest/m1/room-screen-joined.md | 26 -------------------------- 6 files changed, 26 insertions(+), 45 deletions(-) delete mode 100644 quest/m1/room-screen-joined.md diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts index b8779209e49..7957a333af0 100644 --- a/js/room/src/local.test.ts +++ b/js/room/src/local.test.ts @@ -56,21 +56,23 @@ function setup() { // Replays LeavingStopsPublishing in `verify/Room.tla`: the screen broadcast is enabled by a live // share alone, so a participant who never joined, or who left, still publishes their screen. -test("residual: a screen share publishes while not joined and after leaving", async () => { +test("a screen share publishes only while joined", async () => { const { local, screen, broadcasts } = setup(); try { local.screenEnabled.set(true); screen.out.state.set({ kind: "live", media: { video: {} } }); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); - expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(broadcasts.screen.enabled.peek()).toBe(false); local.enabled.set(true); await flush(); + expect(broadcasts.screen.enabled.peek()).toBe(true); + local.enabled.set(false); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); - expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(broadcasts.screen.enabled.peek()).toBe(false); } finally { local.close(); } diff --git a/js/room/src/local.ts b/js/room/src/local.ts index 8b773331396..845d0f5aa1f 100644 --- a/js/room/src/local.ts +++ b/js/room/src/local.ts @@ -33,7 +33,8 @@ export interface LocalProps { * * Enable {@link enabled} to join (announce `{identity}/camera.hang`). Camera and * microphone capture are separate knobs; the screenshare broadcast is - * announced only while a share is live. + * announced only while joined and a share is live, so nothing is published + * before joining or after leaving. */ export class Local { /** Participant identity. */ @@ -96,6 +97,9 @@ export class Local { #screenAudioSource = new Signal(undefined); #screenLive = new Signal(false); + // The screen broadcast and its encoders run only while joined and sharing. + #screenPublished = new Signal(false); + #signals = new Effect(); #control(value: GetterInit | undefined): Signal { @@ -189,9 +193,13 @@ export class Local { }); this.#signals.cleanup(() => this.camera.close()); + this.#signals.run((effect) => { + this.#screenPublished.set(effect.get(this.enabled) && effect.get(this.#screenLive)); + }); + this.screen = new Publish.Broadcast({ origin, - enabled: this.#screenLive, + enabled: this.#screenPublished, name: screenName, display: this.screenCapture.out.display, }); @@ -236,7 +244,7 @@ export class Local { const screenHd = new Publish.Video.Encoder("video/hd", { broadcast: this.screen, capture: this.screenCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, config: { maxPixels: 1920 * 1080 }, }); @@ -245,7 +253,7 @@ export class Local { const screenSd = new Publish.Video.Encoder("video/sd", { broadcast: this.screen, capture: this.screenCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, config: { maxPixels: 960 * 540 }, }); @@ -260,7 +268,7 @@ export class Local { const screenAudio = new Publish.Audio.Encoder("audio", { broadcast: this.screen, capture: screenAudioCapture, - enabled: this.#screenLive, + enabled: this.#screenPublished, bandwidth, }); this.#signals.cleanup(() => screenAudio.close()); diff --git a/js/room/verify/README.md b/js/room/verify/README.md index 1fce4e9ef3e..bb29efa4751 100644 --- a/js/room/verify/README.md +++ b/js/room/verify/README.md @@ -2,13 +2,13 @@ `Room.tla` is a TLA+ model of you and Bob in a room, written to be drawn as a statechart with `/statechart`. Your side is `src/local.ts`: joining announces your camera broadcast, and a live -screen share announces your screen broadcast. Your view of Bob is `src/room.ts`: each of his +screen share announces your screen broadcast while joined. Your view of Bob is `src/room.ts`: each of his announcements adds or removes one of his broadcasts, and your connection dropping removes him until the reconnect announces him again. -| Rule | Holds when | Today | -| --- | --- | --- | -| `LeavingStopsPublishing` | Someone who has not joined the room, or has left it, publishes nothing to it. | Breaks: the screen broadcast is enabled by a live share alone, so a share started before joining, or kept after leaving, stays published, and the room shows its owner. `residual: a screen share publishes while not joined and after leaving` in `src/local.test.ts` replays it. | +| Rule | Holds when | +| --- | --- | +| `LeavingStopsPublishing` | Someone who has not joined the room, or has left it, publishes nothing to it. | The chart also shows every participant leaving your screen while your connection is down and coming back once it reconnects. @@ -19,12 +19,11 @@ coming back once it reconnects. just js tla room/verify/Room.tla ``` -TLC exits non-zero while the rule above breaks. To draw the chart, run `/statechart` with +To draw the chart, run `/statechart` with `Room.tla`, `Room.cfg`, and `Room.view.ts`. `Room.cfg` plays only Bob's camera; add `"screen"` to `Kinds` to play his share too. ## Left out Chat, typing, and user metadata, more than one remote participant, a participant restarting under -the same identity, identity and prefix changes, and anything an app does on top of `Local`, such -as turning sharing off when you leave. +the same identity, identity and prefix changes, and anything an app does on top of `Local`. diff --git a/js/room/verify/Room.tla b/js/room/verify/Room.tla index 141c9b54cc2..3ef784e1179 100644 --- a/js/room/verify/Room.tla +++ b/js/room/verify/Room.tla @@ -2,8 +2,7 @@ \* You and another participant, Bob, in a room, as you see it. \* \* Your side is js/room/src/local.ts: `enabled` joins by announcing `{you}/camera.hang`, and the -\* screen broadcast `{you}/screen.hang` is announced while a share is live (`#screenLive`), -\* whether or not you joined. Your view of Bob is js/room/src/room.ts: each announcement under +\* screen broadcast `{you}/screen.hang` is announced while joined and a share is live. Your view of Bob is js/room/src/room.ts: each announcement under \* the room adds or removes one of Bob's broadcasts, and Bob is on your screen while one is. \* Announcements reach you one at a time. When your connection drops, the entries it fed retract \* at once (reload.ts), and the reconnect announces them again. @@ -24,7 +23,7 @@ Init == /\ seen = {} \* What you publish into the room. -Yours == (IF joined THEN {"camera"} ELSE {}) \cup (IF sharing THEN {"screen"} ELSE {}) +Yours == (IF joined THEN {"camera"} ELSE {}) \cup (IF joined /\ sharing THEN {"screen"} ELSE {}) \* You. Join == ~joined /\ joined' = TRUE /\ UNCHANGED <> diff --git a/quest/m1/README.md b/quest/m1/README.md index b64ff021fc6..7a5ed1b513c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -43,7 +43,6 @@ moves what remains of them once their claims merge. - [Track drop runs are forgotten below the floor](/quest/m1/drop-runs-prune.md) - a track's dropped-group record stays proportional to its window in Rust and JS - [A resumed QUIC client gives up on its own idle timer](/quest/m1/quic-resume-idle.md) - a client stopped past its idle timeout closes within about one timeout of resuming, without waiting on a stateless reset - [A reused moq-transport alias delivers a group that beats its SUBSCRIBE_OK](/quest/m1/ietf-alias-reuse.md) - a new SUBSCRIBE clears its track's alias tombstone, so moqxr's alias-0 catalog refetch no longer hangs -- [A screen share publishes only while joined](/quest/m1/room-screen-joined.md) - `@moq/room` publishes no screen share before joining or after leaving - [The TLA+ models run nightly and reviews resync them](/quest/m1/model-checks-nightly.md) - every model config runs in Nightly, open findings must still fail, and review reruns a model whose covered code changed - [The route model covers a mesh with clients and an announce budget](/quest/m1/route-mesh-model.md) - every ordering on 4-5 relay meshes with two origins and clients, with an announce budget and convergence - [Seeded lite-07 meshes hold the route rules and an announce budget](/quest/m1/route-mesh-sweep.md) - the route rules and message budget checked on real sessions over random meshes diff --git a/quest/m1/room-screen-joined.md b/quest/m1/room-screen-joined.md deleted file mode 100644 index 330425e8d3c..00000000000 --- a/quest/m1/room-screen-joined.md +++ /dev/null @@ -1,26 +0,0 @@ -# [S] A screen share publishes only while joined - -## Goal - -`@moq/room`'s `Local` publishes nothing, screen share included, while the -user has not joined a room or after they leave. Nobody sees a screen the user -did not share into the room. - -## Plan - -Found 2026-11-10 by the room chart ([moq-dev/moq#8447](https://github.com/moq-dev/moq/pull/8447)): -`js/room/verify/Room.tla` breaks `LeavingStopsPublishing` in 1 step. In -`js/room/src/local.ts` the screen broadcast is enabled by `#screenLive` alone, -not gated on `enabled`, so sharing before joining, or leaving while sharing, -publishes the screen as a screen-only participant with no name. -`residual: a screen share publishes while not joined and after leaving` in -`js/room/src/local.test.ts` pins it. - -Gate the screen broadcast on `enabled && #screenLive`, fix `Local`'s doc, -which describes today's behaviour, flip the residual test, and update the -Room chart so its rule holds. Check `moq-room` (Rust) for the same gate. - -Caused by: [moq-dev/moq#3634](https://github.com/moq-dev/moq/pull/3634), which -extracted conferencing into `@moq/room` with this gate. - -Public API: none (behaviour fix). Wire: none. From ba46176a5d5aab0362a0f81ae394a2fec1158e85 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 11 Nov 2026 00:25:24 -0800 Subject: [PATCH 3/3] test(room): pin the screen encoder gate Co-Authored-By: Claude Opus 5.5 --- js/room/src/local.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/js/room/src/local.test.ts b/js/room/src/local.test.ts index 7957a333af0..51f95746b1b 100644 --- a/js/room/src/local.test.ts +++ b/js/room/src/local.test.ts @@ -54,25 +54,30 @@ function setup() { return { local, bandwidth, screen: sources.at(-1) as FakeSource, broadcasts: { camera, screen: screenBroadcast } }; } -// Replays LeavingStopsPublishing in `verify/Room.tla`: the screen broadcast is enabled by a live -// share alone, so a participant who never joined, or who left, still publishes their screen. +// Replays LeavingStopsPublishing in `verify/Room.tla`: a participant who has not joined, or has +// left, neither publishes nor encodes their screen, even with a live share. test("a screen share publishes only while joined", async () => { const { local, screen, broadcasts } = setup(); + // The screen's hd, sd, and audio encoders are constructed last. + const screenEncoders = () => encoders.slice(-3).map((e) => (e.enabled as Signal).peek()); try { local.screenEnabled.set(true); screen.out.state.set({ kind: "live", media: { video: {} } }); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); expect(broadcasts.screen.enabled.peek()).toBe(false); + expect(screenEncoders()).toEqual([false, false, false]); local.enabled.set(true); await flush(); expect(broadcasts.screen.enabled.peek()).toBe(true); + expect(screenEncoders()).toEqual([true, true, true]); local.enabled.set(false); await flush(); expect(broadcasts.camera.enabled.peek()).toBe(false); expect(broadcasts.screen.enabled.peek()).toBe(false); + expect(screenEncoders()).toEqual([false, false, false]); } finally { local.close(); }