Skip to content

Commit 671b704

Browse files
joeblauclaude
andauthored
feat: SRT/WHIP resilience parity via a shared supervision layer (#20) (#31)
SRT/WHIP — chosen for low latency — were the LEAST resilient transports: no NWPath supervision, no queue-stall watchdog, no per-path bitrate ceiling, an inert VideoFrameAdmission (setQueueDepth never called → admit() always true), no mic-stall failover, and a plain 1s→30s backoff that burned reconnect attempts while the network was down. Extract RTMP's supervision DECISIONS into a shared, unit-tested StreamCore layer and adopt it in both publishers: - StreamCore (pure, CI-tested; +27 tests): ReconnectBackoff (jittered 1s→30s ladder), classifyPathTransition (path-update classifier), WatchdogEvaluator (queue-stall recycle math), MicStallEvaluator, PathDebounce. Relocated VideoFrameAdmission + NetworkPathSnapshot here (public). The tests pin RTMP's exact constants/rules — including the frozen-queue-no-recycle (app-backgrounding) and metadata-flip-no-clock-bump guards — so the extraction can't drift RTMP's hard-won behavior. - RTMPPublisher: delegates ONLY isolated arithmetic to those deciders; control flow, socket-close ordering, and supervisors are byte-for-byte unchanged (−141 lines, no behavior change). - SessionPublisher: gains the full supervision additively — NWPath supervision, a 2s queue-stall watchdog, single-flight path-gated jittered reconnect (parks instead of burning attempts while down), per-path setPathProfile ceilings (re-seeded on reconnect), live frame-shedding (setQueueDepth + reset), mic-stall failover, and setMaxRetryCount(0) so HaishinKit's retry can't race the app loop. Recovery kicked off from a path debounce runs on a dedicated task so a route-return cancellation can't contaminate the parking reconnect loop. Closes all ~5 gaps. Built via an understand→design pass and three adversarial multi-agent review passes, which caught and this fixes a permanent-dead-air-after-dropout bug and a backoff-defeating task-cancellation bug. StreamCore: 96 tests pass; full app builds against the iOS 27 SDK. Network resilience itself is device-only and must be validated on hardware. fixes #20 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ecf570e commit 671b704

10 files changed

Lines changed: 1052 additions & 144 deletions

StreamBroadcast/RTMPPublisher.swift

Lines changed: 32 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ actor RTMPPublisher: Publisher {
5050
private var isPaused = false
5151
private var streamAttached = false
5252
private var recoveryInProgress = false
53-
private var nextRecoveryDelay: UInt64 = 1_000_000_000
53+
/// Shared jittered exponential backoff ladder (1s→30s); reset on a successful
54+
/// reconnect, after 30s stable, or when a fresh route appears.
55+
private var backoff = ReconnectBackoff()
5456
private var lastMediaAt = DispatchTime.now().uptimeNanoseconds
5557
private var timeline = MediaTimelineNormalizer()
5658
private var hasPublished = false
@@ -75,8 +77,8 @@ actor RTMPPublisher: Publisher {
7577
private var lastVideoAppendAt: UInt64 = 0
7678
private var lastVideoPTS: CMTime = .negativeInfinity // monotonic guard for frame-repeat
7779
private var frameRepeatTask: Task<Void, Never>?
78-
private var lastQueueBytes: Int = 0 // watchdog: detect a draining queue
79-
private var stalledTicks: Int = 0 // watchdog: consecutive stalled checks
80+
/// Shared outbound-queue stall watchdog state (stalledTicks + lastQueueBytes).
81+
private var watchdog = WatchdogState()
8082

8183
init() {
8284
(micStream, micCont) = AsyncStream.makeStream(of: CMSampleBuffer.self, bufferingPolicy: .unbounded)
@@ -375,8 +377,7 @@ actor RTMPPublisher: Publisher {
375377
// The fresh connection starts admitting every frame; shedding only
376378
// re-raises via checkNetworkHealth if congestion actually returns.
377379
videoAdmission.reset()
378-
lastQueueBytes = 0
379-
stalledTicks = 0
380+
watchdog = WatchdogState()
380381
if streamAttached {
381382
await mixer.removeOutput(stream)
382383
streamAttached = false
@@ -392,7 +393,6 @@ actor RTMPPublisher: Publisher {
392393
/// the loop parks at zero cost instead of burning backoff doublings (each
393394
/// dead attempt would also eat RTMPSocket's hardcoded 15 s connect timeout).
394395
private func reconnect(immediately: Bool) async {
395-
let maxDelay: UInt64 = 30_000_000_000
396396
var attempt = 0
397397
var shouldDelay = !immediately
398398
while isRunning, !userInitiatedStop {
@@ -403,7 +403,7 @@ actor RTMPPublisher: Publisher {
403403
// reset budget; a flapping path costs at most one fast attempt
404404
// per transition (RTMPSocket fast-fails on .waiting).
405405
shouldDelay = false
406-
nextRecoveryDelay = 1_000_000_000
406+
backoff.reset()
407407
}
408408
if shouldDelay {
409409
await interruptibleBackoffSleep()
@@ -420,7 +420,7 @@ actor RTMPPublisher: Publisher {
420420
// across consecutive FAILED attempts (catch branch). Escalating on
421421
// success turned a burst of (individually recoverable) false recycles
422422
// into progressively longer 2→4→8→…→30s dead-air windows.
423-
nextRecoveryDelay = 1_000_000_000
423+
backoff.reset()
424424
streamLog.info("RTMP reconnected after \(attempt) attempt(s)")
425425
stableConnectionTask?.cancel()
426426
stableConnectionTask = Task { [weak self] in
@@ -440,16 +440,15 @@ actor RTMPPublisher: Publisher {
440440
_ = try? await connection.close()
441441
let described = describe(error)
442442
streamLog.error("RTMP reconnect attempt \(attempt) failed: \(described.localizedDescription, privacy: .public)")
443-
nextRecoveryDelay = min(nextRecoveryDelay * 2, maxDelay)
443+
backoff.escalate()
444444
}
445445
}
446446
}
447447

448448
/// Sleeps the current backoff with jitter. A path event (or stop) cancels
449449
/// `backoffSleepTask` to end the wait early; early wake is progress, not error.
450450
private func interruptibleBackoffSleep() async {
451-
let jitter = Double.random(in: 0.8...1.2)
452-
let nanoseconds = UInt64(Double(nextRecoveryDelay) * jitter)
451+
let nanoseconds = backoff.jittered()
453452
let sleeper = Task { try await Task.sleep(nanoseconds: nanoseconds) }
454453
backoffSleepTask = sleeper
455454
defer { backoffSleepTask = nil }
@@ -604,7 +603,7 @@ actor RTMPPublisher: Publisher {
604603
}
605604

606605
private func wakeBackoff(resetDelay: Bool) {
607-
if resetDelay { nextRecoveryDelay = 1_000_000_000 }
606+
if resetDelay { backoff.reset() }
608607
backoffSleepTask?.cancel()
609608
}
610609

@@ -617,7 +616,7 @@ actor RTMPPublisher: Publisher {
617616

618617
private func markConnectionStable() {
619618
guard isRunning, !userInitiatedStop else { return }
620-
nextRecoveryDelay = 1_000_000_000
619+
backoff.reset()
621620
}
622621

623622
/// HaishinKit exposes queue telemetry only through StreamBitRateStrategy. A
@@ -638,73 +637,39 @@ actor RTMPPublisher: Publisher {
638637
// No queue-health conclusion can be drawn while capture is paused or
639638
// the device is showing static content and no recent samples arrived.
640639
// Reset the stall streak too — a gap in these ticks is not a stall.
641-
guard !isPaused else { stalledTicks = 0; return }
640+
guard !isPaused else { watchdog.stalledTicks = 0; return }
642641
let now = DispatchTime.now().uptimeNanoseconds
643-
guard now &- lastMediaAt < 10_000_000_000 else { stalledTicks = 0; return }
642+
guard now &- lastMediaAt < 10_000_000_000 else { watchdog.stalledTicks = 0; return }
644643
// Mic-stall fallback (control plane only): HaishinKit's multitrack mixer
645644
// renders the mix only when the MAIN track appends, so a dead mic route
646645
// would silence app audio too. If app audio is flowing but the mic went
647646
// quiet, promote track 1 to the mix clock; the first mic buffer back
648647
// flips it home (see appendMic). No timeline rebase on either side —
649648
// video/app kept flowing, so mic samples re-enter already aligned.
650-
if settings.includeAppAudio, !micTrackStalled,
651-
lastMicAppendAt > 0,
652-
now &- lastAppAppendAt < 2_000_000_000,
653-
now &- lastMicAppendAt > 4_000_000_000 {
649+
if settings.includeAppAudio, !micTrackStalled, lastMicAppendAt > 0,
650+
MicStallEvaluator.shouldPromoteApp(now: now,
651+
lastMicAppendAt: lastMicAppendAt,
652+
lastAppAppendAt: lastAppAppendAt) {
654653
micTrackStalled = true
655654
await applyAudioMixerSettings()
656655
streamLog.warning("Mic buffers stalled >4s; app audio is now the mix clock")
657656
}
658657
let health = await networkController.healthSnapshot()
659658
videoAdmission.setQueueDepth(health.queueBytes)
660-
// A growing queue by itself means congestion, not a dead connection. The
661-
// adaptive controller needs time to lower the encoder rate and drain it.
662-
// Reconnect only when progress has stopped, telemetry has stopped, or the
663-
// backlog is large enough that viewers would receive several stale seconds.
664-
// For 10 s after a network path change the tolerance halves: the old flow
665-
// is known-suspect, so a genuine stall should recycle fast.
666-
let recentPathChange = lastPathChangeAt > 0 && now &- lastPathChangeAt < 10_000_000_000
667-
let queueLimit = recentPathChange ? 2_000_000 : 4_000_000
668-
let zeroLimit = recentPathChange ? 2 : 4
669-
// A deep queue is only a recycle reason if it is NOT draining — a transient
670-
// backlog that is already shrinking clears on its own once the ABR lowers
671-
// the encoder rate. zeroOutputSeconds independently catches a truly stalled
672-
// (bytesOut==0) queue.
673-
//
674-
// Require the backlog to be strictly GROWING (`>`), not merely frozen (`>=`):
675-
// when the container app is backgrounded the OS starves the ~1Hz ABR timer
676-
// that feeds queueBytes/zeroOutputSeconds, so those figures FREEZE at their
677-
// last value. A frozen-equal queue (`>=`) read that as a permanent stall and
678-
// recycled a socket the fork deliberately keeps open across `.waiting`. Only
679-
// a queue that keeps climbing is genuinely wedged.
680-
let queueStuck = health.queueBytes >= queueLimit && health.queueBytes > lastQueueBytes
681-
lastQueueBytes = health.queueBytes
682-
// eventAgeSeconds is intentionally NOT a recycle condition. It is the age of
683-
// HaishinKit's ~1Hz ABR callback (a wall-clock timer, default QoS), which the
684-
// OS starves when the container app is backgrounded behind another app — it
685-
// is NOT a probe of the socket. The connection.connected + readyState guards
686-
// above already detect a genuinely dead socket, and queue/zeroOutput cover
687-
// real stalls. Recycling on it tore down healthy connections on every
688-
// app-switch — the primary cause of the "Connection lost" dropouts.
689-
//
690-
// Even a real stall must PERSIST before we recycle: an app-switch drives the
691-
// socket into a transient `.waiting` that the RTMPSocket fork keeps open and
692-
// that recovers on the SAME session (no reconnect) once a usable path returns
693-
// — often the instant the app is foregrounded. Tearing it down after a single
694-
// 2 s tick converted that survivable blip into a hard, destination-visible
695-
// RTMP disconnect. Require several consecutive stalled ticks (~12 s, or ~6 s
696-
// right after a path change when the flow is already suspect) so the socket
697-
// layer's keep-open policy is honoured instead of fought.
698-
if queueStuck || health.zeroOutputSeconds >= zeroLimit {
699-
stalledTicks += 1
700-
} else {
701-
stalledTicks = 0
702-
}
703-
let ticksToRecycle = recentPathChange ? 3 : 6
704-
if stalledTicks >= ticksToRecycle {
705-
let ticks = stalledTicks
706-
streamLog.error("RTMP socket stalled \(ticks) ticks: queue=\(health.queueBytes) bytes, zeroOut=\(health.zeroOutputSeconds)s, target=\(health.targetBitRate) bps")
707-
stalledTicks = 0
659+
// The stall math — strictly-GROWING queue (not merely frozen, which happens
660+
// when a backgrounded app starves the ~1Hz telemetry), independent zero-output
661+
// stall, N-tick persistence, and the halved tolerance for 10s after a path
662+
// change — lives in the shared, unit-tested WatchdogEvaluator. eventAgeSeconds
663+
// is deliberately NOT an input (it freezes on backgrounding); the
664+
// connected/readyState guards above already catch a genuinely dead socket.
665+
let recentPathChange = lastPathChangeAt > 0
666+
&& now &- lastPathChangeAt < PathDebounce.recentPathChangeWindow
667+
let decision = WatchdogEvaluator.evaluate(queueBytes: health.queueBytes,
668+
zeroOutputSeconds: health.zeroOutputSeconds,
669+
recentPathChange: recentPathChange,
670+
state: &watchdog)
671+
if decision.shouldRecycle {
672+
streamLog.error("RTMP socket stalled \(decision.stalledTicks) ticks: queue=\(health.queueBytes) bytes, zeroOut=\(health.zeroOutputSeconds)s, target=\(health.targetBitRate) bps")
708673
await requestRecovery(reason: "outbound queue stalled")
709674
}
710675
}
@@ -916,48 +881,6 @@ actor RTMPPublisher: Publisher {
916881
}
917882
}
918883

919-
/// Bounded video-frame admission under outbound congestion. HaishinKit's RTMP
920-
/// send queue is unbounded, so when the uplink can't drain it, SHEDDING input
921-
/// video frames (proportional to how deep the queue is) keeps glass-to-glass
922-
/// latency bounded instead of letting it grow to seconds and then hard-recycling.
923-
/// Audio is never dropped. The depth is refreshed ~every 2s from the real socket
924-
/// queue by checkNetworkHealth; this is the fast, coarse bound that complements
925-
/// the encoder-level bitrate/frame-rate reductions the ABR already applies.
926-
final class VideoFrameAdmission: @unchecked Sendable {
927-
private struct State { var keep = 1; var period = 1; var counter = 0 }
928-
private let lock = OSAllocatedUnfairLock<State>(initialState: State())
929-
930-
/// Maps outbound queue depth to a keep/period frame-pacing ratio.
931-
func setQueueDepth(_ bytes: Int) {
932-
let keep: Int, period: Int
933-
switch bytes {
934-
case ..<524_288: (keep, period) = (1, 1) // < 0.5 MB: keep every frame
935-
case ..<1_048_576: (keep, period) = (2, 3) // 0.5-1 MB: drop 1 in 3
936-
case ..<2_097_152: (keep, period) = (1, 2) // 1-2 MB: drop 1 in 2
937-
default: (keep, period) = (1, 3) // > 2 MB: drop 2 in 3
938-
}
939-
lock.withLock { state in
940-
if state.period != period { state.counter = 0 }
941-
state.keep = keep
942-
state.period = period
943-
}
944-
}
945-
946-
/// True when this video frame should be encoded and sent.
947-
func admit() -> Bool {
948-
lock.withLock { state in
949-
guard state.period > 1 else { return true }
950-
let keep = state.counter % state.period < state.keep
951-
state.counter &+= 1
952-
return keep
953-
}
954-
}
955-
956-
func reset() {
957-
lock.withLock { $0 = State() }
958-
}
959-
}
960-
961884
/// Re-timestamps a video sample buffer to an explicit PTS.
962885
func restampVideoBuffer(_ sb: CMSampleBuffer, pts: CMTime) -> CMSampleBuffer? {
963886
var timing = CMSampleTimingInfo(duration: .invalid,

0 commit comments

Comments
 (0)