feat(gateway): relay complete encoded frames
This commit is contained in:
+105
-2
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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-006–P3C-009, P3C-025, P3C-026, P3C-028, P3C-030, P3C-038, VER-001, VER-006, VER-010.
|
||||||
+35
@@ -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.
|
||||||
+12
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user