feat(gateway): relay complete encoded frames

This commit is contained in:
sechmachine
2026-07-30 22:47:48 +07:00
parent f12ed6c685
commit 786c96b110
22 changed files with 1023 additions and 159 deletions
+150 -81
View File
@@ -40,7 +40,7 @@ import (
const (
qualificationToolVersion = "versevdi-gateway-qualification/v6"
qualificationImpairmentQueuePackets = 256
qualificationImpairmentQueuePackets = nativeApolloVideoQueuePackets
qualificationImpairmentMaxPackets = 100_000
qualificationImpairmentPacketCount = 10_000
qualificationProcessingLimit = 5 * time.Millisecond
@@ -54,6 +54,7 @@ type qualificationMediaProfile struct {
Name string
Codec string
BitrateKbps int64
FPS int
Duration time.Duration
Warmup time.Duration
PacketBytes int
@@ -73,7 +74,7 @@ type qualificationPathTrace struct {
}
type qualificationPath struct {
client *Client
client *independentGatewayClient
server *Server
session *nativeApolloSession
fixture *qualificationApolloFixture
@@ -102,6 +103,11 @@ type qualificationProcessingSummary struct {
Codec string `json:"codec"`
ConfiguredBitrateKbps int64 `json:"configured_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"`
ConfiguredDuration time.Duration `json:"configured_duration_ns"`
ActualDuration time.Duration `json:"actual_duration_ns"`
@@ -154,6 +160,7 @@ type qualificationImpairmentObservation struct {
GatewayForwarded int `json:"gateway_forwarded"`
GatewayDropped int `json:"gateway_dropped"`
QUICSent int `json:"quic_sent"`
QUICDatagramsSent int `json:"quic_datagrams_sent"`
QUICSendDropped int `json:"quic_send_dropped"`
ClientDeliveryDropped int `json:"client_delivery_dropped"`
UnexplainedDropped int `json:"unexplained_dropped"`
@@ -241,9 +248,9 @@ type qualificationManifest struct {
func qualificationMediaProfiles() []qualificationMediaProfile {
return []qualificationMediaProfile{
{Name: "1080p60-h264", Codec: "h264", BitrateKbps: 20000, 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: "4k60-hevc", Codec: "hevc", BitrateKbps: 80000, 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, FPS: 120, 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
}
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 {
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 {
@@ -767,7 +830,7 @@ type qualificationFleet struct {
t *testing.T
server *Server
paths []*qualificationPath
clients []*Client
clients []*independentGatewayClient
cancel context.CancelFunc
serveDone chan error
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),
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 {
fleet.Close()
t.Fatal(err)
@@ -894,7 +957,7 @@ func newQualificationPathWithImpairment(t *testing.T, profile qualificationMedia
ClientNonce: "nonce-qualification", 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 {
cancel()
_ = server.Close()
@@ -941,7 +1004,7 @@ func newQualificationProcessingPath(t *testing.T, profile qualificationMediaProf
ClientNonce: "nonce-qualification", DeviceSignature: strings.Repeat("s", 86),
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 {
process.Close()
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) {
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
}
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) {
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
var recovered []byte
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
return p.client.ReceiveMedia(ctx)
}
func qualificationSourceVideoPackets(t *testing.T, key []byte, frame uint32, encoded []byte) [][]byte {
t.Helper()
if len(encoded) <= apolloVideoShardPayloadSize-8 {
payload := make([]byte, apolloVideoShardPayloadSize)
payload[0], payload[3] = 0x01, 0x01
binary.LittleEndian.PutUint16(payload[4:6], uint16(8+len(encoded)))
copy(payload[8:], encoded)
raw := sourceShapedVideoRaw(frame, uint16(frame), frame, 0x07, 1, 0, 0, payload)
return [][]byte{sourceEncryptVideoRaw(t, key, raw, qualificationVideoIV(frame, 0))}
total := 8 + len(encoded)
shardCount := (total + apolloVideoShardPayloadSize - 1) / apolloVideoShardPayloadSize
if shardCount > apolloVideoMaximumBlocks*apolloVideoMaximumDataShards {
t.Fatal("qualification encoded frame exceeds source-shaped Apollo bound")
}
combined := make([]byte, 2*apolloVideoShardPayloadSize)
combined := make([]byte, shardCount*apolloVideoShardPayloadSize)
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)
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:])
parity := make([]byte, len(first))
for index := range parity {
parity[index] = first[index] ^ sourceGFMultiply(second[index], 142)
}
sourceConfigureVideoShard(parity, frame, uint16(frame*3+2), frame*3+2, 2, 50, 2)
return [][]byte{
sourceEncryptVideoRaw(t, key, second, qualificationVideoIV(frame, 1)),
sourceEncryptVideoRaw(t, key, parity, qualificationVideoIV(frame, 2)),
lastBlock := (shardCount - 1) / apolloVideoMaximumDataShards
packets := make([][]byte, 0, shardCount)
for globalIndex := 0; globalIndex < shardCount; {
block := globalIndex / apolloVideoMaximumDataShards
dataShards := min(apolloVideoMaximumDataShards, shardCount-globalIndex)
for shardIndex := 0; shardIndex < dataShards; shardIndex++ {
flags := byte(0x01)
if shardIndex == 0 {
flags |= 0x04
}
if shardIndex == dataShards-1 {
flags |= 0x02
}
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 {
return fmt.Sprintf("%09x%01xQV", frame, shard)
func qualificationVideoIV(frame uint32, shard int) string {
return fmt.Sprintf("%06x%04xQV", frame&0xffffff, shard&0xffff)
}
func qualificationProductionPathSmoke(t *testing.T, profile qualificationMediaProfile) qualificationPathTrace {
@@ -1409,7 +1462,10 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
observation.ObservedOutOfOrder++
}
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.Delivered = len(received.packets)
@@ -1426,7 +1482,8 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
observation.ProviderEnqueued = int(path.session.mediaEnqueued.Load() - beforeEnqueued)
observation.ProviderQueueReplaced = int(path.session.mediaDrops.Load() - beforeProviderDrops)
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.ProviderEnqueueDropped = max(observation.ProviderRecovered-observation.ProviderEnqueued, 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) {
t.Helper()
payload := qualificationPayload(profile)
payload := qualificationFramePayload(profile, 0)
if len(payload) < 4 {
return qualificationProcessingSummary{}, errors.New("qualification payload too small")
}
path := newQualificationProcessingPath(t, profile, qualificationMediaPacerKbps(profile, 0))
path := newQualificationProcessingPath(t, profile, qualificationFramePacerKbps(profile))
defer path.Close()
if err := runQualificationProcessWarmup(t, path, profile, payload); err != nil {
return qualificationProcessingSummary{}, err
@@ -1603,38 +1660,42 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
if err != nil {
return qualificationProcessingSummary{}, err
}
bytesPerSecond := profile.BitrateKbps * 1000 / 8
targetBytes := bytesPerSecond * profile.Duration.Nanoseconds() / int64(time.Second)
targetPackets := (targetBytes + int64(profile.PacketBytes) - 1) / int64(profile.PacketBytes)
spacing := time.Duration(int64(time.Second) * int64(profile.PacketBytes) * 8 / (profile.BitrateKbps * 1000))
fps := qualificationFrameRate(profile)
targetFrames := profile.Duration.Nanoseconds() * int64(fps) / int64(time.Second)
if targetFrames < 1 {
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
started := time.Now()
receiveCtx, receiveCancel := context.WithDeadline(context.Background(), started.Add(maximumDuration+2*time.Second))
defer receiveCancel()
receivedDone := make(chan error, 1)
go func() {
for index := int64(0); index < targetPackets; index++ {
for index := int64(0); index < targetFrames; index++ {
recovered, receiveErr := path.receivePayload(receiveCtx)
if receiveErr != nil {
receivedDone <- receiveErr
return
}
if len(recovered) != len(payload) {
expected := qualificationFramePayload(profile, index)
if len(recovered) != len(expected) {
receivedDone <- fmt.Errorf(
"qualification processing payload length = %d, want %d at sequence %d",
len(recovered), len(payload), index,
len(recovered), len(expected), index,
)
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) {
receivedDone <- fmt.Errorf("qualification processing payload bytes changed at sequence %d", index)
return
@@ -1644,7 +1705,8 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
}()
var processed int64
var nextRelease time.Time
for processed < targetPackets {
payloadDigest := sha256.New()
for processed < targetFrames {
select {
case receiveErr := <-receivedDone:
if receiveErr != nil {
@@ -1659,8 +1721,8 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
)
qualificationWaitUntil(release)
nextRelease = release.Add(spacing)
current := append([]byte(nil), payload...)
binary.BigEndian.PutUint32(current[len(current)-4:], uint32(processed))
current := qualificationFramePayload(profile, processed)
_, _ = payloadDigest.Write(current)
if _, err := path.emit(t, current); err != nil {
return qualificationProcessingSummary{}, err
}
@@ -1684,9 +1746,11 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
after.MediaRecovered-before.MediaRecovered != uint64(processed) ||
after.MediaEnqueued-before.MediaEnqueued != uint64(processed) ||
after.MediaDrops != before.MediaDrops ||
after.MediaQueueMaximum > nativeApolloVideoQueuePackets ||
after.MediaQueueMaximumBytes > nativeApolloVideoQueueBytes ||
after.Metrics.ProcessingSamples-before.Metrics.ProcessingSamples != uint64(processed) ||
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)
}
samples, err := readQualificationProcessingSamples(rawPath, record.Count)
@@ -1704,13 +1768,18 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
summary.Profile = profile.Name
summary.Codec = profile.Codec
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.ConfiguredDuration = profile.Duration
summary.ActualDuration = actualDuration
summary.ClockOverhead = record.ClockOverhead
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.RawSamplesSHA256 = sum
summary.RawSamplesBytes = size