Skip to content

Commit d49a924

Browse files
floatdropclaude
andauthored
feat(session): round out accept-side request helpers (#33)
* feat(session): round out accept-side request helpers Every outbound request opener now has a matching Accept* counterpart, so a server gets the same typed-handle ergonomics AcceptSubscribe already gave: - AcceptPublish now returns *IncomingPublication (TrackAlias, Update) instead of bare error — the receiving side of a publisher push. - AcceptFetch writes FETCH_OK and returns *FetchResponder, whose OpenFetchStream binds the fetch's Request ID onto the FETCH_HEADER uni-stream automatically. - AcceptTrackStatus writes TRACK_STATUS_OK. It returns no handle by design (TRACK_STATUS is a one-shot status query with no push side); this is documented rather than left as an arbitrary asymmetry. - AcceptPublishNamespace / AcceptSubscribeNamespace / AcceptSubscribeTracks write REQUEST_OK and return *IncomingNamespacePublication / *IncomingNamespaceSubscription / *IncomingTrackSubscription. The last carries WritePublishBlocked, mirroring TrackSubscription.ReadPublishBlocked. Adds accept_test.go (round-trips incl. a FETCH stream and a PUBLISH_BLOCKED exchange), updates the AcceptPublish callers/example, and adds a README row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(session): demonstrate all Accept* helpers in the example The README "Examples" table maps each topic to a real, compile-checked Example function; the accept-side row instead listed bare method names, which is neither an example nor verified. Extend ExampleSession_AcceptRequest to dispatch and answer every request type via its Accept* helper (adding the TrackStatus and namespace cases) and point the single "Accept requests" row at it, dropping the off-convention method list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e79c0c commit d49a924

10 files changed

Lines changed: 477 additions & 12 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ grouped here by the file they live in:
137137
| End a publication | `Example_endingAPublication` |
138138
| Stream exhaustion (PUBLISH_BLOCKED) | `ExampleSession_OpenPublish`, `ExampleSession_ReadPublishBlocked` |
139139
| Announce / discover namespaces | `ExampleSession_PublishNamespace`, `ExampleSession_SubscribeNamespace` |
140-
| Accept requests (server side) | `ExampleSession_AcceptRequest` |
140+
| Accept requests + reply (`Accept*` helpers) | `ExampleSession_AcceptRequest` |
141141
| Graceful shutdown (GOAWAY) | `ExampleSession_SendGoaway`, `ExampleSession_OnGoaway` |
142142

143143
**[`relay`](pkg/relay/example_test.go)**

pkg/moqt/session/accept_test.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package session_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/floatdrop/moq-go/pkg/moqt/message"
7+
"github.com/floatdrop/moq-go/pkg/moqt/session"
8+
"github.com/floatdrop/moq-go/pkg/moqt/wire"
9+
)
10+
11+
// TestAcceptFetchRepliesFetchOK checks AcceptFetch writes FETCH_OK with the
12+
// supplied fields and that the responder's OpenFetchStream binds this fetch's
13+
// Request ID onto the FETCH_HEADER uni-stream automatically.
14+
//
15+
// The server side runs in a goroutine and the client reads on the main
16+
// goroutine in send order (FETCH_OK, then the FETCH_HEADER stream): the
17+
// sessiontest streams are synchronous pipes, so a writer needs a concurrent
18+
// reader.
19+
func TestAcceptFetchRepliesFetchOK(t *testing.T) {
20+
t.Parallel()
21+
client, server := openPair(t)
22+
ctx := t.Context()
23+
24+
fetchMsg := &message.Fetch{
25+
RequestID: client.AllocRequestID(),
26+
FetchType: message.FetchTypeStandalone,
27+
Standalone: &message.StandaloneFetch{
28+
Namespace: wire.Namespace("ns"),
29+
Name: []byte("clip"),
30+
},
31+
}
32+
33+
srvErr := make(chan error, 1)
34+
go func() {
35+
srvErr <- func() error {
36+
req, err := server.AcceptRequest(ctx)
37+
if err != nil {
38+
return err
39+
}
40+
resp, err := req.AcceptFetch(&message.FetchOK{
41+
EndOfTrack: true,
42+
EndLocation: message.Location{Group: 5, Object: 9},
43+
})
44+
if err != nil {
45+
return err
46+
}
47+
fs, err := resp.OpenFetchStream() // Request ID bound automatically
48+
if err != nil {
49+
return err
50+
}
51+
return fs.Close()
52+
}()
53+
}()
54+
55+
fr, err := client.Fetch(ctx, fetchMsg)
56+
if err != nil {
57+
t.Fatalf("client Fetch: %v", err)
58+
}
59+
if !fr.OK.EndOfTrack || fr.OK.EndLocation != (message.Location{Group: 5, Object: 9}) {
60+
t.Errorf("FETCH_OK = %+v, want EndOfTrack and EndLocation {5,9}", fr.OK)
61+
}
62+
63+
ds, err := client.AcceptDataStream(ctx)
64+
if err != nil {
65+
t.Fatalf("AcceptDataStream: %v", err)
66+
}
67+
fin, ok := ds.(*session.IncomingFetchStream)
68+
if !ok {
69+
t.Fatalf("got %T, want *IncomingFetchStream", ds)
70+
}
71+
if fin.Header.RequestID != fetchMsg.RequestID {
72+
t.Errorf("FETCH_HEADER RequestID = %d, want %d (bound by the responder)",
73+
fin.Header.RequestID, fetchMsg.RequestID)
74+
}
75+
if err := <-srvErr; err != nil {
76+
t.Fatalf("server: %v", err)
77+
}
78+
}
79+
80+
// TestAcceptTrackStatusRepliesOK checks AcceptTrackStatus replies TRACK_STATUS_OK
81+
// (a REQUEST_OK) carrying the supplied Track Properties.
82+
func TestAcceptTrackStatusRepliesOK(t *testing.T) {
83+
t.Parallel()
84+
client, server := openPair(t)
85+
86+
ts := &message.TrackStatus{
87+
RequestID: client.AllocRequestID(),
88+
Namespace: wire.Namespace("ns"),
89+
Name: []byte("t"),
90+
}
91+
_, resp := runRequestRoundTrip(t, client, server, ts, func(r *session.Request) error {
92+
return r.AcceptTrackStatus(&message.TrackStatusOK{})
93+
})
94+
if _, ok := resp.(*message.RequestOK); !ok {
95+
t.Fatalf("client got %s, want REQUEST_OK (TRACK_STATUS_OK)", resp.Type())
96+
}
97+
}
98+
99+
// TestAcceptNamespaceRequestsReplyOK checks the three namespace-request accept
100+
// helpers each reply REQUEST_OK and return a handle.
101+
func TestAcceptNamespaceRequestsReplyOK(t *testing.T) {
102+
t.Parallel()
103+
104+
t.Run("PublishNamespace", func(t *testing.T) {
105+
t.Parallel()
106+
client, server := openPair(t)
107+
m := &message.PublishNamespace{RequestID: client.AllocRequestID(), Namespace: wire.Namespace("ns")}
108+
_, resp := runRequestRoundTrip(t, client, server, m, func(r *session.Request) error {
109+
_, err := r.AcceptPublishNamespace()
110+
return err
111+
})
112+
if _, ok := resp.(*message.RequestOK); !ok {
113+
t.Fatalf("got %s, want REQUEST_OK", resp.Type())
114+
}
115+
})
116+
117+
t.Run("SubscribeNamespace", func(t *testing.T) {
118+
t.Parallel()
119+
client, server := openPair(t)
120+
m := &message.SubscribeNamespace{RequestID: client.AllocRequestID(), TrackNamespacePrefix: wire.Namespace("ns")}
121+
_, resp := runRequestRoundTrip(t, client, server, m, func(r *session.Request) error {
122+
_, err := r.AcceptSubscribeNamespace()
123+
return err
124+
})
125+
if _, ok := resp.(*message.RequestOK); !ok {
126+
t.Fatalf("got %s, want REQUEST_OK", resp.Type())
127+
}
128+
})
129+
130+
t.Run("SubscribeTracks", func(t *testing.T) {
131+
t.Parallel()
132+
client, server := openPair(t)
133+
m := &message.SubscribeTracks{RequestID: client.AllocRequestID(), TrackNamespacePrefix: wire.Namespace("ns")}
134+
_, resp := runRequestRoundTrip(t, client, server, m, func(r *session.Request) error {
135+
_, err := r.AcceptSubscribeTracks()
136+
return err
137+
})
138+
if _, ok := resp.(*message.RequestOK); !ok {
139+
t.Fatalf("got %s, want REQUEST_OK", resp.Type())
140+
}
141+
})
142+
}
143+
144+
// TestAcceptSubscribeTracksWritePublishBlocked checks the publisher-side
145+
// WritePublishBlocked round-trips to the subscriber's ReadPublishBlocked. The
146+
// server side runs in a goroutine so its REQUEST_OK and PUBLISH_BLOCKED writes
147+
// have the client's concurrent reads (SubscribeTracks, ReadPublishBlocked) to
148+
// drain the synchronous sessiontest pipes.
149+
func TestAcceptSubscribeTracksWritePublishBlocked(t *testing.T) {
150+
t.Parallel()
151+
client, server := openPair(t)
152+
ctx := t.Context()
153+
154+
srvErr := make(chan error, 1)
155+
go func() {
156+
srvErr <- func() error {
157+
req, err := server.AcceptRequest(ctx)
158+
if err != nil {
159+
return err
160+
}
161+
pub, err := req.AcceptSubscribeTracks()
162+
if err != nil {
163+
return err
164+
}
165+
return pub.WritePublishBlocked(&message.PublishBlocked{
166+
TrackNamespaceSuffix: wire.Namespace("ns"),
167+
TrackName: []byte("blocked-track"),
168+
})
169+
}()
170+
}()
171+
172+
ts, err := client.SubscribeTracks(ctx, &message.SubscribeTracks{
173+
TrackNamespacePrefix: wire.Namespace("ns"),
174+
})
175+
if err != nil {
176+
t.Fatalf("client SubscribeTracks: %v", err)
177+
}
178+
defer ts.Close()
179+
180+
pb, err := ts.ReadPublishBlocked()
181+
if err != nil {
182+
t.Fatalf("ReadPublishBlocked: %v", err)
183+
}
184+
if string(pb.TrackName) != "blocked-track" {
185+
t.Errorf("PUBLISH_BLOCKED TrackName = %q, want %q", pb.TrackName, "blocked-track")
186+
}
187+
if err := <-srvErr; err != nil {
188+
t.Fatalf("server: %v", err)
189+
}
190+
}
191+
192+
// TestAcceptPublishReturnsHandle checks AcceptPublish replies REQUEST_OK and
193+
// returns a handle reporting the publisher-assigned Track Alias.
194+
func TestAcceptPublishReturnsHandle(t *testing.T) {
195+
t.Parallel()
196+
client, server := openPair(t)
197+
ctx := t.Context()
198+
199+
type result struct {
200+
pub *session.Publication
201+
err error
202+
}
203+
done := make(chan result, 1)
204+
go func() {
205+
pub, err := client.Publish(ctx, &message.Publish{
206+
Namespace: wire.Namespace("ns"),
207+
Name: []byte("track"),
208+
TrackAlias: 11,
209+
})
210+
done <- result{pub, err}
211+
}()
212+
213+
req, err := server.AcceptRequest(ctx)
214+
if err != nil {
215+
t.Fatalf("AcceptRequest: %v", err)
216+
}
217+
recv, err := req.AcceptPublish()
218+
if err != nil {
219+
t.Fatalf("AcceptPublish: %v", err)
220+
}
221+
if recv.TrackAlias() != 11 {
222+
t.Errorf("IncomingPublication.TrackAlias = %d, want 11", recv.TrackAlias())
223+
}
224+
225+
got := <-done
226+
if got.err != nil {
227+
t.Fatalf("client Publish: %v", got.err)
228+
}
229+
_ = got.pub
230+
}

pkg/moqt/session/example_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,9 +442,47 @@ func ExampleSession_AcceptRequest() {
442442
case *message.Publish:
443443
// AcceptPublish registers the Track Alias (§11.1) and replies
444444
// REQUEST_OK; objects arrive via Session.AcceptDataStream.
445-
_ = req.AcceptPublish()
445+
recv, err := req.AcceptPublish()
446+
if err != nil {
447+
return
448+
}
449+
_ = recv // recv.TrackAlias() / recv.Update(...) for follow-ups.
446450
case *message.Fetch:
447-
_ = req.RejectError(moqt.RequestDoesNotExist, "no such range")
451+
// AcceptFetch replies FETCH_OK and returns a responder whose
452+
// OpenFetchStream is pre-bound to this fetch's Request ID.
453+
resp, err := req.AcceptFetch(nil)
454+
if err != nil {
455+
return
456+
}
457+
_ = resp // resp.OpenFetchStream() to stream the response objects.
458+
case *message.TrackStatus:
459+
// AcceptTrackStatus replies TRACK_STATUS_OK. It is a one-shot status
460+
// query, so (unlike the others) it returns no handle.
461+
_ = req.AcceptTrackStatus(nil)
462+
case *message.PublishNamespace:
463+
// AcceptPublishNamespace replies REQUEST_OK; NAMESPACE / NAMESPACE_DONE
464+
// follow-ups arrive by reading the returned handle (e.g. message.Parse).
465+
ann, err := req.AcceptPublishNamespace()
466+
if err != nil {
467+
return
468+
}
469+
_ = ann
470+
case *message.SubscribeNamespace:
471+
// AcceptSubscribeNamespace replies REQUEST_OK; announce matching
472+
// namespaces by writing NAMESPACE / NAMESPACE_DONE to the handle.
473+
sub, err := req.AcceptSubscribeNamespace()
474+
if err != nil {
475+
return
476+
}
477+
_ = sub
478+
case *message.SubscribeTracks:
479+
// AcceptSubscribeTracks replies REQUEST_OK; forward matching tracks as
480+
// PUBLISHes and signal stream exhaustion with WritePublishBlocked.
481+
tracks, err := req.AcceptSubscribeTracks()
482+
if err != nil {
483+
return
484+
}
485+
_ = tracks // tracks.WritePublishBlocked(...) on §6.1 stream exhaustion.
448486
}
449487
}
450488
}

pkg/moqt/session/fetch.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,52 @@ func (s *Session) Fetch(ctx context.Context, m *message.Fetch) (*FetchRequest, e
6060
})
6161
}
6262

63+
// FetchResponder is the publisher side of a FETCH (§10.12) this endpoint
64+
// accepted via [Request.AcceptFetch] — the accept-side counterpart of
65+
// [Session.Fetch]. FETCH_OK has already been written on the embedded request
66+
// stream; the response objects are streamed on a separate FETCH_HEADER
67+
// uni-stream (§11.5) opened via [FetchResponder.OpenFetchStream], which binds
68+
// this fetch's Request ID automatically. The embedded request stream stays open
69+
// for REQUEST_UPDATE follow-ups.
70+
type FetchResponder struct {
71+
// Stream is the FETCH request stream, still open for REQUEST_UPDATE
72+
// follow-ups. Close it to end the fetch.
73+
Stream
74+
75+
s *Session
76+
requestID uint64
77+
}
78+
79+
// OpenFetchStream opens the outbound FETCH_HEADER uni-stream (§11.5) carrying
80+
// this fetch's response objects, with the Request ID bound automatically. The
81+
// caller MUST Close the returned stream to FIN it once all objects are written,
82+
// or Cancel to reset. It is [Session.OpenFetchStream] pre-bound to this fetch.
83+
func (f *FetchResponder) OpenFetchStream() (*OutgoingFetchStream, error) {
84+
return f.s.OpenFetchStream(message.FetchHeader{RequestID: f.requestID})
85+
}
86+
87+
// AcceptFetch accepts an inbound FETCH (§10.12) and returns a [FetchResponder]
88+
// for streaming the response objects — the accept-side counterpart of
89+
// [Session.Fetch]. r.First MUST be a *message.Fetch.
90+
//
91+
// ok carries the FETCH_OK fields the caller wants to set (EndOfTrack,
92+
// EndLocation, negotiated Parameters, TrackProperties); it may be nil for the
93+
// all-default reply. AcceptFetch writes FETCH_OK and returns a responder whose
94+
// [FetchResponder.OpenFetchStream] is pre-bound to this fetch's Request ID.
95+
func (r *Request) AcceptFetch(ok *message.FetchOK) (*FetchResponder, error) {
96+
f, isFetch := r.First.(*message.Fetch)
97+
if !isFetch {
98+
return nil, fmt.Errorf("moqt/session: AcceptFetch on a %s request", r.First.Type())
99+
}
100+
if ok == nil {
101+
ok = &message.FetchOK{}
102+
}
103+
if err := message.Marshal(r.Stream, ok); err != nil {
104+
return nil, fmt.Errorf("moqt/session: write FETCH_OK: %w", err)
105+
}
106+
return &FetchResponder{Stream: r.Stream, s: r.s, requestID: f.RequestID}, nil
107+
}
108+
63109
// OpenFetchStream opens an outbound FETCH_HEADER uni-stream (§11.5),
64110
// writes the header (Type + Request ID), and returns the body writer. The
65111
// caller MUST Close to FIN the stream once all fetch objects have been

0 commit comments

Comments
 (0)