Files
livekit/pkg/agent/endpoint/stream.go
T
Théo Monnom 8c7011cb7a agent: HTTP endpoints data plane
Serve worker-declared FastAPI routes at /agents/{deployment}/{path} without
any worker-side listener: workers dial a fixed pool of wires speaking
AgentHttp.Frame, the server opens multiplexed streams carrying one opaque
HTTP/1.1 exchange each (two-level credit flow control, prioritized write
scheduler, attach epoch fencing). The front does starlette-exact manifest
matching with per-endpoint public access, typed 401/404/405, a retry table,
and SSE/WebSocket passthrough; a pluggable fallback hook lets multi-node
deployments resolve misses elsewhere. Includes a conformance client, a
manual sidecar, and the acceptance suite.

The auth middleware now reads access_token from the query string only:
FormValue consumed the bodies of proxied url-encoded POSTs.
2026-08-19 18:25:35 -07:00

342 lines
8.3 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package endpoint
import (
"errors"
"fmt"
"io"
"sync"
"github.com/livekit/protocol/livekit"
)
// Stream is the server side of one multiplexed stream: one opaque HTTP
// exchange. Write carries request bytes toward the worker under the
// peer-granted stream window AND the wire's shared connection window; Read
// consumes response bytes and replenishes both windows as they are consumed, so
// a slow HTTP client suspends the worker's send without touching sibling
// streams.
type Stream struct {
id uint32
conn *DataConn
mu sync.Mutex
cond *sync.Cond
// send side (request bytes)
sendCredit int64
writeClosed bool
// recv side (response bytes)
recvChunks [][]byte
recvOffset int // read offset into recvChunks[0]
recvWindow int64 // bytes the peer may still send; enforced, not advisory
recvUnacked int64 // consumed bytes not yet credited back
recvEOF bool
refused bool
err error
closed bool
bytesRead int64
bytesWritten int64
}
func newStream(id uint32, conn *DataConn, window int64) *Stream {
s := &Stream{
id: id,
conn: conn,
sendCredit: window,
recvWindow: window,
}
s.cond = sync.NewCond(&s.mu)
return s
}
func (s *Stream) ID() uint32 { return s.id }
// BytesRead reports response bytes consumed so far; the retry boundary is
// "no response byte arrived".
func (s *Stream) BytesRead() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.bytesRead
}
// Refused reports whether the worker reset the stream with HSR_REFUSED, i.e.
// the request was never dispatched and is safe to retry elsewhere.
func (s *Stream) Refused() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.refused
}
// Write sends request bytes toward the worker, blocking on the stream window,
// the wire's shared connection window and the local buffer budget.
func (s *Stream) Write(p []byte) (int, error) {
cancelled := func() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed || s.err != nil || s.writeClosed
}
total := 0
for len(p) > 0 {
s.mu.Lock()
for s.sendCredit <= 0 && s.err == nil && !s.closed && !s.writeClosed {
s.cond.Wait()
}
if err := s.writeErrLocked(); err != nil {
s.mu.Unlock()
return total, err
}
want := int64(len(p))
if want > s.sendCredit {
want = s.sendCredit
}
if max := int64(s.conn.params.MaxFrameSize); want > max {
want = max
}
s.mu.Unlock()
// the shared window is reserved after the stream window and outside
// s.mu (the cancelled callback re-locks it)
n, err := s.conn.reserveConnSend(want, cancelled)
if err != nil {
return total, err
}
s.mu.Lock()
if err := s.writeErrLocked(); err != nil {
s.mu.Unlock()
s.conn.returnConnSend(n)
return total, err
}
var overshoot int64
if n > s.sendCredit {
// single-writer streams never hit this; stay safe regardless
overshoot = n - s.sendCredit
n = s.sendCredit
}
s.sendCredit -= n
s.bytesWritten += n
s.mu.Unlock()
s.conn.returnConnSend(overshoot)
if n == 0 {
// a concurrent writer drained the stream window between the peek
// and the reservation: never emit an empty frame
continue
}
chunk := make([]byte, n)
copy(chunk, p[:n])
if err := s.conn.sched.enqueueData(s.id, chunk); err != nil {
return total, err
}
s.conn.noteActivity(n)
p = p[n:]
total += int(n)
}
return total, nil
}
func (s *Stream) writeErrLocked() error {
switch {
case s.err != nil:
return s.err
case s.closed:
return ErrStreamClosed
case s.writeClosed:
return fmt.Errorf("%w: write after CloseWrite", ErrStreamClosed)
}
return nil
}
// CloseWrite half-closes the send side; the worker sees EOF after the queued
// request bytes drain.
func (s *Stream) CloseWrite() error {
s.mu.Lock()
if s.writeClosed || s.closed {
s.mu.Unlock()
return nil
}
s.writeClosed = true
s.mu.Unlock()
return s.conn.sched.enqueueEOF(s.id)
}
// Read consumes response bytes. Stream and connection credit are granted back
// to the worker on consumption (threshold acking at half the respective
// window) - receipt alone never replenishes either window.
func (s *Stream) Read(p []byte) (int, error) {
s.mu.Lock()
for len(s.recvChunks) == 0 && !s.recvEOF && s.err == nil && !s.closed {
s.cond.Wait()
}
if len(s.recvChunks) == 0 {
err := s.err
if err == nil && s.recvEOF {
err = io.EOF
} else if err == nil {
err = ErrStreamClosed
}
s.mu.Unlock()
return 0, err
}
chunk := s.recvChunks[0]
n := copy(p, chunk[s.recvOffset:])
s.recvOffset += n
if s.recvOffset == len(chunk) {
s.recvChunks = s.recvChunks[1:]
s.recvOffset = 0
}
s.bytesRead += int64(n)
s.recvUnacked += int64(n)
var credit int64
if s.recvUnacked >= int64(s.conn.params.CreditWindow)/2 {
credit = s.recvUnacked
s.recvUnacked = 0
s.recvWindow += credit
}
s.mu.Unlock()
if credit > 0 {
_ = s.conn.sched.enqueueControl(&livekit.AgentHttp_Frame{
StreamId: s.id,
Message: &livekit.AgentHttp_Frame_Credit{Credit: uint32(credit)},
})
}
s.conn.connConsumed(int64(n))
s.conn.noteActivity(int64(n))
return n, nil
}
// drainUnreadLocked releases response bytes that arrived but will never be
// Read, so a torn-down stream cannot leak the wire's connection window.
// Callers hold s.mu.
func (s *Stream) drainUnreadLocked() int64 {
var pending int64
for i, c := range s.recvChunks {
pending += int64(len(c))
if i == 0 {
pending -= int64(s.recvOffset)
}
}
s.recvChunks = nil
s.recvOffset = 0
return pending
}
// Reset aborts the stream in both directions.
func (s *Stream) Reset(code livekit.AgentHttp_HttpStreamResetCode, reason string) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
if s.err == nil {
s.err = ErrStreamClosed
}
pending := s.drainUnreadLocked()
s.cond.Broadcast()
s.mu.Unlock()
s.conn.connConsumed(pending)
s.conn.wakeSendWaiters()
s.conn.sched.dropStream(s.id)
_ = s.conn.sched.enqueueControl(&livekit.AgentHttp_Frame{
StreamId: s.id,
Message: &livekit.AgentHttp_Frame_Reset_{
Reset_: &livekit.AgentHttp_HttpStreamReset{Code: code, Error: reason},
},
})
s.conn.removeStream(s.id)
}
// Close releases the stream after a completed exchange.
func (s *Stream) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
if s.err == nil {
s.err = ErrStreamClosed
}
pending := s.drainUnreadLocked()
s.cond.Broadcast()
s.mu.Unlock()
s.conn.connConsumed(pending)
s.conn.wakeSendWaiters()
s.conn.sched.dropStream(s.id)
s.conn.removeStream(s.id)
return nil
}
// --- frame delivery from the wire read loop ---
func (s *Stream) onData(payload []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
// stream was reset locally; the conn window is released by the caller
return errStreamGone
}
if int64(len(payload)) > s.recvWindow {
return fmt.Errorf("%w: peer exceeded credit window on stream %d", ErrProtocol, s.id)
}
s.recvWindow -= int64(len(payload))
s.recvChunks = append(s.recvChunks, payload)
s.cond.Broadcast()
return nil
}
// errStreamGone tells the read loop the payload was not adopted and its
// connection-window share must be released immediately.
var errStreamGone = errors.New("stream gone")
func (s *Stream) onEOF() {
s.mu.Lock()
s.recvEOF = true
s.cond.Broadcast()
s.mu.Unlock()
}
func (s *Stream) onReset(code livekit.AgentHttp_HttpStreamResetCode, reason string) {
s.mu.Lock()
if s.err == nil {
if code == livekit.AgentHttp_HSR_REFUSED {
s.refused = true
s.err = ErrStreamRefused
} else {
s.err = fmt.Errorf("stream reset by worker: %s (%s)", code, reason)
}
}
pending := s.drainUnreadLocked()
s.cond.Broadcast()
s.mu.Unlock()
s.conn.connConsumed(pending)
}
func (s *Stream) onCredit(increment uint32) {
s.mu.Lock()
s.sendCredit += int64(increment)
s.cond.Broadcast()
s.mu.Unlock()
}