diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 6360596ae93..d13f5c72f20 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -393,6 +393,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('timestamps round-trip exactly at the track scale', () async { final broadcast = BroadcastProducer(); final track = broadcast.publishTrack( diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index c82ead5ca1a..1abb447d9bc 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -262,6 +262,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 331c25308bf..5f4097b4874 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -233,7 +233,6 @@ lite-07; bitrate enforcement is not part of this release. - [Audio's Latest start reaches back](/quest/m2/audio-latest-reach-back.md) - moq_audio's start covers min plus warmup like other consumers - [moq-lite tails count drops](/quest/m2/lite-tail-drop-count.md) - SUBSCRIBE_DROP accounting replaces the session-tail grace from lite-05 - [Re-run the SPS fuzzer on h264-reader](/quest/m2/sps-fuzz-h264-reader.md) - the H.264 SPS overflow is rechecked on the new parser -- [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 - [Audio track changes are gapless](/quest/m2/gapless-audio-switch.md) - no silence beyond a frame on audio format or rendition changes, enforced by media QA - [PCM encodes in one pass](/quest/m2/pcm-bulk-encode.md) - the PCM encoder converts a frame in bulk instead of one sample at a time - [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 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 922c50a7e0f..dfd88b59d58 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -347,6 +347,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(