From dcc68cb392bc9ffdef83b4ae8c2e57046adca115 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 01:57:26 -0400 Subject: [PATCH 1/2] joule-moq-room: the clock's events track is published with each grant, so a viewer can enter an empty room The clock published its events track at the room's first event, a publisher's join. A viewer who entered a room nobody had joined subscribed to a track with no publisher, and the relay refused it at its rendezvous wait ("no publisher within RENDEZVOUS_TIMEOUT"): on wai.transaction.science, /room/'s Watch and WAI Studio's Rooms panel never entered while the room was empty. The track is now published by Service::open_events, which each grant spawns; the first event uses the same path (events_track), so the track is published once per room. room_service: a_viewer_enters_a_room_nobody_has_joined, a viewer's subscription answered in an empty room and the first publisher's join reaching it. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-room/src/http.rs | 8 +++ .../crates/joule-moq-room/src/lib.rs | 20 ++++++- .../joule-moq-room/tests/room_service.rs | 55 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs index 1981dcae1..370e8e7c9 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs @@ -375,6 +375,14 @@ pub fn grant(svc: &Arc, client: &str, host: Option<&str>, authorization if role == Role::Publisher || issued.audience { tokio::spawn(svc.clone().watch(generation, principal, until)); } + // The clock's events track is published with the grant, before the room's first event: + // a viewer who enters a room nobody has joined finds it. + let s = svc.clone(); + tokio::spawn(async move { + if let Err(e) = s.open_events(generation).await { + tracing::warn!(error = %e, "the clock's events track is not published"); + } + }); tracing::info!(role = ?role, room = %hex::encode(issued.room), principal = %hex::encode(&principal[..6]), "a grant"); reply( StatusCode::OK, diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs index 192b2c1b8..9f1c89bef 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs @@ -562,9 +562,17 @@ impl Service { self.emit_items_after(generation, items, 0).await } - /// [`Service::emit_items`], each record at room-clock `min_t` or later. - pub async fn emit_items_after(&self, generation: u64, items: &[Item], min_t: u64) -> Result, u64)>, String> { + /// Publish the clock's events track in the room of `generation` now, unless it is + /// published: before the room's first event, so that a viewer who enters a room nobody has + /// joined subscribes to a track that is there (the relay refuses a subscription no + /// publisher answers within its rendezvous wait). Each grant asks for it. + pub async fn open_events(&self, generation: u64) -> Result<(), String> { let mut clock = self.clock.lock().await; + self.events_track(&mut clock, generation).await.map(|_| ()) + } + + /// The clock's session, with its events track in the room of `generation` published. + async fn events_track(&self, clock: &mut Clock, generation: u64) -> Result, String> { let client = clock.client.clone().ok_or("the clock has no session")?; let (room, t0_us) = { let st = self.state.lock().unwrap(); @@ -588,6 +596,14 @@ impl Service { clock.track = Some(Track { generation, room, t0_us, alias, _publish: publish, chain: Chain::default(), group: None, stream: None, next_object: 0 }); self.co.lock().unwrap().room(generation).clock_chain.clear(); } + Ok(client) + } + + /// [`Service::emit_items`], each record at room-clock `min_t` or later. + pub async fn emit_items_after(&self, generation: u64, items: &[Item], min_t: u64) -> Result, u64)>, String> { + let mut clock = self.clock.lock().await; + let client = self.events_track(&mut clock, generation).await?; + let room = clock.track.as_ref().expect("made above").room; let mut out = Vec::new(); for what in items { let track = clock.track.as_mut().expect("made above"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs index 69f0abc42..ba9935211 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -9,6 +9,9 @@ //! - the grant alone, without the proof, opens no session; //! - publishers are bounded, a viewer is not counted among them, grants are bounded per //! client, and a new room starts once the last publisher has left; +//! - a viewer may enter a room nobody has joined: the clock's events track is published with +//! the grant, so the viewer's subscription is answered, and the clock's next record (the +//! first publisher's `join`) reaches it; //! - an end-to-end encrypted room (WAI confidentiality): its record checks against its //! descriptor, members key through the service's log as a page does (KeyPackages, the //! clock's proposals, a GroupInfo, commits and Welcomes, each checked by the clock before @@ -244,6 +247,58 @@ async fn run() { server.shutdown().await; } +/// The relay answers a subscription only once its track has a publisher (or refuses it at +/// its rendezvous wait), and the clock signs nothing in a room nobody has joined: the clock's +/// events track has to be published with the grant, or a viewer of an empty room is refused. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_viewer_enters_a_room_nobody_has_joined() { + timeout(Duration::from_secs(120), run_empty()).await.expect("the test finishes in time"); +} + +async fn run_empty() { + let keys = Keys::generate(); + let server = relay(&keys).await; + let cfg = Config::from_toml(&format!( + "listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\n[room]\nmax_publishers = 2\njoin_wait_s = 30\n", + server.websocket_addr.unwrap(), + server.relay().config().path + )) + .unwrap(); + let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg); + let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(svc.clone().run_clock()); + tokio::spawn(http::serve(svc.clone(), listener)); + timeout(STEP, async { + while !svc.connected.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the clock's session opens"); + + // A viewer first, in a room nobody has joined: its subscription to the clock's events + // track is answered. + let viewer = key(0x51); + let (st, v) = ask(addr, &viewer, "viewer", "10.0.0.5").await; + assert_eq!((st, v["role"].as_str()), (200, Some("viewer")), "{v}"); + let room = v["room"]["room"].as_str().unwrap().to_string(); + let cv = session(&server, with_grant(&server, &hex::decode(v["grant"].as_str().unwrap()).unwrap(), &viewer)).await; + let clock = pubhex(&keys.clock); + let ns = TrackNamespace::from_strs([room.as_str(), clock.as_str(), "events"]); + cv.subscribe(ns, TrackName::from_str("0"), Parameters::new()).await.expect("a viewer of an empty room subscribes to the clock's events track"); + + // Then a publisher enters; the clock signs her `join`, and it reaches the viewer on that + // subscription. + let alice = key(0x52); + let (st, a) = ask(addr, &alice, "publisher", "10.0.0.6").await; + assert_eq!(st, 200, "{a}"); + assert_eq!(a["room"]["room"].as_str(), Some(room.as_str()), "the publisher enters the viewer's room"); + let ca = session(&server, with_grant(&server, &hex::decode(a["grant"].as_str().unwrap()).unwrap(), &alice)).await; + let _held = publish_media(&ca, &room, &alice).await; + assert!(cv.next_data(STEP).await.is_some(), "the clock's record reaches the viewer on its subscription"); +} + // ------------------------------------------------------------------- end-to-end encryption /// A member as a page is one: it reads the room's log in order and creates or commits -- 2.54.0 (Apple Git-157) From 3b7f4299b8a02abd3fc11589d483acb9b1386743 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 02:02:08 -0400 Subject: [PATCH 2/2] joule-moq-room README: the events track is published with the room's first grant Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/crates/joule-moq-room/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index 2dd3608b8..3338b262a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -18,7 +18,9 @@ authority and clock keys and: watches each publisher's video track (a SUBSCRIBE that asks for no objects): when the track appears the clock signs `join` and `publish` for the publisher on its events track (live-events §8.1), and when the relay ends the track (the publisher's session closed) it - signs `leave`. Pages author none of it. + signs `leave`. Pages author none of it. The events track is published with the room's + first grant, before anything is signed on it, so a viewer who enters a room nobody has + joined subscribes to a track that is there. - **serves the room.** `GET /room` gives the signed descriptor, the composition descriptor and the publishers' grants, which a page verifies the room with (live-rooms §9). -- 2.54.0 (Apple Git-157)