From 3e12160743a4e16084ca68beef7e394f354df38a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 01:50:46 -0800 Subject: [PATCH 1/2] chore(quest): claim quest/m2/binding-broadcast-end.md Quest-Claim: b5c87e210376c378 From 4e74e9a61a2e21623cf72087534436e68e04082e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 1 Nov 2026 01:58:06 -0800 Subject: [PATCH 2/2] test(bindings): cover MoqBroadcastConsumer.closed in Kotlin, Swift, and Dart Go, Python, and the Rust FFI already test that a broadcast consumer's closed() returns once the broadcast ends; the remaining wrappers expose it through aliases but had no test. The moq-transport run-end error kind is owned by quest/m1/ffi-ietf-run-end.md, so this quest is complete. Co-Authored-By: Claude Opus 5.5 --- dart/moq/test/moq_test.dart | 10 ++++++ .../kotlin/dev/moq/SmokeTest.kt | 11 +++++++ quest/m2/README.md | 1 - quest/m2/binding-broadcast-end.md | 33 ------------------- swift/Tests/MoqTests/SmokeTests.swift | 8 +++++ 5 files changed, 29 insertions(+), 34 deletions(-) delete mode 100644 quest/m2/binding-broadcast-end.md diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index a772ace5575..df9590bac3c 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -387,6 +387,16 @@ void main() { expect(() => catalog.finish(), throwsA(isA())); }); + test('a broadcast consumer\'s closed() resolves once it closes', () async { + final broadcast = BroadcastProducer(); + var resolved = false; + final closed = broadcast.consume().closed().then((_) => resolved = true); + await Future.delayed(const Duration(milliseconds: 10)); + expect(resolved, isFalse, reason: 'resolved while the broadcast is live'); + broadcast.close(); + await closed.timeout(timeout); + }); + test('microsecond timestamps read back as Durations', () { expect( Frame(payload: Uint8List(0), timestampUs: 20000).timestamp, diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index f96a36d52db..cb5e57a3809 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -250,6 +250,17 @@ class SmokeTest { } } + @Test + fun `a broadcast consumer's closed returns once the broadcast ends`() = runTest { + BroadcastProducer().use { broadcast -> + broadcast.consume().use { consumer -> + val closed = async { consumer.closed() } + broadcast.end() + closed.await() + } + } + } + @Test fun `broadcast updates shared video properties`() { BroadcastProducer().use { broadcast -> diff --git a/quest/m2/README.md b/quest/m2/README.md index 2dfe86b62d4..2e4bad8d8c9 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -236,7 +236,6 @@ lite-07; bitrate enforcement is not part of this release. - [Interop audit](/quest/m2/audit-interop.md) - our interop runner entries match what we ship, and every red interop cell with a moq.dev side is fixed, reported, or noted as down or unsupported - [The HLS server follows each path once](/quest/m2/hls-follow-once.md) - `Following` reports synced absence, so moq-hls resolves with one follow - [moq-bench pins the instances it measures](/quest/m2/bench-epoch-pin.md) - pooled requests pin their announced epoch, so a takeover is not counted as the same broadcast -- [Bindings see a broadcast end](/quest/m2/binding-broadcast-end.md) - `MoqBroadcastConsumer` exposes the close, and a moq-transport run end is a protocol error - [We own our WebRTC stack](/quest/m2/webrtc/README.md) - moq-rtc becomes moq-webrtc with moq-ice, the data-channel transport, and a vendored, trimmed str0m/dimpl/is/sctp-proto - [Certificate reload and qmux's tasks are supervised](/quest/m2/tls-reload-supervision.md) - a panic in either fails loudly instead of silently stopping - [PUBLISH_DONE says why](/quest/m2/publish-done-codes.md) - GOING_AWAY, TOO_FAR_BEHIND, and EXPIRED round-trip as stream codes diff --git a/quest/m2/binding-broadcast-end.md b/quest/m2/binding-broadcast-end.md deleted file mode 100644 index ce167c0c7eb..00000000000 --- a/quest/m2/binding-broadcast-end.md +++ /dev/null @@ -1,33 +0,0 @@ -# [S] Bindings see a broadcast end - -## Goal - -A moq-ffi binding (Go, Kotlin, Swift, Python, Dart) can tell that a -broadcast it is reading has ended, apart from a refusal, without matching -error types. `MoqBroadcastConsumer` exposes the broadcast closing, as the -Rust consumer does. A publisher's run ending reaches bindings the same way -over moq-transport as over moq-lite: as a protocol error, not -`MoqProtocolKind::Internal`. - -## Plan - -Decided 2026-10-30 (moq.pro maintainer, planning the follow-ups of -[moq-dev/moq.pro#3169](https://github.com/moq-dev/moq.pro/pull/3169)). That -PR's Go and Kotlin watch samples tell a run's end from a permanent catalog -refusal only by the error kind: a lite run's end arrives as a protocol -reset. Without a way to see the broadcast close, they can print the old -run's catalog once more during a replacement. A moq-transport run's end -arrives as an internal error, so those samples stop instead of following it. - -- Add a `closed()` (or equivalent) to `MoqBroadcastConsumer` in - `rs/moq-ffi/src/consumer.rs`, resolving when the underlying - `moq_net::broadcast::Consumer` closes. Cover it in each binding's tests. -- Find where a moq-transport publisher's run end becomes - `SessionError::Internal` / `StreamError::Internal` for a subscriber, and - give it the same code lite uses, in both directions of a relay. Cover it in - the session harness for lite-06, lite-07-wip, and moq-transport-22, direct - and through a relay. -- Adding `closed()` is additive. If the error-kind change is a wire or API - break, bring it to the maintainer first, as this milestone's Plan says. -- Downstream, moq.pro's Go and Kotlin samples switch to `closed()` with the - pin bump that brings this in. diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 5a9c90c8117..9c36b6dcdb7 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -326,6 +326,14 @@ final class SmokeTests: XCTestCase { XCTAssertThrowsError(try catalog.finish()) } + func testBroadcastConsumerClosedReturnsOnClose() async throws { + let broadcast = try BroadcastProducer() + let consumer = try broadcast.consume() + let closed = Task { try await consumer.closed() } + try broadcast.close() + try await closed.value + } + func testVideoHintsReachMediaPublishApi() throws { let broadcast = try BroadcastProducer() let hint = Media.VideoHint(