@@ -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.
962885func restampVideoBuffer( _ sb: CMSampleBuffer , pts: CMTime ) -> CMSampleBuffer ? {
963886 var timing = CMSampleTimingInfo ( duration: . invalid,
0 commit comments