feat(gateway): relay complete encoded frames

This commit is contained in:
sechmachine
2026-07-30 22:47:48 +07:00
parent f12ed6c685
commit 786c96b110
22 changed files with 1023 additions and 159 deletions
+105 -2
View File
@@ -28,7 +28,9 @@ import (
) )
const ( const (
nativeApolloVideoQueuePackets = 256 nativeApolloVideoQueuePackets = 16
nativeApolloVideoQueueBytes = 4 << 20
nativeApolloVideoQueueLatency = 250 * time.Millisecond
nativeApolloAudioQueuePackets = 16 nativeApolloAudioQueuePackets = 16
nativeApolloEventQueuePackets = 16 nativeApolloEventQueuePackets = 16
) )
@@ -316,6 +318,9 @@ type nativeApolloSession struct {
mediaRecovered atomic.Uint64 mediaRecovered atomic.Uint64
mediaEnqueued atomic.Uint64 mediaEnqueued atomic.Uint64
mediaQueueMaximum atomic.Uint64 mediaQueueMaximum atomic.Uint64
mediaQueueBytes atomic.Int64
mediaQueueMaximumBytes atomic.Uint64
mediaQueueSequence atomic.Uint64
} }
func newNativeApolloSession(sessionID string) *nativeApolloSession { func newNativeApolloSession(sessionID string) *nativeApolloSession {
@@ -762,10 +767,22 @@ func (s *nativeApolloSession) quiesceMedia() {
func (s *nativeApolloSession) closeMediaChannels() { func (s *nativeApolloSession) closeMediaChannels() {
s.channelsOnce.Do(func() { s.channelsOnce.Do(func() {
s.mediaQuiesced.Store(true)
s.mediaMu.Lock() s.mediaMu.Lock()
defer s.mediaMu.Unlock() defer s.mediaMu.Unlock()
for {
select {
case media := <-s.video:
if media.expiry != nil {
media.expiry.Stop()
}
media.releaseQueue()
default:
close(s.video) close(s.video)
close(s.audio) close(s.audio)
return
}
}
}) })
} }
@@ -775,9 +792,66 @@ func (s *nativeApolloSession) enqueueMedia(output chan ProviderMedia, payload []
if len(payload) == 0 || s.mediaQuiesced.Load() { if len(payload) == 0 || s.mediaQuiesced.Load() {
return false return false
} }
if output == s.video && len(payload) > maxCompleteFrameBytes {
s.mediaDrops.Add(1)
return false
}
s.mediaRecovered.Add(1) s.mediaRecovered.Add(1)
media := ProviderMedia{Payload: payload, ReceivedAt: receivedAt, EnqueuedAt: time.Now()} media := ProviderMedia{Payload: payload, ReceivedAt: receivedAt, EnqueuedAt: time.Now()}
if pushLatest(output, media) { if output == s.video {
dropped := uint64(0)
for s.mediaQueueBytes.Load()+int64(len(payload)) > nativeApolloVideoQueueBytes {
select {
case replaced := <-output:
if replaced.expiry != nil {
replaced.expiry.Stop()
}
replaced.releaseQueue()
dropped++
default:
s.mediaDrops.Add(dropped + 1)
return false
}
}
media.queueID = s.mediaQueueSequence.Add(1)
media.accounting = &providerMediaQueueAccounting{
bytes: int64(len(payload)), total: &s.mediaQueueBytes,
}
currentBytes := uint64(s.mediaQueueBytes.Add(int64(len(payload))))
media.expiry = time.AfterFunc(nativeApolloVideoQueueLatency, func() {
s.expireVideo(media.queueID)
media.releaseQueue()
})
for maximum := s.mediaQueueMaximumBytes.Load(); currentBytes > maximum && !s.mediaQueueMaximumBytes.CompareAndSwap(maximum, currentBytes); maximum = s.mediaQueueMaximumBytes.Load() {
}
if dropped > 0 {
s.mediaDrops.Add(dropped)
}
}
dropped := false
select {
case output <- media:
default:
select {
case replaced := <-output:
if replaced.expiry != nil {
replaced.expiry.Stop()
}
replaced.releaseQueue()
default:
}
select {
case output <- media:
dropped = true
default:
if media.expiry != nil {
media.expiry.Stop()
}
media.releaseQueue()
dropped = true
}
}
if dropped {
s.mediaDrops.Add(1) s.mediaDrops.Add(1)
} }
s.mediaEnqueued.Add(1) s.mediaEnqueued.Add(1)
@@ -787,6 +861,35 @@ func (s *nativeApolloSession) enqueueMedia(output chan ProviderMedia, payload []
return true return true
} }
func (s *nativeApolloSession) expireVideo(queueID uint64) {
s.mediaMu.Lock()
defer s.mediaMu.Unlock()
if s.mediaQuiesced.Load() {
return
}
retained := make([]ProviderMedia, 0, cap(s.video))
removed := false
for {
select {
case media := <-s.video:
if media.queueID == queueID {
removed = true
media.releaseQueue()
continue
}
retained = append(retained, media)
default:
for _, media := range retained {
s.video <- media
}
if removed {
s.mediaDrops.Add(1)
}
return
}
}
}
func (s *nativeApolloSession) readUDPMedia() { func (s *nativeApolloSession) readUDPMedia() {
if s.media == nil { if s.media == nil {
close(s.readDone) close(s.readDone)
+63 -13
View File
@@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"bytes"
"context" "context"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
@@ -531,19 +532,6 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
awaitControl(apolloControlTypeFEC, false) awaitControl(apolloControlTypeFEC, false)
remote := <-controlRemote
hostTermination := sourceSealHostControl(t, material.key, 0, apolloControlTypeTerm, []byte{1, 2, 3, 4})
if _, err := controlServer.WriteToUDP(sourceShapedENetReliablePacketOn(7, 2, apolloChannelGeneric, 1, hostTermination), remote); err != nil {
t.Fatal(err)
}
select {
case event := <-session.Events():
if event.Kind != ProviderEventTerminated || string(event.Payload) != string([]byte{1, 2, 3, 4}) {
t.Fatalf("provider termination event = %#v", event)
}
case <-time.After(time.Second):
t.Fatal("encrypted host termination was not forwarded")
}
select { select {
case media := <-session.Video(): case media := <-session.Video():
payload := media.Payload payload := media.Payload
@@ -562,6 +550,19 @@ func TestNativeApolloSetupRequiresModernEncryptedRTSPOrder(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("source-shaped audio was not relayed") t.Fatal("source-shaped audio was not relayed")
} }
remote := <-controlRemote
hostTermination := sourceSealHostControl(t, material.key, 0, apolloControlTypeTerm, []byte{1, 2, 3, 4})
if _, err := controlServer.WriteToUDP(sourceShapedENetReliablePacketOn(7, 2, apolloChannelGeneric, 1, hostTermination), remote); err != nil {
t.Fatal(err)
}
select {
case event := <-session.Events():
if event.Kind != ProviderEventTerminated || string(event.Payload) != string([]byte{1, 2, 3, 4}) {
t.Fatalf("provider termination event = %#v", event)
}
case <-time.After(time.Second):
t.Fatal("encrypted host termination was not forwarded")
}
terminateCtx, cancel := context.WithTimeout(context.Background(), time.Second) terminateCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel() defer cancel()
if err := session.Terminate(terminateCtx); err != nil { if err := session.Terminate(terminateCtx); err != nil {
@@ -899,6 +900,55 @@ func TestPushLatestDropsExactlyOneOldPayload(t *testing.T) {
} }
} }
func TestNativeProviderVideoQueueBoundsRealFrames(t *testing.T) {
const maximumQueuedVideoBytes = 4 << 20
session := newNativeApolloSession("bounded-video")
frame := bytes.Repeat([]byte{0x65}, 768<<10)
for index := 0; index < 12; index++ {
if !session.enqueueMedia(session.video, append([]byte(nil), frame...), time.Now()) {
t.Fatalf("frame %d was not accepted", index)
}
}
var queuedBytes int
for {
select {
case media := <-session.Video():
queuedBytes += len(media.Payload)
default:
if queuedBytes > maximumQueuedVideoBytes {
t.Fatalf("video queue retained %d bytes, limit %d", queuedBytes, maximumQueuedVideoBytes)
}
if drops := session.Telemetry().MediaDrops; drops != 7 {
t.Fatalf("latest-frame replacements = %d, want 7", drops)
}
if maximum := session.mediaQueueMaximum.Load(); maximum > nativeApolloVideoQueuePackets {
t.Fatalf("maximum video queue entries = %d", maximum)
}
if maximum := session.mediaQueueMaximumBytes.Load(); maximum > maximumQueuedVideoBytes {
t.Fatalf("maximum video queue bytes = %d", maximum)
}
return
}
}
}
func TestNativeProviderVideoQueueExpiresResidence(t *testing.T) {
session := newNativeApolloSession("expiring-video")
if !session.enqueueMedia(session.video, []byte("stale-frame"), time.Now()) {
t.Fatal("video frame was not accepted")
}
time.Sleep(nativeApolloVideoQueueLatency + 25*time.Millisecond)
select {
case media := <-session.Video():
t.Fatalf("expired video remained queued: %#v", media)
default:
}
if drops := session.Telemetry().MediaDrops; drops != 1 {
t.Fatalf("expired video drops = %d, want 1", drops)
}
}
func sourceShapedEncryptedVideoPacket(t *testing.T, key, encoded []byte) []byte { func sourceShapedEncryptedVideoPacket(t *testing.T, key, encoded []byte) []byte {
t.Helper() t.Helper()
payload := make([]byte, apolloVideoShardPayloadSize) payload := make([]byte, apolloVideoShardPayloadSize)
+1 -1
View File
@@ -11,7 +11,7 @@ var ErrNoCapabilityOverlap = errors.New("no capability overlap")
func DefaultCapabilities() protocol.CapabilityProfile { func DefaultCapabilities() protocol.CapabilityProfile {
return protocol.CapabilityProfile{ return protocol.CapabilityProfile{
Transport: "quic-tls13", Transport: "quic-tls13",
Framing: "datagram-v1", Framing: "datagram-v2",
Media: "encoded", Media: "encoded",
Audio: "encoded", Audio: "encoded",
SourceRateControl: "server", SourceRateControl: "server",
+94 -32
View File
@@ -7,9 +7,15 @@ import (
) )
const ( const (
frameHeaderSize = 21 frameV1HeaderSize = 21
frameV2HeaderSize = 23
frameV1PayloadSize = 1179
frameV2PayloadSize = 1177
maxV1FragmentCount = 16
maxV2FragmentCount = 891
maxCompleteFrameBytes = 1 << 20
maxFrameSize = 1 << 16 maxFrameSize = 1 << 16
maxFragmentCount = 16 frameHeaderSize = frameV2HeaderSize
) )
const ( const (
@@ -35,16 +41,25 @@ var (
) )
type Frame struct { type Frame struct {
Version byte
Channel byte Channel byte
Flags byte Flags byte
Sequence uint32 Sequence uint32
TimestampMS uint64 TimestampMS uint64
FragmentIndex byte FragmentIndex uint16
FragmentCount byte FragmentCount uint16
Payload []byte Payload []byte
} }
func channelLimit(channel byte) (int, bool) { func channelLimit(version, channel byte) (int, bool) {
if version == 2 {
switch channel {
case ChannelVideo, ChannelAudio:
return frameV2PayloadSize, true
default:
return 0, false
}
}
switch channel { switch channel {
case ChannelControl: case ChannelControl:
return 1024, true return 1024, true
@@ -53,93 +68,139 @@ func channelLimit(channel byte) (int, bool) {
case ChannelText: case ChannelText:
return 65515, true return 65515, true
case ChannelVideo, ChannelAudio, ChannelInput: case ChannelVideo, ChannelAudio, ChannelInput:
return 1179, true return frameV1PayloadSize, true
default: default:
return 0, false return 0, false
} }
} }
func EncodeFrame(frame Frame) ([]byte, error) { func EncodeFrame(frame Frame) ([]byte, error) {
limit, ok := channelLimit(frame.Channel) version := frame.Version
if version == 0 {
version = 1
}
if version != 1 && version != 2 {
return nil, ErrFrameVersion
}
limit, ok := channelLimit(version, frame.Channel)
if !ok { if !ok {
return nil, ErrFrameChannel return nil, ErrFrameChannel
} }
if frame.Flags != 0 { if frame.Flags != 0 {
return nil, ErrFrameFlags return nil, ErrFrameFlags
} }
if frame.FragmentCount == 0 || frame.FragmentCount > maxFragmentCount || frame.FragmentIndex >= frame.FragmentCount { maxFragments := uint16(maxV1FragmentCount)
headerSize := frameV1HeaderSize
if version == 2 {
maxFragments = maxV2FragmentCount
headerSize = frameV2HeaderSize
}
if frame.FragmentCount == 0 || frame.FragmentCount > maxFragments || frame.FragmentIndex >= frame.FragmentCount {
return nil, ErrFrameFragment return nil, ErrFrameFragment
} }
if len(frame.Payload) > limit { if len(frame.Payload) > limit {
return nil, ErrFramePayloadLimit return nil, ErrFramePayloadLimit
} }
if len(frame.Payload) > maxFrameSize-frameHeaderSize { if len(frame.Payload) > 1<<16-headerSize {
return nil, ErrFrameSize return nil, ErrFrameSize
} }
encoded := make([]byte, frameHeaderSize+len(frame.Payload)) encoded := make([]byte, headerSize+len(frame.Payload))
encoded[0], encoded[1], encoded[2], encoded[3], encoded[4] = 'V', 'D', 1, frame.Channel, frame.Flags encoded[0], encoded[1], encoded[2], encoded[3], encoded[4] = 'V', 'D', version, frame.Channel, frame.Flags
binary.BigEndian.PutUint32(encoded[5:9], frame.Sequence) binary.BigEndian.PutUint32(encoded[5:9], frame.Sequence)
binary.BigEndian.PutUint64(encoded[9:17], frame.TimestampMS) binary.BigEndian.PutUint64(encoded[9:17], frame.TimestampMS)
encoded[17], encoded[18] = frame.FragmentIndex, frame.FragmentCount if version == 1 {
encoded[17], encoded[18] = byte(frame.FragmentIndex), byte(frame.FragmentCount)
binary.BigEndian.PutUint16(encoded[19:21], uint16(len(frame.Payload))) binary.BigEndian.PutUint16(encoded[19:21], uint16(len(frame.Payload)))
copy(encoded[frameHeaderSize:], frame.Payload) } else {
binary.BigEndian.PutUint16(encoded[17:19], frame.FragmentIndex)
binary.BigEndian.PutUint16(encoded[19:21], frame.FragmentCount)
binary.BigEndian.PutUint16(encoded[21:23], uint16(len(frame.Payload)))
}
copy(encoded[headerSize:], frame.Payload)
return encoded, nil return encoded, nil
} }
func DecodeFrame(raw []byte) (Frame, error) { func DecodeFrame(raw []byte) (Frame, error) {
if len(raw) < frameHeaderSize { if len(raw) < 3 {
return Frame{}, ErrFrameTruncated return Frame{}, ErrFrameTruncated
} }
if len(raw) > maxFrameSize {
return Frame{}, ErrFrameSize
}
if raw[0] != 'V' || raw[1] != 'D' { if raw[0] != 'V' || raw[1] != 'D' {
return Frame{}, ErrFrameMagic return Frame{}, ErrFrameMagic
} }
if raw[2] != 1 { version := raw[2]
if version != 1 && version != 2 {
return Frame{}, ErrFrameVersion return Frame{}, ErrFrameVersion
} }
limit, ok := channelLimit(raw[3]) headerSize := frameV1HeaderSize
maxFragments := uint16(maxV1FragmentCount)
if version == 2 {
headerSize = frameV2HeaderSize
maxFragments = maxV2FragmentCount
}
if len(raw) < headerSize {
return Frame{}, ErrFrameTruncated
}
if version == 1 && len(raw) > 1<<16 || version == 2 && len(raw) > 1200 {
return Frame{}, ErrFrameSize
}
limit, ok := channelLimit(version, raw[3])
if !ok { if !ok {
return Frame{}, ErrFrameChannel return Frame{}, ErrFrameChannel
} }
if raw[4] != 0 { if raw[4] != 0 {
return Frame{}, ErrFrameFlags return Frame{}, ErrFrameFlags
} }
if raw[18] == 0 || raw[18] > maxFragmentCount || raw[17] >= raw[18] { var fragmentIndex, fragmentCount uint16
payloadOffset := 19
if version == 1 {
fragmentIndex, fragmentCount = uint16(raw[17]), uint16(raw[18])
} else {
fragmentIndex = binary.BigEndian.Uint16(raw[17:19])
fragmentCount = binary.BigEndian.Uint16(raw[19:21])
payloadOffset = 21
}
if fragmentCount == 0 || fragmentCount > maxFragments || fragmentIndex >= fragmentCount {
return Frame{}, ErrFrameFragment return Frame{}, ErrFrameFragment
} }
payloadLength := int(binary.BigEndian.Uint16(raw[19:21])) payloadLength := int(binary.BigEndian.Uint16(raw[payloadOffset : payloadOffset+2]))
if payloadLength > limit { if payloadLength > limit {
return Frame{}, ErrFramePayloadLimit return Frame{}, ErrFramePayloadLimit
} }
if len(raw) != frameHeaderSize+payloadLength { if len(raw) != headerSize+payloadLength {
return Frame{}, ErrFrameLength return Frame{}, ErrFrameLength
} }
return Frame{ return Frame{
Version: version,
Channel: raw[3], Channel: raw[3],
Flags: raw[4], Flags: raw[4],
Sequence: binary.BigEndian.Uint32(raw[5:9]), Sequence: binary.BigEndian.Uint32(raw[5:9]),
TimestampMS: binary.BigEndian.Uint64(raw[9:17]), TimestampMS: binary.BigEndian.Uint64(raw[9:17]),
FragmentIndex: raw[17], FragmentIndex: fragmentIndex,
FragmentCount: raw[18], FragmentCount: fragmentCount,
Payload: append([]byte(nil), raw[frameHeaderSize:]...), Payload: append([]byte(nil), raw[headerSize:]...),
}, nil }, nil
} }
func FragmentPayload(channel byte, sequence uint32, timestampMS uint64, payload []byte) ([]Frame, error) { func FragmentPayload(channel byte, sequence uint32, timestampMS uint64, payload []byte) ([]Frame, error) {
limit, ok := channelLimit(channel) version := byte(1)
if !ok { limit := frameV1PayloadSize
maxFragments := maxV1FragmentCount
if channel == ChannelVideo || channel == ChannelAudio {
version = 2
limit = frameV2PayloadSize
maxFragments = maxV2FragmentCount
}
if _, ok := channelLimit(version, channel); !ok {
return nil, ErrFrameChannel return nil, ErrFrameChannel
} }
if limit > 1179 { if len(payload) > maxCompleteFrameBytes {
limit = 1179 return nil, ErrFrameFragmentedLimit
} }
count := (len(payload) + limit - 1) / limit count := (len(payload) + limit - 1) / limit
if count == 0 { if count == 0 {
count = 1 count = 1
} }
if count > maxFragmentCount { if count > maxFragments {
return nil, ErrFrameFragmentedLimit return nil, ErrFrameFragmentedLimit
} }
frames := make([]Frame, 0, count) frames := make([]Frame, 0, count)
@@ -150,11 +211,12 @@ func FragmentPayload(channel byte, sequence uint32, timestampMS uint64, payload
end = len(payload) end = len(payload)
} }
frames = append(frames, Frame{ frames = append(frames, Frame{
Version: version,
Channel: channel, Channel: channel,
Sequence: sequence, Sequence: sequence,
TimestampMS: timestampMS, TimestampMS: timestampMS,
FragmentIndex: byte(index), FragmentIndex: uint16(index),
FragmentCount: byte(count), FragmentCount: uint16(count),
Payload: append([]byte(nil), payload[start:end]...), Payload: append([]byte(nil), payload[start:end]...),
}) })
} }
+271 -9
View File
@@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"bytes"
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
@@ -32,7 +33,7 @@ const protocolTerminalReceiptVector = "VGF1\x00\x03\x00\x00"
func TestFrameValidationAndFragmentation(t *testing.T) { func TestFrameValidationAndFragmentation(t *testing.T) {
frames, err := FragmentPayload(ChannelVideo, 7, 11, make([]byte, 1180)) frames, err := FragmentPayload(ChannelVideo, 7, 11, make([]byte, 1180))
if err != nil || len(frames) != 2 || len(frames[0].Payload) != 1179 || len(frames[1].Payload) != 1 { if err != nil || len(frames) != 2 || len(frames[0].Payload) != 1177 || len(frames[1].Payload) != 3 {
t.Fatalf("fragmentation = %#v, err = %v", frames, err) t.Fatalf("fragmentation = %#v, err = %v", frames, err)
} }
encoded, err := EncodeFrame(frames[0]) encoded, err := EncodeFrame(frames[0])
@@ -161,11 +162,11 @@ func TestNewServerRejectsPartiallyConfiguredCapabilities(t *testing.T) {
} }
func TestSyntheticImpairmentPacingAndResourceBounds(t *testing.T) { func TestSyntheticImpairmentPacingAndResourceBounds(t *testing.T) {
payload := make([]byte, 1179*16+1) payload := make([]byte, maxCompleteFrameBytes+1)
if _, err := FragmentPayload(ChannelVideo, 1, 0, payload); !errors.Is(err, ErrFrameFragmentedLimit) { if _, err := FragmentPayload(ChannelVideo, 1, 0, payload); !errors.Is(err, ErrFrameFragmentedLimit) {
t.Fatalf("oversized media payload accepted: %v", err) t.Fatalf("oversized media payload accepted: %v", err)
} }
frames, err := FragmentPayload(ChannelVideo, 1, 0, bytesRepeat(0x5a, 1179*4)) frames, err := FragmentPayload(ChannelVideo, 1, 0, bytesRepeat(0x5a, frameV2PayloadSize*4))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -186,11 +187,29 @@ func TestSyntheticImpairmentPacingAndResourceBounds(t *testing.T) {
} }
deliveredBytes += len(decoded.Payload) deliveredBytes += len(decoded.Payload)
} }
if delivered != 3 || deliveredBytes != 1179*3 { if delivered != 3 || deliveredBytes != frameV2PayloadSize*3 {
t.Fatalf("synthetic impairment delivered=%d bytes=%d", delivered, deliveredBytes) t.Fatalf("synthetic impairment delivered=%d bytes=%d", delivered, deliveredBytes)
} }
} }
func TestFragmentPayloadCarriesCompleteEncodedFrame(t *testing.T) {
payload := bytesRepeat(0x5a, 256*1024)
frames, err := FragmentPayload(ChannelVideo, 9, 11, payload)
if errors.Is(err, ErrFrameFragmentedLimit) {
t.Fatalf("complete encoded frame rejected at legacy fragment ceiling: %v", err)
}
if err != nil {
t.Fatal(err)
}
var recovered []byte
for _, frame := range frames {
recovered = append(recovered, frame.Payload...)
}
if !bytes.Equal(recovered, payload) {
t.Fatal("complete encoded frame payload changed")
}
}
func TestApolloFixturesAndLifecycle(t *testing.T) { func TestApolloFixturesAndLifecycle(t *testing.T) {
management, err := os.ReadFile("testdata/apollo-management.xml") management, err := os.ReadFile("testdata/apollo-management.xml")
if err != nil { if err != nil {
@@ -218,10 +237,10 @@ func TestApolloFixturesAndLifecycle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if got := <-session.Video(); string(got.Payload) != string(video) { if got := <-session.Video(); string(got.Payload) != string(video) {
t.Fatalf("video changed: %x", got) t.Fatalf("video changed: %x", got.Payload)
} }
if got := <-session.Audio(); string(got.Payload) != string(audio) { if got := <-session.Audio(); string(got.Payload) != string(audio) {
t.Fatalf("audio changed: %x", got) t.Fatalf("audio changed: %x", got.Payload)
} }
if err := session.Input(context.Background(), InputEvent{Sequence: 1, Device: "keyboard", Code: 7, Pressed: true}); err != nil { if err := session.Input(context.Background(), InputEvent{Sequence: 1, Device: "keyboard", Code: 7, Pressed: true}); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -401,7 +420,7 @@ func TestGatewayTelemetrySeparatesQueueProcessingAndPacing(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
receiveCtx, receiveCancel := context.WithTimeout(context.Background(), 2*time.Second) receiveCtx, receiveCancel := context.WithTimeout(context.Background(), 2*time.Second)
for index := byte(0); index < 2; index++ { for index := uint16(0); index < 2; index++ {
frame, err := client.ReceiveFrame(receiveCtx) frame, err := client.ReceiveFrame(receiveCtx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -838,6 +857,7 @@ func newNativeGatewayLifecycleHarness(t *testing.T, sessionID string) nativeGate
type independentGatewayClient struct { type independentGatewayClient struct {
connection *quic.Conn connection *quic.Conn
control *quic.Stream control *quic.Stream
media independentMediaReassembler
} }
func dialIndependentGateway(ctx context.Context, address string, tlsConfig *tls.Config, request protocol.TunnelAdmissionRequest) (*independentGatewayClient, error) { func dialIndependentGateway(ctx context.Context, address string, tlsConfig *tls.Config, request protocol.TunnelAdmissionRequest) (*independentGatewayClient, error) {
@@ -866,7 +886,11 @@ func dialIndependentGateway(ctx context.Context, address string, tlsConfig *tls.
_ = connection.CloseWithError(applicationError, "independent client admission failed") _ = connection.CloseWithError(applicationError, "independent client admission failed")
return nil, err return nil, err
} }
return &independentGatewayClient{connection: connection, control: stream}, nil return &independentGatewayClient{
connection: connection,
control: stream,
media: independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)},
}, nil
} }
func (c *independentGatewayClient) ReceiveProviderEvent(ctx context.Context) (ProviderEvent, error) { func (c *independentGatewayClient) ReceiveProviderEvent(ctx context.Context) (ProviderEvent, error) {
@@ -912,7 +936,245 @@ func (c *independentGatewayClient) ReceiveFrame(ctx context.Context) (Frame, err
if err != nil { if err != nil {
return Frame{}, err return Frame{}, err
} }
return DecodeFrame(data) return independentDecodeFrame(data)
}
func (c *independentGatewayClient) ReceiveMedia(ctx context.Context) ([]byte, error) {
for {
data, err := c.connection.ReceiveDatagram(ctx)
if err != nil {
return nil, err
}
payload, complete, err := c.media.Add(data, time.Now())
if err != nil {
return nil, err
}
if complete {
return payload, nil
}
}
}
type independentMediaKey struct {
channel byte
sequence uint32
}
type independentMediaUnit struct {
started time.Time
timestamp uint64
fragments [][]byte
received []bool
bytes int
}
type independentMediaReassembler struct {
incomplete map[independentMediaKey]*independentMediaUnit
}
func independentDecodeFrame(data []byte) (Frame, error) {
const (
v1Header, v2Header = 21, 23
v1Payload, v2Payload = 1179, 1177
)
if len(data) < 3 {
return Frame{}, ErrFrameTruncated
}
if data[0] != 'V' || data[1] != 'D' {
return Frame{}, ErrFrameMagic
}
version := data[2]
headerSize, payloadLimit := v1Header, v1Payload
if version == 2 {
headerSize, payloadLimit = v2Header, v2Payload
} else if version != 1 {
return Frame{}, ErrFrameVersion
}
if len(data) < headerSize || version == 2 && len(data) > 1200 {
return Frame{}, ErrFrameSize
}
frame := Frame{
Version: version,
Channel: data[3],
Flags: data[4],
Sequence: binary.BigEndian.Uint32(data[5:9]),
TimestampMS: binary.BigEndian.Uint64(data[9:17]),
}
payloadLengthOffset := 19
if version == 1 {
frame.FragmentIndex = uint16(data[17])
frame.FragmentCount = uint16(data[18])
} else {
frame.FragmentIndex = binary.BigEndian.Uint16(data[17:19])
frame.FragmentCount = binary.BigEndian.Uint16(data[19:21])
payloadLengthOffset = 21
}
if (frame.Channel != ChannelVideo && frame.Channel != ChannelAudio) || frame.Flags != 0 ||
frame.FragmentCount == 0 || version == 1 && frame.FragmentCount > 16 ||
version == 2 && frame.FragmentCount > 891 || frame.FragmentIndex >= frame.FragmentCount {
return Frame{}, ErrFrameFragment
}
payloadLength := int(binary.BigEndian.Uint16(data[payloadLengthOffset : payloadLengthOffset+2]))
if payloadLength > payloadLimit || len(data) != headerSize+payloadLength {
return Frame{}, ErrFrameLength
}
frame.Payload = append([]byte(nil), data[headerSize:]...)
return frame, nil
}
func (r *independentMediaReassembler) Add(data []byte, now time.Time) ([]byte, bool, error) {
frame, err := independentDecodeFrame(data)
if err != nil {
return nil, false, err
}
for key, unit := range r.incomplete {
if now.Sub(unit.started) > 250*time.Millisecond {
delete(r.incomplete, key)
}
}
key := independentMediaKey{channel: frame.Channel, sequence: frame.Sequence}
unit := r.incomplete[key]
if unit == nil {
if len(r.incomplete) == 4 {
var oldestKey independentMediaKey
var oldest time.Time
for candidate, current := range r.incomplete {
if oldest.IsZero() || current.started.Before(oldest) {
oldestKey, oldest = candidate, current.started
}
}
delete(r.incomplete, oldestKey)
}
unit = &independentMediaUnit{
started: now, timestamp: frame.TimestampMS,
fragments: make([][]byte, frame.FragmentCount), received: make([]bool, frame.FragmentCount),
}
r.incomplete[key] = unit
}
if len(unit.fragments) != int(frame.FragmentCount) || unit.timestamp != frame.TimestampMS {
return nil, false, ErrFrameFragment
}
index := int(frame.FragmentIndex)
if unit.received[index] {
if !bytes.Equal(unit.fragments[index], frame.Payload) {
return nil, false, ErrFrameFragment
}
return nil, false, nil
}
if unit.bytes+len(frame.Payload) > 1<<20 {
delete(r.incomplete, key)
return nil, false, ErrFrameSize
}
unit.fragments[index] = frame.Payload
unit.received[index] = true
unit.bytes += len(frame.Payload)
for _, received := range unit.received {
if !received {
return nil, false, nil
}
}
payload := make([]byte, 0, unit.bytes)
for _, fragment := range unit.fragments {
payload = append(payload, fragment...)
}
delete(r.incomplete, key)
return payload, true, nil
}
func TestIndependentClientReassemblesProtocolDatagramV2(t *testing.T) {
fixed, err := hex.DecodeString("5644020a00000000010000000000000002000000010003010203")
if err != nil {
t.Fatal(err)
}
frame, err := independentDecodeFrame(fixed)
if err != nil || frame.Version != 2 || frame.Channel != ChannelVideo ||
frame.Sequence != 1 || frame.TimestampMS != 2 || frame.FragmentCount != 1 ||
!bytes.Equal(frame.Payload, []byte{1, 2, 3}) {
t.Fatalf("fixed Protocol v2 frame = %#v, %v", frame, err)
}
payload := bytes.Repeat([]byte("frame-boundary-"), 300)
fragments, err := FragmentPayload(ChannelVideo, 7, 11, payload)
if err != nil || len(fragments) < 3 {
t.Fatalf("fragments = %d, %v", len(fragments), err)
}
encoded := make([][]byte, len(fragments))
for index, fragment := range fragments {
encoded[index], err = EncodeFrame(fragment)
if err != nil {
t.Fatal(err)
}
}
reassembler := independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)}
now := time.Unix(0, 0)
order := []int{2, 0, 0, 1, 3}
var recovered []byte
for _, index := range order {
var complete bool
recovered, complete, err = reassembler.Add(encoded[index], now)
if err != nil {
t.Fatal(err)
}
if index != 3 && complete {
t.Fatalf("unit completed at fragment %d", index)
}
}
if !bytes.Equal(recovered, payload) {
t.Fatal("independent client changed the complete encoded frame")
}
if _, _, err := reassembler.Add(encoded[0], now); err != nil {
t.Fatal(err)
}
conflict := append([]byte(nil), encoded[0]...)
conflict[len(conflict)-1] ^= 0xff
if _, _, err := reassembler.Add(conflict, now); !errors.Is(err, ErrFrameFragment) {
t.Fatalf("conflicting duplicate = %v", err)
}
reassembler = independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)}
if _, _, err := reassembler.Add(encoded[0], now); err != nil {
t.Fatal(err)
}
expired := append([]byte(nil), encoded[0]...)
binary.BigEndian.PutUint32(expired[5:9], 8)
if _, _, err := reassembler.Add(expired, now.Add(251*time.Millisecond)); err != nil {
t.Fatal(err)
}
if len(reassembler.incomplete) != 1 {
t.Fatalf("expired incomplete units = %d, want 1", len(reassembler.incomplete))
}
reassembler = independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)}
for sequence := uint32(1); sequence <= 5; sequence++ {
partial, encodeErr := EncodeFrame(Frame{
Version: 2, Channel: ChannelVideo, Sequence: sequence, TimestampMS: 1,
FragmentIndex: 0, FragmentCount: 2, Payload: []byte{byte(sequence)},
})
if encodeErr != nil {
t.Fatal(encodeErr)
}
if _, _, err := reassembler.Add(partial, now.Add(time.Duration(sequence)*time.Nanosecond)); err != nil {
t.Fatal(err)
}
}
if len(reassembler.incomplete) != 4 || reassembler.incomplete[independentMediaKey{channel: ChannelVideo, sequence: 1}] != nil {
t.Fatalf("fifth unit did not evict the oldest: %#v", reassembler.incomplete)
}
reassembler = independentMediaReassembler{incomplete: make(map[independentMediaKey]*independentMediaUnit)}
for index := uint16(0); index < 891; index++ {
fragment, encodeErr := EncodeFrame(Frame{
Version: 2, Channel: ChannelVideo, Sequence: 99, TimestampMS: 1,
FragmentIndex: index, FragmentCount: 891, Payload: make([]byte, 1177),
})
if encodeErr != nil {
t.Fatal(encodeErr)
}
_, _, err = reassembler.Add(fragment, now)
}
if !errors.Is(err, ErrFrameSize) || len(reassembler.incomplete) != 0 {
t.Fatalf("oversized reassembly = %v, incomplete=%d", err, len(reassembler.incomplete))
}
} }
func (c *independentGatewayClient) waitClosed(t *testing.T) { func (c *independentGatewayClient) waitClosed(t *testing.T) {
+16
View File
@@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol" protocol "git.sechmachine.io.vn/sechmachine/VerseVDI-Protocol/gen/go/protocol"
@@ -234,6 +235,21 @@ type ProviderMedia struct {
Payload []byte Payload []byte
ReceivedAt time.Time ReceivedAt time.Time
EnqueuedAt time.Time EnqueuedAt time.Time
queueID uint64
expiry *time.Timer
accounting *providerMediaQueueAccounting
}
type providerMediaQueueAccounting struct {
released atomic.Bool
bytes int64
total *atomic.Int64
}
func (media ProviderMedia) releaseQueue() {
if media.accounting != nil && media.accounting.released.CompareAndSwap(false, true) {
media.accounting.total.Add(-media.accounting.bytes)
}
} }
type Provider interface { type Provider interface {
+47 -4
View File
@@ -22,9 +22,9 @@ func TestQualificationCatalogMatchesSection7(t *testing.T) {
t.Fatalf("media profile count = %d, want 3", len(media)) t.Fatalf("media profile count = %d, want 3", len(media))
} }
wantMedia := []qualificationMediaProfile{ wantMedia := []qualificationMediaProfile{
{Name: "1080p60-h264", Codec: "h264", BitrateKbps: 20000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "1080p60-h264", Codec: "h264", BitrateKbps: 20000, FPS: 60, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
{Name: "1440p120-hevc", Codec: "hevc", BitrateKbps: 50000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "1440p120-hevc", Codec: "hevc", BitrateKbps: 50000, FPS: 120, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
{Name: "4k60-hevc", Codec: "hevc", BitrateKbps: 80000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "4k60-hevc", Codec: "hevc", BitrateKbps: 80000, FPS: 60, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
} }
if !reflect.DeepEqual(media, wantMedia) { if !reflect.DeepEqual(media, wantMedia) {
t.Fatalf("media profiles = %#v, want %#v", media, wantMedia) t.Fatalf("media profiles = %#v, want %#v", media, wantMedia)
@@ -145,6 +145,14 @@ func TestQualificationShortProcessingSubprocessCoversFixedProfiles(t *testing.T)
} }
if summary.Count < 1 || summary.CPUScope != qualificationGatewayCPUScope || if summary.Count < 1 || summary.CPUScope != qualificationGatewayCPUScope ||
summary.ClockOverhead <= 0 || summary.ClockMethod != qualificationClockOverheadMethod || summary.ClockOverhead <= 0 || summary.ClockMethod != qualificationClockOverheadMethod ||
summary.Count != int64(profile.FPS) ||
summary.ConfiguredFPS != profile.FPS ||
summary.ObservedFPS < float64(profile.FPS)*0.95 ||
summary.ObservedFPS > float64(profile.FPS)*1.05 ||
summary.PayloadBytes != profile.BitrateKbps*1000/8 ||
summary.MaximumFrameBytes <= summary.MinimumFrameBytes ||
summary.MaximumFrameBytes <= 18_864 ||
summary.PayloadSHA256 == "" ||
summary.ObservedBitrateKbps < float64(profile.BitrateKbps)*0.95 || summary.ObservedBitrateKbps < float64(profile.BitrateKbps)*0.95 ||
summary.ObservedBitrateKbps > float64(profile.BitrateKbps)*1.05 { summary.ObservedBitrateKbps > float64(profile.BitrateKbps)*1.05 {
t.Fatalf("%s subprocess summary = %#v", profile.Name, summary) t.Fatalf("%s subprocess summary = %#v", profile.Name, summary)
@@ -152,6 +160,19 @@ func TestQualificationShortProcessingSubprocessCoversFixedProfiles(t *testing.T)
} }
} }
func TestQualificationShortProcessingUsesCompleteFrameCadence(t *testing.T) {
profile := qualificationMediaProfiles()[0]
profile.Duration = 100 * time.Millisecond
profile.Warmup = time.Millisecond
summary, err := runQualificationProcessing(t, profile, filepath.Join(t.TempDir(), "frames.csv.gz"))
if err != nil {
t.Fatal(err)
}
if summary.Count != 6 {
t.Fatalf("100 ms of 1080p60 processed %d units, want 6 complete encoded frames", summary.Count)
}
}
func TestQualificationSustainedProcessingKeepsCleanPathBounded(t *testing.T) { func TestQualificationSustainedProcessingKeepsCleanPathBounded(t *testing.T) {
profile := qualificationMediaProfiles()[2] profile := qualificationMediaProfiles()[2]
profile.Duration = 2 * time.Minute profile.Duration = 2 * time.Minute
@@ -400,7 +421,7 @@ func TestQualificationGatewaySubprocessResourcesResetAndTrackWork(t *testing.T)
Name: "resource-process", Codec: "h264", BitrateKbps: 100000, Name: "resource-process", Codec: "h264", BitrateKbps: 100000,
Duration: time.Second, Warmup: time.Millisecond, PacketBytes: 1000, Duration: time.Second, Warmup: time.Millisecond, PacketBytes: 1000,
} }
path := newQualificationProcessingPath(t, profile, qualificationMediaPacerKbps(profile, 0)) path := newQualificationProcessingPath(t, profile, qualificationFramePacerKbps(profile))
defer path.Close() defer path.Close()
output := t.TempDir() output := t.TempDir()
record := func(name string, work func() error) qualificationProcessRecordResult { record := func(name string, work func() error) qualificationProcessRecordResult {
@@ -552,3 +573,25 @@ func TestQualificationSmokeTraversesNativeApolloRecoveryQueuePacerAndQUIC(t *tes
t.Fatalf("qualification production-path trace = %#v", trace) t.Fatalf("qualification production-path trace = %#v", trace)
} }
} }
func TestQualificationCarriesCompleteLargeFramesThroughPublicPath(t *testing.T) {
profile := qualificationMediaProfiles()[2]
path := newQualificationPath(t, profile, 200000)
defer path.Close()
for _, size := range []int{24 * 1024, 96 * 1024, 384 * 1024} {
payload := make([]byte, size)
for index := range payload {
payload[index] = byte(index*31 + size)
}
trace, _, err := path.traverse(t, payload)
if err != nil {
t.Fatalf("frame bytes=%d: %v", size, err)
}
if !trace.NativeUDPIngress || !trace.ApolloRecovered || !trace.ProductionQueue ||
!trace.ProductionMediaLoop || !trace.ProductionPacer || !trace.VerseQUIC ||
!trace.PublicClientDecode || !trace.PayloadPreserved {
t.Fatalf("frame bytes=%d skipped path: %#v", size, trace)
}
}
}
+149 -80
View File
@@ -40,7 +40,7 @@ import (
const ( const (
qualificationToolVersion = "versevdi-gateway-qualification/v6" qualificationToolVersion = "versevdi-gateway-qualification/v6"
qualificationImpairmentQueuePackets = 256 qualificationImpairmentQueuePackets = nativeApolloVideoQueuePackets
qualificationImpairmentMaxPackets = 100_000 qualificationImpairmentMaxPackets = 100_000
qualificationImpairmentPacketCount = 10_000 qualificationImpairmentPacketCount = 10_000
qualificationProcessingLimit = 5 * time.Millisecond qualificationProcessingLimit = 5 * time.Millisecond
@@ -54,6 +54,7 @@ type qualificationMediaProfile struct {
Name string Name string
Codec string Codec string
BitrateKbps int64 BitrateKbps int64
FPS int
Duration time.Duration Duration time.Duration
Warmup time.Duration Warmup time.Duration
PacketBytes int PacketBytes int
@@ -73,7 +74,7 @@ type qualificationPathTrace struct {
} }
type qualificationPath struct { type qualificationPath struct {
client *Client client *independentGatewayClient
server *Server server *Server
session *nativeApolloSession session *nativeApolloSession
fixture *qualificationApolloFixture fixture *qualificationApolloFixture
@@ -102,6 +103,11 @@ type qualificationProcessingSummary struct {
Codec string `json:"codec"` Codec string `json:"codec"`
ConfiguredBitrateKbps int64 `json:"configured_bitrate_kbps"` ConfiguredBitrateKbps int64 `json:"configured_bitrate_kbps"`
ObservedBitrateKbps float64 `json:"observed_bitrate_kbps"` ObservedBitrateKbps float64 `json:"observed_bitrate_kbps"`
ConfiguredFPS int `json:"configured_fps"`
ObservedFPS float64 `json:"observed_fps"`
PayloadBytes int64 `json:"payload_bytes"`
MinimumFrameBytes int `json:"minimum_frame_bytes"`
MaximumFrameBytes int `json:"maximum_frame_bytes"`
Warmup time.Duration `json:"warmup_ns"` Warmup time.Duration `json:"warmup_ns"`
ConfiguredDuration time.Duration `json:"configured_duration_ns"` ConfiguredDuration time.Duration `json:"configured_duration_ns"`
ActualDuration time.Duration `json:"actual_duration_ns"` ActualDuration time.Duration `json:"actual_duration_ns"`
@@ -154,6 +160,7 @@ type qualificationImpairmentObservation struct {
GatewayForwarded int `json:"gateway_forwarded"` GatewayForwarded int `json:"gateway_forwarded"`
GatewayDropped int `json:"gateway_dropped"` GatewayDropped int `json:"gateway_dropped"`
QUICSent int `json:"quic_sent"` QUICSent int `json:"quic_sent"`
QUICDatagramsSent int `json:"quic_datagrams_sent"`
QUICSendDropped int `json:"quic_send_dropped"` QUICSendDropped int `json:"quic_send_dropped"`
ClientDeliveryDropped int `json:"client_delivery_dropped"` ClientDeliveryDropped int `json:"client_delivery_dropped"`
UnexplainedDropped int `json:"unexplained_dropped"` UnexplainedDropped int `json:"unexplained_dropped"`
@@ -241,9 +248,9 @@ type qualificationManifest struct {
func qualificationMediaProfiles() []qualificationMediaProfile { func qualificationMediaProfiles() []qualificationMediaProfile {
return []qualificationMediaProfile{ return []qualificationMediaProfile{
{Name: "1080p60-h264", Codec: "h264", BitrateKbps: 20000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "1080p60-h264", Codec: "h264", BitrateKbps: 20000, FPS: 60, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
{Name: "1440p120-hevc", Codec: "hevc", BitrateKbps: 50000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "1440p120-hevc", Codec: "hevc", BitrateKbps: 50000, FPS: 120, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
{Name: "4k60-hevc", Codec: "hevc", BitrateKbps: 80000, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179}, {Name: "4k60-hevc", Codec: "hevc", BitrateKbps: 80000, FPS: 60, Duration: 10 * time.Minute, Warmup: time.Second, PacketBytes: 1179},
} }
} }
@@ -278,9 +285,65 @@ func qualificationPayload(profile qualificationMediaProfile) []byte {
return payload return payload
} }
func qualificationFrameRate(profile qualificationMediaProfile) int {
if profile.FPS > 0 {
return profile.FPS
}
return 60
}
func qualificationFrameSize(profile qualificationMediaProfile, index int64) int {
fps := qualificationFrameRate(profile)
bytesPerSecond := int(profile.BitrateKbps * 1000 / 8)
position := int(index % int64(fps))
if fps == 1 {
return min(bytesPerSecond, maxCompleteFrameBytes)
}
keyframeBytes := min(bytesPerSecond/fps*4, maxCompleteFrameBytes)
if position == 0 {
return keyframeBytes
}
remaining := bytesPerSecond - keyframeBytes
size := remaining / (fps - 1)
if position <= remaining%(fps-1) {
size++
}
return size
}
func qualificationFramePayload(profile qualificationMediaProfile, index int64) []byte {
payload := make([]byte, qualificationFrameSize(profile, index))
if profile.Codec == "h264" {
copy(payload, []byte{0, 0, 1, 0x65})
} else {
copy(payload, []byte{0, 0, 1, 0x26})
}
for offset := 4; offset < len(payload); offset++ {
payload[offset] = byte(int64(offset)*31 + index*17 + int64(len(profile.Name)))
}
if len(payload) >= 8 {
binary.BigEndian.PutUint64(payload[len(payload)-8:], uint64(index))
}
return payload
}
func qualificationMediaPacerKbps(profile qualificationMediaProfile, reduction int) int64 { func qualificationMediaPacerKbps(profile qualificationMediaProfile, reduction int) int64 {
payloadKbps := profile.BitrateKbps * int64(100-reduction) / 100 payloadKbps := profile.BitrateKbps * int64(100-reduction) / 100
return (payloadKbps*int64(profile.PacketBytes+frameHeaderSize) + int64(profile.PacketBytes) - 1) / int64(profile.PacketBytes) datagrams := (profile.PacketBytes + frameV2PayloadSize - 1) / frameV2PayloadSize
wireBytes := profile.PacketBytes + datagrams*frameV2HeaderSize
return (payloadKbps*int64(wireBytes) + int64(profile.PacketBytes) - 1) / int64(profile.PacketBytes)
}
func qualificationFramePacerKbps(profile qualificationMediaProfile) int64 {
fps := qualificationFrameRate(profile)
var payloadBytes, wireBytes int64
for index := range fps {
size := qualificationFrameSize(profile, int64(index))
datagrams := (size + frameV2PayloadSize - 1) / frameV2PayloadSize
payloadBytes += int64(size)
wireBytes += int64(size + datagrams*frameV2HeaderSize)
}
return (profile.BitrateKbps*wireBytes + payloadBytes - 1) / payloadBytes
} }
func qualificationBoundedRelease(target, next, now time.Time, spacing time.Duration) time.Time { func qualificationBoundedRelease(target, next, now time.Time, spacing time.Duration) time.Time {
@@ -767,7 +830,7 @@ type qualificationFleet struct {
t *testing.T t *testing.T
server *Server server *Server
paths []*qualificationPath paths []*qualificationPath
clients []*Client clients []*independentGatewayClient
cancel context.CancelFunc cancel context.CancelFunc
serveDone chan error serveDone chan error
closeOnce sync.Once closeOnce sync.Once
@@ -812,7 +875,7 @@ func newQualificationFleet(t *testing.T, count int, profile qualificationMediaPr
Grant: strings.Repeat(string(rune('a'+index)), 64), ClientNonce: fmt.Sprintf("nonce-fleet-%06d", index), Grant: strings.Repeat(string(rune('a'+index)), 64), ClientNonce: fmt.Sprintf("nonce-fleet-%06d", index),
DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities(), DeviceSignature: strings.Repeat("s", 86), Capabilities: DefaultCapabilities(),
} }
client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request) client, err := dialIndependentGateway(context.Background(), server.Addr().String(), clientTLS, request)
if err != nil { if err != nil {
fleet.Close() fleet.Close()
t.Fatal(err) t.Fatal(err)
@@ -894,7 +957,7 @@ func newQualificationPathWithImpairment(t *testing.T, profile qualificationMedia
ClientNonce: "nonce-qualification", DeviceSignature: strings.Repeat("s", 86), ClientNonce: "nonce-qualification", DeviceSignature: strings.Repeat("s", 86),
Capabilities: DefaultCapabilities(), Capabilities: DefaultCapabilities(),
} }
client, err := Dial(context.Background(), server.Addr().String(), clientTLS, request) client, err := dialIndependentGateway(context.Background(), server.Addr().String(), clientTLS, request)
if err != nil { if err != nil {
cancel() cancel()
_ = server.Close() _ = server.Close()
@@ -941,7 +1004,7 @@ func newQualificationProcessingPath(t *testing.T, profile qualificationMediaProf
ClientNonce: "nonce-qualification", DeviceSignature: strings.Repeat("s", 86), ClientNonce: "nonce-qualification", DeviceSignature: strings.Repeat("s", 86),
Capabilities: DefaultCapabilities(), Capabilities: DefaultCapabilities(),
} }
client, err := Dial(context.Background(), process.ready.GatewayAddress, clientTLS, request) client, err := dialIndependentGateway(context.Background(), process.ready.GatewayAddress, clientTLS, request)
if err != nil { if err != nil {
process.Close() process.Close()
t.Fatal(err) t.Fatal(err)
@@ -1007,7 +1070,7 @@ func (p *qualificationPath) traverse(t *testing.T, payload []byte) (qualificatio
func (p *qualificationPath) emit(t *testing.T, payload []byte) (qualificationPathTrace, error) { func (p *qualificationPath) emit(t *testing.T, payload []byte) (qualificationPathTrace, error) {
t.Helper() t.Helper()
if p == nil || p.fixture == nil || len(payload) == 0 || len(payload) > 2*apolloVideoShardPayloadSize-8 { if p == nil || p.fixture == nil || len(payload) == 0 || len(payload) > apolloVideoMaximumBlocks*apolloVideoMaximumDataShards*apolloVideoShardPayloadSize-8 {
return qualificationPathTrace{}, ErrProviderMalformed return qualificationPathTrace{}, ErrProviderMalformed
} }
p.frame++ p.frame++
@@ -1024,60 +1087,50 @@ func (p *qualificationPath) emit(t *testing.T, payload []byte) (qualificationPat
func (p *qualificationPath) receivePayload(parent context.Context) ([]byte, error) { func (p *qualificationPath) receivePayload(parent context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(parent, 2*time.Second) ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() defer cancel()
var recovered []byte return p.client.ReceiveMedia(ctx)
var sequence uint32
var fragmentCount byte
for {
frame, err := p.client.ReceiveFrame(ctx)
if err != nil {
return nil, err
}
if frame.Channel != ChannelVideo {
continue
}
if fragmentCount == 0 {
sequence, fragmentCount = frame.Sequence, frame.FragmentCount
}
if frame.Sequence != sequence || frame.FragmentIndex != byte(len(recovered)/1179) {
return nil, errors.New("qualification QUIC fragments reordered")
}
recovered = append(recovered, frame.Payload...)
if frame.FragmentIndex+1 == fragmentCount {
break
}
}
return recovered, nil
} }
func qualificationSourceVideoPackets(t *testing.T, key []byte, frame uint32, encoded []byte) [][]byte { func qualificationSourceVideoPackets(t *testing.T, key []byte, frame uint32, encoded []byte) [][]byte {
t.Helper() t.Helper()
if len(encoded) <= apolloVideoShardPayloadSize-8 { total := 8 + len(encoded)
payload := make([]byte, apolloVideoShardPayloadSize) shardCount := (total + apolloVideoShardPayloadSize - 1) / apolloVideoShardPayloadSize
payload[0], payload[3] = 0x01, 0x01 if shardCount > apolloVideoMaximumBlocks*apolloVideoMaximumDataShards {
binary.LittleEndian.PutUint16(payload[4:6], uint16(8+len(encoded))) t.Fatal("qualification encoded frame exceeds source-shaped Apollo bound")
copy(payload[8:], encoded)
raw := sourceShapedVideoRaw(frame, uint16(frame), frame, 0x07, 1, 0, 0, payload)
return [][]byte{sourceEncryptVideoRaw(t, key, raw, qualificationVideoIV(frame, 0))}
} }
combined := make([]byte, 2*apolloVideoShardPayloadSize) combined := make([]byte, shardCount*apolloVideoShardPayloadSize)
combined[0], combined[3] = 0x01, 0x01 combined[0], combined[3] = 0x01, 0x01
binary.LittleEndian.PutUint16(combined[4:6], uint16(8+len(encoded)-apolloVideoShardPayloadSize)) lastPayloadLength := total - (shardCount-1)*apolloVideoShardPayloadSize
binary.LittleEndian.PutUint16(combined[4:6], uint16(lastPayloadLength))
copy(combined[8:], encoded) copy(combined[8:], encoded)
first := sourceShapedVideoRaw(frame, uint16(frame*3), frame*3, 0x05, 2, 50, 0, combined[:apolloVideoShardPayloadSize])
second := sourceShapedVideoRaw(frame, uint16(frame*3+1), frame*3+1, 0x03, 2, 50, 1, combined[apolloVideoShardPayloadSize:]) lastBlock := (shardCount - 1) / apolloVideoMaximumDataShards
parity := make([]byte, len(first)) packets := make([][]byte, 0, shardCount)
for index := range parity { for globalIndex := 0; globalIndex < shardCount; {
parity[index] = first[index] ^ sourceGFMultiply(second[index], 142) block := globalIndex / apolloVideoMaximumDataShards
dataShards := min(apolloVideoMaximumDataShards, shardCount-globalIndex)
for shardIndex := 0; shardIndex < dataShards; shardIndex++ {
flags := byte(0x01)
if shardIndex == 0 {
flags |= 0x04
} }
sourceConfigureVideoShard(parity, frame, uint16(frame*3+2), frame*3+2, 2, 50, 2) if shardIndex == dataShards-1 {
return [][]byte{ flags |= 0x02
sourceEncryptVideoRaw(t, key, second, qualificationVideoIV(frame, 1)),
sourceEncryptVideoRaw(t, key, parity, qualificationVideoIV(frame, 2)),
} }
offset := globalIndex * apolloVideoShardPayloadSize
raw := sourceShapedVideoRaw(
frame, uint16(frame*1024+uint32(globalIndex)), frame*1024+uint32(globalIndex),
flags, dataShards, 0, shardIndex, combined[offset:offset+apolloVideoShardPayloadSize],
)
raw[27] = byte(block<<4 | lastBlock<<6)
packets = append(packets, sourceEncryptVideoRaw(t, key, raw, qualificationVideoIV(frame, globalIndex)))
globalIndex++
}
}
return packets
} }
func qualificationVideoIV(frame uint32, shard byte) string { func qualificationVideoIV(frame uint32, shard int) string {
return fmt.Sprintf("%09x%01xQV", frame, shard) return fmt.Sprintf("%06x%04xQV", frame&0xffffff, shard&0xffff)
} }
func qualificationProductionPathSmoke(t *testing.T, profile qualificationMediaProfile) qualificationPathTrace { func qualificationProductionPathSmoke(t *testing.T, profile qualificationMediaProfile) qualificationPathTrace {
@@ -1409,7 +1462,10 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
observation.ObservedOutOfOrder++ observation.ObservedOutOfOrder++
} }
previousDelivered = packet.index previousDelivered = packet.index
deliveries = append(deliveries, qualificationDeliverySample{At: packet.deliveredAt, Bytes: int64(media.PacketBytes + frameHeaderSize)}) datagrams := (media.PacketBytes + frameV2PayloadSize - 1) / frameV2PayloadSize
deliveries = append(deliveries, qualificationDeliverySample{
At: packet.deliveredAt, Bytes: int64(media.PacketBytes + datagrams*frameV2HeaderSize),
})
observation.MaxQueuePackets = max(observation.MaxQueuePackets, packet.queuePackets) observation.MaxQueuePackets = max(observation.MaxQueuePackets, packet.queuePackets)
} }
observation.Delivered = len(received.packets) observation.Delivered = len(received.packets)
@@ -1426,7 +1482,8 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
observation.ProviderEnqueued = int(path.session.mediaEnqueued.Load() - beforeEnqueued) observation.ProviderEnqueued = int(path.session.mediaEnqueued.Load() - beforeEnqueued)
observation.ProviderQueueReplaced = int(path.session.mediaDrops.Load() - beforeProviderDrops) observation.ProviderQueueReplaced = int(path.session.mediaDrops.Load() - beforeProviderDrops)
observation.GatewayForwarded = int(afterMetrics.ProcessingSamples - beforeMetrics.ProcessingSamples) observation.GatewayForwarded = int(afterMetrics.ProcessingSamples - beforeMetrics.ProcessingSamples)
observation.QUICSent = int(afterMetrics.MediaPackets - beforeMetrics.MediaPackets) observation.QUICSent = observation.GatewayForwarded
observation.QUICDatagramsSent = int(afterMetrics.MediaPackets - beforeMetrics.MediaPackets)
observation.ProviderFECDropped = max(observation.SourceEmitted-observation.ProviderRecovered, 0) observation.ProviderFECDropped = max(observation.SourceEmitted-observation.ProviderRecovered, 0)
observation.ProviderEnqueueDropped = max(observation.ProviderRecovered-observation.ProviderEnqueued, 0) observation.ProviderEnqueueDropped = max(observation.ProviderRecovered-observation.ProviderEnqueued, 0)
observation.GatewayDropped = max(observation.ProviderEnqueued-observation.ProviderQueueReplaced-observation.GatewayForwarded, 0) observation.GatewayDropped = max(observation.ProviderEnqueued-observation.ProviderQueueReplaced-observation.GatewayForwarded, 0)
@@ -1586,11 +1643,11 @@ func qualificationMaximumDeliveryBytes(deliveries []qualificationDeliverySample,
func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile, rawPath string) (qualificationProcessingSummary, error) { func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile, rawPath string) (qualificationProcessingSummary, error) {
t.Helper() t.Helper()
payload := qualificationPayload(profile) payload := qualificationFramePayload(profile, 0)
if len(payload) < 4 { if len(payload) < 4 {
return qualificationProcessingSummary{}, errors.New("qualification payload too small") return qualificationProcessingSummary{}, errors.New("qualification payload too small")
} }
path := newQualificationProcessingPath(t, profile, qualificationMediaPacerKbps(profile, 0)) path := newQualificationProcessingPath(t, profile, qualificationFramePacerKbps(profile))
defer path.Close() defer path.Close()
if err := runQualificationProcessWarmup(t, path, profile, payload); err != nil { if err := runQualificationProcessWarmup(t, path, profile, payload); err != nil {
return qualificationProcessingSummary{}, err return qualificationProcessingSummary{}, err
@@ -1603,38 +1660,42 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
if err != nil { if err != nil {
return qualificationProcessingSummary{}, err return qualificationProcessingSummary{}, err
} }
bytesPerSecond := profile.BitrateKbps * 1000 / 8 fps := qualificationFrameRate(profile)
targetBytes := bytesPerSecond * profile.Duration.Nanoseconds() / int64(time.Second) targetFrames := profile.Duration.Nanoseconds() * int64(fps) / int64(time.Second)
targetPackets := (targetBytes + int64(profile.PacketBytes) - 1) / int64(profile.PacketBytes) if targetFrames < 1 {
spacing := time.Duration(int64(time.Second) * int64(profile.PacketBytes) * 8 / (profile.BitrateKbps * 1000)) return qualificationProcessingSummary{}, errors.New("qualification duration produces no complete frames")
}
spacing := time.Second / time.Duration(fps)
var targetBytes int64
var expectedDatagrams uint64
minimumFrameBytes, maximumFrameBytes := maxCompleteFrameBytes, 0
for index := int64(0); index < targetFrames; index++ {
size := qualificationFrameSize(profile, index)
targetBytes += int64(size)
expectedDatagrams += uint64((size + frameV2PayloadSize - 1) / frameV2PayloadSize)
minimumFrameBytes = min(minimumFrameBytes, size)
maximumFrameBytes = max(maximumFrameBytes, size)
}
maximumDuration := profile.Duration*105/100 + 250*time.Millisecond maximumDuration := profile.Duration*105/100 + 250*time.Millisecond
started := time.Now() started := time.Now()
receiveCtx, receiveCancel := context.WithDeadline(context.Background(), started.Add(maximumDuration+2*time.Second)) receiveCtx, receiveCancel := context.WithDeadline(context.Background(), started.Add(maximumDuration+2*time.Second))
defer receiveCancel() defer receiveCancel()
receivedDone := make(chan error, 1) receivedDone := make(chan error, 1)
go func() { go func() {
for index := int64(0); index < targetPackets; index++ { for index := int64(0); index < targetFrames; index++ {
recovered, receiveErr := path.receivePayload(receiveCtx) recovered, receiveErr := path.receivePayload(receiveCtx)
if receiveErr != nil { if receiveErr != nil {
receivedDone <- receiveErr receivedDone <- receiveErr
return return
} }
if len(recovered) != len(payload) { expected := qualificationFramePayload(profile, index)
if len(recovered) != len(expected) {
receivedDone <- fmt.Errorf( receivedDone <- fmt.Errorf(
"qualification processing payload length = %d, want %d at sequence %d", "qualification processing payload length = %d, want %d at sequence %d",
len(recovered), len(payload), index, len(recovered), len(expected), index,
) )
return return
} }
sequence := binary.BigEndian.Uint32(recovered[len(recovered)-4:])
if sequence != uint32(index) {
receivedDone <- fmt.Errorf(
"qualification processing sequence = %d, want %d", sequence, index,
)
return
}
expected := append([]byte(nil), payload...)
binary.BigEndian.PutUint32(expected[len(expected)-4:], uint32(index))
if !bytes.Equal(recovered, expected) { if !bytes.Equal(recovered, expected) {
receivedDone <- fmt.Errorf("qualification processing payload bytes changed at sequence %d", index) receivedDone <- fmt.Errorf("qualification processing payload bytes changed at sequence %d", index)
return return
@@ -1644,7 +1705,8 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
}() }()
var processed int64 var processed int64
var nextRelease time.Time var nextRelease time.Time
for processed < targetPackets { payloadDigest := sha256.New()
for processed < targetFrames {
select { select {
case receiveErr := <-receivedDone: case receiveErr := <-receivedDone:
if receiveErr != nil { if receiveErr != nil {
@@ -1659,8 +1721,8 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
) )
qualificationWaitUntil(release) qualificationWaitUntil(release)
nextRelease = release.Add(spacing) nextRelease = release.Add(spacing)
current := append([]byte(nil), payload...) current := qualificationFramePayload(profile, processed)
binary.BigEndian.PutUint32(current[len(current)-4:], uint32(processed)) _, _ = payloadDigest.Write(current)
if _, err := path.emit(t, current); err != nil { if _, err := path.emit(t, current); err != nil {
return qualificationProcessingSummary{}, err return qualificationProcessingSummary{}, err
} }
@@ -1684,9 +1746,11 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
after.MediaRecovered-before.MediaRecovered != uint64(processed) || after.MediaRecovered-before.MediaRecovered != uint64(processed) ||
after.MediaEnqueued-before.MediaEnqueued != uint64(processed) || after.MediaEnqueued-before.MediaEnqueued != uint64(processed) ||
after.MediaDrops != before.MediaDrops || after.MediaDrops != before.MediaDrops ||
after.MediaQueueMaximum > nativeApolloVideoQueuePackets ||
after.MediaQueueMaximumBytes > nativeApolloVideoQueueBytes ||
after.Metrics.ProcessingSamples-before.Metrics.ProcessingSamples != uint64(processed) || after.Metrics.ProcessingSamples-before.Metrics.ProcessingSamples != uint64(processed) ||
after.PacerReservations <= before.PacerReservations || after.PacerReservations <= before.PacerReservations ||
after.Metrics.MediaPackets-before.Metrics.MediaPackets != uint64(processed) { after.Metrics.MediaPackets-before.Metrics.MediaPackets != expectedDatagrams {
return qualificationProcessingSummary{}, fmt.Errorf("qualification subprocess bypassed a production stage: before=%#v after=%#v processed=%d", before, after, processed) return qualificationProcessingSummary{}, fmt.Errorf("qualification subprocess bypassed a production stage: before=%#v after=%#v processed=%d", before, after, processed)
} }
samples, err := readQualificationProcessingSamples(rawPath, record.Count) samples, err := readQualificationProcessingSamples(rawPath, record.Count)
@@ -1704,13 +1768,18 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
summary.Profile = profile.Name summary.Profile = profile.Name
summary.Codec = profile.Codec summary.Codec = profile.Codec
summary.ConfiguredBitrateKbps = profile.BitrateKbps summary.ConfiguredBitrateKbps = profile.BitrateKbps
summary.ObservedBitrateKbps = float64(processed*int64(profile.PacketBytes)*8) / actualDuration.Seconds() / 1000 summary.ObservedBitrateKbps = float64(targetBytes*8) / actualDuration.Seconds() / 1000
summary.ConfiguredFPS = fps
summary.ObservedFPS = float64(processed) / actualDuration.Seconds()
summary.PayloadBytes = targetBytes
summary.MinimumFrameBytes = minimumFrameBytes
summary.MaximumFrameBytes = maximumFrameBytes
summary.Warmup = profile.Warmup summary.Warmup = profile.Warmup
summary.ConfiguredDuration = profile.Duration summary.ConfiguredDuration = profile.Duration
summary.ActualDuration = actualDuration summary.ActualDuration = actualDuration
summary.ClockOverhead = record.ClockOverhead summary.ClockOverhead = record.ClockOverhead
summary.ClockMethod = record.ClockMethod summary.ClockMethod = record.ClockMethod
summary.PayloadSHA256 = fmt.Sprintf("%x", sha256.Sum256(payload)) summary.PayloadSHA256 = fmt.Sprintf("%x", payloadDigest.Sum(nil))
summary.RawSamples = filepath.Base(rawPath) summary.RawSamples = filepath.Base(rawPath)
summary.RawSamplesSHA256 = sum summary.RawSamplesSHA256 = sum
summary.RawSamplesBytes = size summary.RawSamplesBytes = size
+2
View File
@@ -55,6 +55,7 @@ type qualificationGatewayProcessSnapshot struct {
MediaEnqueued uint64 MediaEnqueued uint64
MediaDrops uint64 MediaDrops uint64
MediaQueueMaximum uint64 MediaQueueMaximum uint64
MediaQueueMaximumBytes uint64
PacerReservations uint64 PacerReservations uint64
ProviderTelemetry ProviderTelemetry ProviderTelemetry ProviderTelemetry
} }
@@ -458,6 +459,7 @@ func TestQualificationGatewayProcessChild(t *testing.T) {
snapshot.MediaEnqueued = session.mediaEnqueued.Load() snapshot.MediaEnqueued = session.mediaEnqueued.Load()
snapshot.MediaDrops = session.mediaDrops.Load() snapshot.MediaDrops = session.mediaDrops.Load()
snapshot.MediaQueueMaximum = session.mediaQueueMaximum.Load() snapshot.MediaQueueMaximum = session.mediaQueueMaximum.Load()
snapshot.MediaQueueMaximumBytes = session.mediaQueueMaximumBytes.Load()
snapshot.ProviderTelemetry = session.Telemetry() snapshot.ProviderTelemetry = session.Telemetry()
} }
_ = json.NewEncoder(response).Encode(snapshot) _ = json.NewEncoder(response).Encode(snapshot)
+19
View File
@@ -1,6 +1,7 @@
package gateway package gateway
import ( import (
"context"
"net" "net"
"testing" "testing"
"time" "time"
@@ -17,6 +18,24 @@ func TestGatewaySlowReaderStillCleansUpWithinBound(t *testing.T) {
harness.waitReleased(t) harness.waitReleased(t)
} }
func TestGatewayDropsVideoPastQueueResidenceBound(t *testing.T) {
harness := newGatewayTransportHarness(t)
harness.drainInitialMedia(t)
before := harness.server.Metrics().MediaDrops
harness.session.video <- ProviderMedia{
Payload: []byte("stale-complete-frame"), ReceivedAt: time.Now().Add(-time.Second),
EnqueuedAt: time.Now().Add(-nativeApolloVideoQueueLatency - time.Millisecond),
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
if frame, err := harness.client.ReceiveFrame(ctx); err == nil {
t.Fatalf("expired provider frame crossed the public transport: %#v", frame)
}
if drops := harness.server.Metrics().MediaDrops - before; drops != 1 {
t.Fatalf("expired queue drops = %d, want 1", drops)
}
}
func TestGatewayMalformedUDPDoesNotAmplify(t *testing.T) { func TestGatewayMalformedUDPDoesNotAmplify(t *testing.T) {
harness := newGatewayTransportHarness(t) harness := newGatewayTransportHarness(t)
connection, err := net.DialUDP("udp", nil, harness.server.Addr().(*net.UDPAddr)) connection, err := net.DialUDP("udp", nil, harness.server.Addr().(*net.UDPAddr))
+8
View File
@@ -673,6 +673,14 @@ func (s *gatewaySession) mediaLoop() {
video = nil video = nil
continue continue
} }
if media.expiry != nil {
media.expiry.Stop()
}
media.releaseQueue()
if !media.EnqueuedAt.IsZero() && time.Since(media.EnqueuedAt) > nativeApolloVideoQueueLatency {
s.server.metrics.MediaDrops.Add(1)
continue
}
if err := s.forwardMedia(ChannelVideo, media); err != nil { if err := s.forwardMedia(ChannelVideo, media); err != nil {
s.result <- err s.result <- err
return return
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,33 @@
## Context
`FragmentPayload` currently stops at 16 × 1,179 bytes and the independent test client assumes ordered fragments. Native Apollo output enters count-only buffered channels, so realistic complete frames have neither a byte ceiling nor an explicit residence bound.
## Goals / Non-Goals
**Goals:**
- Implement Protocol datagram-v2 for complete encoded frames up to 1 MiB.
- Reassemble bounded duplicate/reordered QUIC datagrams independently.
- Bound native video queue count, bytes, and residence time while retaining latest-frame replacement and drop telemetry.
**Non-Goals:**
- Codec inspection, retransmission, provider fallback, generic queue/transport APIs, or Server behavior changes.
## Decisions
- Keep the existing `Frame`/QUIC path and add version-aware encode/decode rather than a second transport.
- Use one sequence per provider frame and the Protocol 1,177-byte fragment size.
- Keep the existing native video channel at 16 entries, add exact atomic byte
accounting capped at 4 MiB, and use per-entry timers for the 250 ms residence
bound. This matches the Protocol's reviewed incomplete-unit timeout and covers
bounded keyframe serialization; the transport performs a final stale check.
- Audio and events keep their independent existing limits.
## Risks / Trade-offs
- [Latest-frame eviction drops decodable dependencies] → preserve truthful drops and existing IDR feedback; never grow memory or block every session.
- [Large frames multiply fragment sends] → cap both complete bytes and fragment count before allocation.
- [Expiry races with dequeue or cleanup] → stop each package-private timer on
dequeue/replacement, serialize channel expiry and close, and retain the
transport stale check.
@@ -0,0 +1,24 @@
## Why
The production gateway cannot forward complete encoded video frames larger than 18,864 bytes, and its native video queue is bounded only by entry count. Realistic Phase 3C frame distributions therefore fail before QUIC delivery or can consume unreviewed memory.
## What Changes
- Implement the Protocol-owned complete-frame datagram profile and independent bounded client reassembly.
- Relay full recovered Apollo frames without mutation or unrelated sequence splitting.
- Bound native video queuing by frame count, encoded bytes, and residence time with latest-frame replacement and truthful drops.
- Preserve independent audio and event bounds and all no-transcode/provider isolation rules.
## Capabilities
### New Capabilities
- `complete-encoded-frame-transport`: Production fragmentation, reassembly, and byte/latency/count-bounded native frame queuing.
### Modified Capabilities
None.
## Impact
Gateway framing, native Apollo media queues, QUIC send/receive tests, telemetry, and bounded resource checks. No new dependency or Server change. Requirements: P3C-006P3C-009, P3C-025, P3C-026, P3C-028, P3C-030, P3C-038, VER-001, VER-006, VER-010.
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Production transport preserves complete encoded frames
The gateway SHALL carry each recovered Apollo encoded frame as one Protocol datagram-v2 sequence, preserve exact bytes and frame boundaries through the production media queue, pacer, QUIC transport, and independent reassembler, and reject frames outside Protocol bounds before forwarding.
#### Scenario: Large source-shaped frame
- **WHEN** Apollo UDP/FEC recovers a valid encoded frame above 18,864 bytes within the reviewed maximum
- **THEN** the independent client receives one byte-identical frame with the same boundary
#### Scenario: Invalid fragment stream
- **WHEN** fragments are oversized, inconsistent, conflicting duplicates, outside the reorder/state/time bounds, or claim an oversized frame
- **THEN** the client emits no partial payload and bounded state is released
### Requirement: Native video queue has count byte and latency bounds
The native provider video queue SHALL retain at most 16 complete frames, at
most 4 MiB of encoded frame bytes, and no frame for more than 250 milliseconds.
It SHALL replace the oldest entry when full, expire stale entries independently
of queue activity, and increment truthful drop telemetry for every replacement
or expiry. Cleanup and cancellation MUST stop expiry work and release all queued
payload references.
#### Scenario: Sustained realistic frames
- **WHEN** a provider produces realistic variable-size complete frames faster than a slow Verse reader can forward them
- **THEN** retained entries, bytes, and age remain within the reviewed per-session limits and newer frames continue to progress
#### Scenario: Session cleanup
- **WHEN** a session terminates, disconnects, or is cancelled with queued video
- **THEN** queued frames are released, blocked readers wake, and no media crosses after quiescence
### Requirement: Other provider queues remain independently bounded
Audio and provider event queues SHALL retain independent count and payload bounds and MUST NOT share the video byte budget.
#### Scenario: Video saturation
- **WHEN** the video queue reaches its byte or age bound
- **THEN** audio and terminal event delivery retain their existing independent bounded capacity
@@ -0,0 +1,20 @@
## 1. Red production path
- [x] 1.1 Add a public Apollo-UDP-to-independent-client regression for complete frames above 18,864 bytes
- [x] 1.2 Add malformed, duplicate, reorder, timeout, and maximum-allocation reassembly cases
## 2. Complete-frame transport
- [x] 2.1 Implement negotiated datagram-v2 fragmentation and bounded independent reassembly
- [x] 2.2 Prove deterministic 1080p60, 1440p120, and 4K60 frame distributions preserve exact bytes and boundaries
## 3. Native queue bounds
- [x] 3.1 Add sustained realistic-frame regressions for count, byte, latency, cleanup, cancellation, slow-reader, and amplification bounds
- [x] 3.2 Bound the existing native video channel by 16 entries, 4 MiB, and 250 ms with latest-frame replacement and truthful drops
- [x] 3.3 Preserve independent bounded audio and terminal event paths
## 4. Verification
- [x] 4.1 Run focused framing, native media, queue, race, cancellation, and resource checks
- [x] 4.2 Run strict OpenSpec validation and the final affected Data Plane verification once
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-30
@@ -0,0 +1,26 @@
## Context
The current harness sends one fixed 1,179-byte payload per logical sample. It reaches the production path but does not represent encoded frames at 60/120 FPS or exercise realistic fragmentation, reassembly, queue bytes, and keyframe pressure.
## Goals / Non-Goals
**Goals:**
- Deterministically generate complete variable-size frame units at exact profile frame rates and target bitrates.
- Include bounded periodic keyframes while preserving exact aggregate bytes.
- Measure the existing production path and independent reassembly with frame-level accounting.
**Non-Goals:**
- A real encoder, codec parsing, a second simulator, or a normative run before immutable Protocol publication.
## Decisions
- Derive bytes per fixed interval from bitrate and FPS, distribute integer remainder deterministically, and shift bounded bytes into periodic keyframes while keeping the interval total exact.
- Carry a deterministic frame index/pattern only in the generated payload bytes; no codec semantics are claimed.
- Keep the existing path/impairment/resource driver and change its unit from datagram payload to complete frame.
## Risks / Trade-offs
- [Keyframes can exceed queue budget] → use the reviewed 1 MiB frame ceiling and production byte-bound queue.
- [Short smoke windows have rounding effects] → assert exact generated totals and report measured duration separately from normative ten-minute gates.
@@ -0,0 +1,24 @@
## Why
The existing fixed-profile harness treats each 1,179-byte datagram as an encoded frame, so its reported frame rate, frame boundaries, bitrate, queue pressure, and processing evidence do not model the named 60/120 FPS profiles.
## What Changes
- Generate deterministic variable-size encoded frame units at the named frame rates and target bitrates, including bounded keyframes.
- Traverse native Apollo recovery, production queues, the production pacer, QUIC framing, and independent reassembly.
- Assert frame count/rate, bitrate, exact bytes and boundaries, clean loss attribution, latency, and resource bounds.
- Keep short smoke tests separate and leave all prior normative artifacts unchanged.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `gateway-qualification`: Fixed-profile evidence measures complete encoded frame units rather than one datagram per frame.
## Impact
The existing qualification harness and canonical qualification specification only. No codec operation, production dependency, or normative run before immutable consumer publication. Requirements: P3C-002, P3C-008, P3C-029, P3C-030, P3C-033, VER-009, VER-010, OPS-015.
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Fixed media processing qualification
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, byte/count/latency-bounded production queues, the production fair pacer, Protocol complete-frame fragmentation, Verse framing/QUIC, and an independent bounded client reassembler for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. The source fixture SHALL emit deterministic variable-size complete encoded frame units at the named 60/120 FPS rate, preserve exact target bytes over each fixed interval, and include bounded larger keyframes without codec operation. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve every frame's bytes and boundary, retain every monotonic processing sample plus bounded provider-queue observations, and report frame count, frame rate, bitrate, count, min, median, p90, p95, p99, max, mean, standard deviation, measured batched monotonic-clock overhead and method, and observed bitrate. Processing begins at complete provider-frame receipt and ends at QUIC handoff, excluding client transit and pacing. Queue delay SHALL measure provider-queue residence, processing SHALL measure gateway work before pacing, and pacing delay SHALL measure scheduler waiting. CPU, heap, allocations, and goroutines SHALL be measured from the isolated gateway process only; CPU SHALL be actual OS user plus system consumption and MUST NOT include idle wall capacity or unrelated parent fixture/client work. Successive profiles SHALL use independent resource-counter baselines. Any bypass, payload or boundary mutation, frame-rate/count mismatch, wall-duration violation, bitrate outside both lower and upper bounds, unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5 ms SHALL fail.
#### Scenario: Healthy fixed profile
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
- **THEN** the harness emits compressed raw frame/path and gateway-process resource samples plus a summary tied to the exact command, CPU scope, timing-overhead method, topology, source commit, immutable Protocol version, environment, and payload hash
#### Scenario: Processing gate failure
- **WHEN** any production path stage lacks a per-frame observation, stage accounting does not balance, payload or frame boundaries change, duration, frame-rate, frame-count, or bitrate bounds fail, measured p95 exceeds 5 ms, parent work changes gateway CPU, idle capacity is reported as consumed CPU, or timing overhead is absent
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate
@@ -0,0 +1,19 @@
## 1. Red fixed-profile model
- [x] 1.1 Add deterministic frame-count, frame-rate, bitrate, keyframe, byte-total, and boundary regressions
- [x] 1.2 Prove the current 1,179-byte one-frame model fails the required profiles
## 2. Production-path qualification
- [x] 2.1 Replace packet payload generation with bounded variable-size complete frame units
- [x] 2.2 Carry frame-level source, recovery, queue, QUIC, delivery, and loss attribution through the existing path
- [x] 2.3 Assert frame rate/count, bitrate bounds, exact bytes/boundaries, processing latency, and resource bounds
## 3. Verification
- [x] 3.1 Run short production-path smoke tests for all three profiles and affected impairment accounting
- [x] 3.2 Validate the active OpenSpec change strictly
## 4. Frozen qualification
- [ ] 4.1 Run the single normative Section 7 qualification after immutable Protocol consumer resolution
+36 -3
View File
@@ -5,14 +5,47 @@ Define the deterministic processing, impairment, pacing, and evidence boundaries
for qualifying a frozen Phase 3C gateway candidate. for qualifying a frozen Phase 3C gateway candidate.
## Requirements ## Requirements
### Requirement: Fixed media processing qualification ### Requirement: Fixed media processing qualification
The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`, recovery/FEC, bounded production queues, the production fair pacer, Verse framing/QUIC, and a public or independent client decoder for 1080p60 H.264 at 20 Mbps, 1440p120 HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. After a recorded warm-up, the frozen candidate SHALL run each profile for ten wall-clock minutes, preserve encoded payload bytes, retain every monotonic processing sample plus bounded provider-queue observations, and report count, min, median, p90, p95, p99, max, mean, standard deviation, measured batched monotonic-clock overhead and method, and observed bitrate. Processing begins at complete provider-unit receipt and ends at QUIC handoff, excluding client transit and pacing. Queue delay SHALL measure provider-queue residence, processing SHALL measure gateway work before pacing, and pacing delay SHALL measure scheduler waiting. Native queues SHALL remain bounded at 256 video packets and 16 audio or event units per session, retaining latest-unit replacement. CPU, heap, allocations, and goroutines SHALL be measured from the isolated gateway process only; CPU SHALL be actual OS user plus system consumption and MUST NOT include idle wall capacity or unrelated parent fixture/client work. Successive profiles SHALL use independent resource-counter baselines. Any bypass, payload mutation, wall-duration violation, bitrate outside both lower and upper bounds, unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5 ms SHALL fail. The qualification harness SHALL drive pinned-mTLS Apollo management, encrypted
RTSP, ENet, and provider UDP through native source validation, `readUDPMedia`,
recovery/FEC, byte/count/latency-bounded production queues, the production fair
pacer, Protocol complete-frame fragmentation, Verse framing/QUIC, and an
independent bounded client reassembler for 1080p60 H.264 at 20 Mbps, 1440p120
HEVC at 50 Mbps, and 4K60 HEVC at 80 Mbps. The source fixture SHALL emit
deterministic variable-size complete encoded frames at the named 60/120 FPS
rate, preserve exact target bytes over each fixed interval, and include bounded
larger keyframes without codec operation. After a recorded warm-up, the frozen
candidate SHALL run each profile for ten wall-clock minutes, preserve every
frame's bytes and boundary, retain every monotonic processing sample plus
bounded provider-queue observations, and report frame count, frame rate,
bitrate, min, median, p90, p95, p99, max, mean, standard deviation, and measured
batched monotonic-clock overhead and method. Processing begins at complete
provider-frame receipt and ends at QUIC handoff, excluding client transit and
pacing. Queue delay SHALL measure provider-queue residence, processing SHALL
measure gateway work before pacing, and pacing delay SHALL measure scheduler
waiting. Native video queues SHALL retain at most 16 complete frames, 4 MiB,
and 250 milliseconds; audio and event queues SHALL remain independently bounded
at 16 units. CPU, heap, allocations, and goroutines SHALL be measured from the
isolated gateway process only; CPU SHALL be actual OS user plus system
consumption and MUST NOT include idle wall capacity or unrelated parent
fixture/client work. Successive profiles SHALL use independent resource-counter
baselines. Any bypass, payload or frame-boundary mutation, frame-rate/count
mismatch, wall-duration violation, bitrate outside both lower and upper bounds,
unexplained clean-path loss, zero or unbounded clock overhead, or p95 above 5
ms SHALL fail.
#### Scenario: Healthy fixed profile #### Scenario: Healthy fixed profile
- **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command - **WHEN** a frozen candidate runs one fixed profile for the normative duration in the isolated qualification command
- **THEN** the harness emits compressed raw path and gateway-process resource samples plus a summary tied to the exact command, CPU scope, timing-overhead method, topology, source commit, immutable Protocol version, environment, and payload hash - **THEN** the harness emits compressed raw frame/path and gateway-process
resource samples plus a summary tied to the exact command, CPU scope,
timing-overhead method, topology, source commit, immutable Protocol version,
environment, and payload hash
#### Scenario: Processing gate failure #### Scenario: Processing gate failure
- **WHEN** any production path stage lacks a per-traversal observation, stage accounting does not balance, payload integrity fails, duration or bitrate bounds fail, measured p95 exceeds 5 ms, parent work changes gateway CPU, idle capacity is reported as consumed CPU, or timing overhead is absent - **WHEN** any production path stage lacks a per-frame observation, stage
accounting does not balance, payload or frame boundaries change, duration,
frame-rate, frame-count, or bitrate bounds fail, measured p95 exceeds 5 ms,
parent work changes gateway CPU, idle capacity is reported as consumed CPU, or
timing overhead is absent
- **THEN** the qualification command exits unsuccessfully without recording a passing candidate - **THEN** the qualification command exits unsuccessfully without recording a passing candidate
### Requirement: Bounded impairment qualification ### Requirement: Bounded impairment qualification