Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/lib/go/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ shows which module holds each.

- **Context cancellation.** Every call that can block takes a `context.Context` first, and cancelling it tears down the native work, not just your wait. A one-shot call (`RequestBroadcast`, `FetchGroup`, `Server.Accept`) aborts alone; a stream read (`Next`, `ReadFrame`, and the iterators over them) cancels the stream it reads.
- **Keep owners reachable.** An origin from `moq.NewOriginProducer` has no `Close`. It ends when the garbage collector reaches its last owner (each producer, published broadcast, and `OriginDynamic`), and its consumers then fail with `moq.ErrClosed`.
- **Bandwidth ownership.** An encoder you manage can reserve a share of the session's bandwidth. Reuse its reservation when the ceiling changes; before abandoning or replacing it, call `Update(0)` to return its share and automatic probe ceiling without waiting for garbage collection. The track can stay subscribed and the reservation can be reused.
- **Audio needs cuts.** Video groups at its keyframes, but audio forms a group only where you call `Cut()`: after every frame, or at a segment cadence to align with video.
- **Live encoder timing.** After writing a frame you encoded yourself, call `Flush(timestamp)` with the same timestamp so the catalog advertises your jitter. Skip it for file and network imports. On a seek or pause, call `Discontinuity()`, then keep timestamps moving forward and resume video on a keyframe.
- **Decoded frames hold decoder buffers.** `Close` each `VideoDecodedFrame` promptly, or the decoder stalls. `Resize` is best effort, so read each frame's `Width()` and `Height()`.
Expand Down
7 changes: 7 additions & 0 deletions go/wrapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ pinning a generated self-signed certificate.
`Client.Session().Stats()` returns a connection stats snapshot. Fields are nil
when the transport backend does not report that metric yet.

An encoder you manage can claim a share of `Client.Session().Bandwidth()` with
`Reserve(demand, maxBps)`, using the track's `Demand()`. Keep one reservation and change its ceiling
with `Update(maxBps)`. Call `Update(0)` before abandoning or replacing it to
return its share and automatic probe ceiling immediately, even while the track
stays subscribed. A later update can resume the same claim. While the track
stays open, dropping the Go reference alone leaves release to garbage collection.

Incoming server requests expose the query-free `Path()` before `Accept()`. It
is consistent across transports and returns an empty string for the root or
missing path. `Query()` returns the encoded query and may contain credentials.
Expand Down
86 changes: 86 additions & 0 deletions go/wrapper/bandwidth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package moq_test

import (
"context"
"math"
"runtime"
"testing"

"moq.dev/moq"
)

// Retaining the old handle models a replacement before collection.
// Update(0) must hand back its share even while both handles and subscribers live.
func TestReservationUpdateReleasesSubscribedTrack(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
relay := startRelay(t, ctx, "127.0.0.1:0")
defer relay.server.Close()
client, err := moq.Dial(ctx, "https://"+relay.addr,
moq.WithTLSVerify(false), moq.WithReconnect(false))
if err != nil {
t.Fatal(err)
}
defer client.Close()
bandwidth := client.Session().Bandwidth()
// Grant assertions use a live QUIC estimate, but need no traffic or probing.
off := uint64(0)
bandwidth.SetTarget(&off)
broadcast, err := moq.NewBroadcastProducer()
if err != nil {
t.Fatal(err)
}
defer broadcast.Close()

reserve := func(name string, priority int8, ceiling uint64) (*moq.TrackDemand, *moq.Reservation) {
t.Helper()
track, err := broadcast.PublishTrack(name, &moq.TrackInfo{Priority: priority})
if err != nil {
t.Fatal(err)
}
consumer, err := track.Consume(nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(consumer.Cancel)
// TrackDemand is weak, so keep the producer for the whole test.
t.Cleanup(func() { runtime.KeepAlive(track) })
demand, err := track.Demand()
if err != nil {
t.Fatal(err)
}
return demand, bandwidth.Reserve(demand, ceiling)
}
grant := func(reservation *moq.Reservation, want uint64) {
t.Helper()
got := reservation.Grant()
if got == nil {
t.Fatalf("grant unavailable, want %d", want)
}
if *got != want {
t.Fatalf("grant = %d, want %d", *got, want)
}
}

demand, retired := reserve("encoder", 1, math.MaxUint64)
_, sibling := reserve("sibling", 0, 1)
replacement := bandwidth.Reserve(demand, 1)
// Even a retained predecessor must relinquish the allocation explicitly.
// Priority makes this independent of the live estimate's exact value.
grant(sibling, 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for the first bandwidth sample

On a fresh connection this assertion races the asynchronous estimate forwarding: Session::new creates the send-bandwidth producer without seeding it (rs/moq-net/src/session.rs:345-347), and the connection publishes Connected before run_session forwards the first sample (rs/moq-tokio/src/connection.rs:875-882). Consequently Grant() can still return nil here, causing the helper to fail before the release behavior is tested; the context timeout cannot help because these reads are nonblocking. Synchronize on estimate availability or use a deterministic injected estimate so this remains a high-signal merge-gate test.

AGENTS.md reference: AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

retired.Update(0)
grant(retired, 0)
grant(replacement, 1)
grant(sibling, 1)

// Zero is reversible and repeated release is harmless.
retired.Update(math.MaxUint64)
grant(sibling, 0)
retired.Update(0)
retired.Update(0)
grant(sibling, 1)
if demand.Wanted() == nil {
t.Fatal("release ended the track subscription")
}
runtime.KeepAlive(retired)
}
11 changes: 8 additions & 3 deletions go/wrapper/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ type Bandwidth struct {

// Reserve claims up to maxBps for the track demand watches; every track
// producer hands out its Demand. maxBps is a ceiling, not a measurement:
// reserve the most the track can ever send. Drop the reservation to hand the
// room back.
// reserve the most the track can ever send. Reuse Reservation.Update to change
// the ceiling, or call Update(0) before abandoning or replacing the reservation
// to hand the room back without waiting for garbage collection.
func (b *Bandwidth) Reserve(demand *TrackDemand, maxBps uint64) *Reservation {
return &Reservation{inner: b.inner.Reserve(demand.inner, maxBps)}
}
Expand All @@ -162,6 +163,8 @@ func (b *Bandwidth) SetTarget(targetBps *uint64) {
//
// Grant is a snapshot: nil means no estimate or no demand, so hold the current
// rate, and 0 is a real zero grant.
// Update(0) relinquishes the allocation while the track stays subscribed; the
// handle remains usable and a later Update can claim bandwidth again.
type Reservation struct {
inner *ffi.MoqReservation
}
Expand All @@ -171,7 +174,9 @@ func (r *Reservation) Grant() *uint64 {
return r.inner.Grant()
}

// Update changes the ceiling, keeping the same claim.
// Update changes the ceiling, keeping the same claim. Zero immediately gives
// its share to siblings and removes its contribution to the automatic probe
// target; it does not close the track or destroy the native handle.
func (r *Reservation) Update(maxBps uint64) {
r.inner.Update(maxBps)
}
Expand Down
1 change: 0 additions & 1 deletion quest/m1/release-audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ child records whether its behavior is new on main or inherited from release.
- [Finish bitrate recovery](/quest/m1/release-audit/rate-recovery.md) - a quiet bandwidth grant no longer strands moq-video, moq-ffi, and moq-c encoders partway up the ramp
- [Release finished FFI codecs](/quest/m1/release-audit/ffi-finished.md) - preserving finished tracks does not preserve drained encoders and scratch buffers
- [Retire expiry metadata](/quest/m1/release-audit/expiry-index.md) - empty deadline entries do not accumulate with historical read budgets
- [Settle Go reservation release](/quest/m1/release-audit/go-reservation.md) - investigate prompt allocation release through the public wrapper
- [Settle finite HLS import](/quest/m1/release-audit/hls-finite.md) - initially ended playlists have an explicit and tested start position
- [Settle HLS timestamp resets](/quest/m1/release-audit/hls-discontinuity.md) - unchanged-map discontinuities preserve timeline identity or are explicitly refused

Expand Down
26 changes: 0 additions & 26 deletions quest/m1/release-audit/go-reservation.md

This file was deleted.

35 changes: 35 additions & 0 deletions rs/moq-net/src/model/bandwidth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,41 @@ mod tests {
assert_eq!(large_reserved.peek(), Some(bps(5_000_000)));
}

/// Collected-language callers can relinquish a ceiling before their retained
/// handle is finalized, without ending the track or waiting for its subscribers.
#[test]
fn zero_ceiling_releases_share_and_probe_target() {
let estimate = Producer::new();
let allocator = Allocator::new(estimate.consume());
estimate.set(Some(bps(3_000_000))).unwrap();
let mut target = allocator.target();
let waiter = kio::Waiter::noop();

let (_encoder_broadcast, encoder) = track(VIDEO);
let _encoder_sub = encoder.subscribe(None);
let retired = allocator.reserve(&encoder.demand(), bps(4_000_000));
let replacement = allocator.reserve(&encoder.demand(), bps(4_000_000));
let (_sibling_broadcast, sibling) = track(VIDEO);
let _sibling_sub = sibling.subscribe(None);
let sibling = allocator.reserve(&sibling.demand(), bps(4_000_000));
assert_eq!(sibling.peek(), Some(bps(1_000_000)));
assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(12_000_000)));

retired.update(Rate::ZERO);
assert_eq!(retired.peek(), Some(Rate::ZERO));
assert_eq!(replacement.peek(), Some(bps(1_500_000)));
assert_eq!(sibling.peek(), Some(bps(1_500_000)));
assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(8_000_000)));
retired.update(Rate::ZERO);
assert!(target.poll_changed(&waiter).is_pending());

// The registration remains usable, so an encoder may resume in place.
retired.update(bps(4_000_000));
assert_eq!(sibling.peek(), Some(bps(1_000_000)));
assert_eq!(target.poll_changed(&waiter), Poll::Ready(bps(12_000_000)));
assert!(encoder.demand().is_used());
}

/// A reader parked on `changed` has to be woken by its own reservation moving, not
/// just by the estimate or a sibling: the encoder is sitting in a `select!` on it.
#[moq_clock::test]
Expand Down
Loading