mirror of
https://github.com/kerberos-io/agent.git
synced 2026-08-23 15:08:32 +00:00
Add live stream recovery gating
Drop stale H.264 packets until a recent keyframe arrives, with lifecycle logging and slow MoQ write diagnostics. Add focused FrameGate tests and configure the UI package registry.
This commit is contained in:
45
machinery/src/cloud/livemoq/recovery.go
Normal file
45
machinery/src/cloud/livemoq/recovery.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package livemoq
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type FrameGateEvent uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
FrameGateEventNone FrameGateEvent = iota
|
||||||
|
FrameGateEventStarted
|
||||||
|
FrameGateEventLagging
|
||||||
|
FrameGateEventRecovered
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrameGate keeps publication on a decodable, recent GOP.
|
||||||
|
type FrameGate struct {
|
||||||
|
started bool
|
||||||
|
recovering bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow rejects stale frames and waits for a fresh keyframe before reopening.
|
||||||
|
func (g *FrameGate) Allow(isKeyFrame bool, capturedAtMs int64, now time.Time, maxAge time.Duration) (bool, FrameGateEvent) {
|
||||||
|
if capturedAtMs > 0 && now.Sub(time.UnixMilli(capturedAtMs)) > maxAge {
|
||||||
|
event := FrameGateEventNone
|
||||||
|
if !g.recovering {
|
||||||
|
event = FrameGateEventLagging
|
||||||
|
}
|
||||||
|
g.started = false
|
||||||
|
g.recovering = true
|
||||||
|
return false, event
|
||||||
|
}
|
||||||
|
|
||||||
|
if !g.started {
|
||||||
|
if !isKeyFrame {
|
||||||
|
return false, FrameGateEventNone
|
||||||
|
}
|
||||||
|
g.started = true
|
||||||
|
if g.recovering {
|
||||||
|
g.recovering = false
|
||||||
|
return true, FrameGateEventRecovered
|
||||||
|
}
|
||||||
|
return true, FrameGateEventStarted
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, FrameGateEventNone
|
||||||
|
}
|
||||||
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package livemoq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFrameGateRecoversAtFreshKeyframe(t *testing.T) {
|
||||||
|
now := time.UnixMilli(10_000)
|
||||||
|
maxAge := 1500 * time.Millisecond
|
||||||
|
gate := FrameGate{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
isKeyFrame bool
|
||||||
|
capturedAtMs int64
|
||||||
|
wantAllowed bool
|
||||||
|
wantEvent FrameGateEvent
|
||||||
|
}{
|
||||||
|
{name: "waits for initial keyframe", capturedAtMs: 10_000},
|
||||||
|
{name: "starts at initial keyframe", isKeyFrame: true, capturedAtMs: 10_000, wantAllowed: true, wantEvent: FrameGateEventStarted},
|
||||||
|
{name: "publishes fresh delta", capturedAtMs: 10_020, wantAllowed: true},
|
||||||
|
{name: "detects stale packet", capturedAtMs: 8_000, wantEvent: FrameGateEventLagging},
|
||||||
|
{name: "rejects fresh delta while recovering", capturedAtMs: 10_040},
|
||||||
|
{name: "rejects stale keyframe without duplicate event", isKeyFrame: true, capturedAtMs: 8_000},
|
||||||
|
{name: "recovers at fresh keyframe", isKeyFrame: true, capturedAtMs: 10_060, wantAllowed: true, wantEvent: FrameGateEventRecovered},
|
||||||
|
{name: "publishes delta after recovery", capturedAtMs: 10_080, wantAllowed: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
allowed, event := gate.Allow(test.isKeyFrame, test.capturedAtMs, now, maxAge)
|
||||||
|
if allowed != test.wantAllowed {
|
||||||
|
t.Fatalf("Allow() allowed = %t, want %t", allowed, test.wantAllowed)
|
||||||
|
}
|
||||||
|
if event != test.wantEvent {
|
||||||
|
t.Fatalf("Allow() event = %d, want %d", event, test.wantEvent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrameGateAllowsMissingCaptureTime(t *testing.T) {
|
||||||
|
gate := FrameGate{}
|
||||||
|
allowed, event := gate.Allow(true, 0, time.Now(), time.Second)
|
||||||
|
if !allowed || event != FrameGateEventStarted {
|
||||||
|
t.Fatalf("Allow() = (%t, %d), want (true, %d)", allowed, event, FrameGateEventStarted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultMoQRelayURL = "https://relay.uug.ai/anon"
|
defaultMoQRelayURL = "https://relay.uug.ai/anon"
|
||||||
minMoQRetryDelay = time.Second
|
minMoQRetryDelay = time.Second
|
||||||
maxMoQRetryDelay = 30 * time.Second
|
maxMoQRetryDelay = 30 * time.Second
|
||||||
|
maxMoQLivePacketAge = 1500 * time.Millisecond
|
||||||
|
slowMoQWriteThreshold = 100 * time.Millisecond
|
||||||
|
moQWriteWarningInterval = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type liveMoQConfig struct {
|
type liveMoQConfig struct {
|
||||||
@@ -136,7 +139,8 @@ func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
|||||||
defer stream.Finish()
|
defer stream.Finish()
|
||||||
|
|
||||||
cursor := config.queue.Latest()
|
cursor := config.queue.Latest()
|
||||||
writing := false
|
gate := livemoq.FrameGate{}
|
||||||
|
var lastSlowWriteWarning time.Time
|
||||||
for {
|
for {
|
||||||
packet, err := cursor.ReadPacket()
|
packet, err := cursor.ReadPacket()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,12 +149,17 @@ func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
|||||||
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
|
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !writing {
|
allowed, event := gate.Allow(packet.IsKeyFrame, packet.CurrentTime, time.Now(), maxMoQLivePacketAge)
|
||||||
if !packet.IsKeyFrame {
|
switch event {
|
||||||
continue
|
case livemoq.FrameGateEventStarted:
|
||||||
}
|
|
||||||
writing = true
|
|
||||||
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; broadcast is live")
|
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; broadcast is live")
|
||||||
|
case livemoq.FrameGateEventLagging:
|
||||||
|
log.Log.Warning("cloud.publishLiveStreamMoQ(): stream is lagging; dropping packets until a recent keyframe")
|
||||||
|
case livemoq.FrameGateEventRecovered:
|
||||||
|
log.Log.Info("cloud.publishLiveStreamMoQ(): caught up with live stream at a recent keyframe")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
payload, err := livemoq.NormalizeH264AccessUnit(packet.Data)
|
payload, err := livemoq.NormalizeH264AccessUnit(packet.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,8 +169,24 @@ func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
|||||||
Payload: payload,
|
Payload: payload,
|
||||||
TimestampUs: livemoq.TimestampUs(packet.Time),
|
TimestampUs: livemoq.TimestampUs(packet.Time),
|
||||||
}
|
}
|
||||||
|
writeStartedAt := time.Now()
|
||||||
if err := stream.WriteFrame(frame); err != nil {
|
if err := stream.WriteFrame(frame); err != nil {
|
||||||
return fmt.Errorf("write H.264 access unit: %w", err)
|
return fmt.Errorf("write H.264 access unit: %w", err)
|
||||||
}
|
}
|
||||||
|
writeDuration := time.Since(writeStartedAt)
|
||||||
|
if writeDuration >= slowMoQWriteThreshold && time.Since(lastSlowWriteWarning) >= moQWriteWarningInterval {
|
||||||
|
packetAge := time.Duration(0)
|
||||||
|
if packet.CurrentTime > 0 {
|
||||||
|
packetAge = time.Since(time.UnixMilli(packet.CurrentTime))
|
||||||
|
if packetAge < 0 {
|
||||||
|
packetAge = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Log.Warning(fmt.Sprintf(
|
||||||
|
"cloud.publishLiveStreamMoQ(): WriteFrame blocked for %s (packet_age=%s keyframe=%t)",
|
||||||
|
writeDuration.Round(time.Millisecond), packetAge.Round(time.Millisecond), packet.IsKeyFrame,
|
||||||
|
))
|
||||||
|
lastSlowWriteWarning = time.Now()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user