Skip to content

Commit 8110d85

Browse files
joeblauclaude
andcommitted
fix: audio-mix and stop/start races in both publishers
Two pre-existing robustness gaps shared by RTMPPublisher and SessionPublisher, surfaced by the issue #20 resilience review. Fixed in both via the shared layer. 1. App-audio mix went permanently silent if the mic route was dead from go-live. HaishinKit renders the multitrack mix only when the MAIN track (mic, track 0) appends; the mic-stall failover only promoted app audio to the main track once the mic had appended at least once (`lastMicAppendAt > 0`), so a mic dead from the start never promoted and the whole mix — app audio included — stayed silent forever. MicStallEvaluator now measures mic silence from `startedAt` (go-live) when the mic has never appeared, so a dead-from-start route still promotes app audio once the grace window passes. The previously-working stall path is byte-for-byte unchanged (micReference == lastMicAppendAt when > 0). 2. stop() during start()'s pre-`isRunning` setup leaked long-lived tasks. Both publishers ran their setup awaits (factory/mixer setup + startRunning) before setting isRunning and spawning the path-supervisor / watchdog / frame-repeat tasks; a stop() interleaving there early-returned via `guard isRunning`, then start() resumed and spawned tasks it could never cancel (leaving a 2s watchdog timer waking forever and the mixer running). Added a `guard !userInitiatedStop` bail-out after mixer.startRunning in both, plus a second re-check in RTMP after the two status awaits (a narrower window Session doesn't have) so the supervisors/watchdog are never spawned post-teardown. RTMP's normal go-live path is unchanged (both guards fire only on a concurrent stop). Reviewed adversarially. StreamCore: 99 tests pass (+3 mic-stall cases); full app builds against the iOS 27 SDK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 671b704 commit 8110d85

4 files changed

Lines changed: 80 additions & 14 deletions

File tree

StreamBroadcast/RTMPPublisher.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ actor RTMPPublisher: Publisher {
151151
private var lastMicAppendAt: UInt64 = 0
152152
private var lastAppAppendAt: UInt64 = 0
153153
private var micTrackStalled = false
154+
/// When the broadcast went live, so a mic route that is dead from the START (no
155+
/// buffer ever) still trips the failover — its silence is measured from here.
156+
private var startedAt: UInt64 = 0
154157

155158
/// Builds + starts the pipeline, connects, and begins publishing. The video
156159
/// size is NOT set here — it is locked from the first frame via `setOutputSize`.
@@ -174,6 +177,11 @@ actor RTMPPublisher: Publisher {
174177

175178
await mixer.startRunning()
176179

180+
// stop() may have interleaved during the setup awaits above (actor reentrancy)
181+
// and early-returned via its `guard isRunning` branch before we set isRunning.
182+
// Bail out cleanly rather than spawn long-lived tasks it could never cancel.
183+
guard !userInitiatedStop else { await mixer.stopRunning(); return }
184+
177185
if settings.backupEnabled {
178186
streamLog.warning("Local backup disabled: a second real-time video encoder is not enabled")
179187
}
@@ -182,6 +190,7 @@ actor RTMPPublisher: Publisher {
182190
// and publish failures therefore retry just like a mid-stream drop instead
183191
// of ending the user-owned capture session.
184192
isRunning = true
193+
startedAt = DispatchTime.now().uptimeNanoseconds
185194
startAudioConsumers()
186195
startFrameRepeat()
187196
// 4th long-lived task, spawned BEFORE the first connect so path gating
@@ -209,6 +218,10 @@ actor RTMPPublisher: Publisher {
209218
// attempts cannot be replayed as stale recovery events.
210219
let connectionStatuses = await connection.status
211220
let streamStatuses = await stream.status
221+
// stop() can interleave during the two status awaits above (actor reentrancy)
222+
// after passing its own `guard isRunning`; re-check so the supervisors and the
223+
// watchdog timer aren't spawned — and then leaked, uncancellable — post-teardown.
224+
guard isRunning, !userInitiatedStop else { return }
212225

213226
connectionSupervisorTask = Task { [weak self] in
214227
await self?.superviseConnection(connectionStatuses)
@@ -646,10 +659,11 @@ actor RTMPPublisher: Publisher {
646659
// quiet, promote track 1 to the mix clock; the first mic buffer back
647660
// flips it home (see appendMic). No timeline rebase on either side —
648661
// video/app kept flowing, so mic samples re-enter already aligned.
649-
if settings.includeAppAudio, !micTrackStalled, lastMicAppendAt > 0,
662+
if settings.includeAppAudio, !micTrackStalled, startedAt > 0,
650663
MicStallEvaluator.shouldPromoteApp(now: now,
651664
lastMicAppendAt: lastMicAppendAt,
652-
lastAppAppendAt: lastAppAppendAt) {
665+
lastAppAppendAt: lastAppAppendAt,
666+
startedAt: startedAt) {
653667
micTrackStalled = true
654668
await applyAudioMixerSettings()
655669
streamLog.warning("Mic buffers stalled >4s; app audio is now the mix clock")

StreamBroadcast/SessionPublisher.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ actor SessionPublisher: Publisher {
100100
private var micTrackStalled = false
101101
private var lastMicAppendAt: UInt64 = 0
102102
private var lastAppAppendAt: UInt64 = 0
103+
/// When the broadcast went live, so a mic route dead from the START (no buffer
104+
/// ever) still trips the failover — its silence is measured from here.
105+
private var startedAt: UInt64 = 0
103106
/// Proactive network-path supervision (Wi-Fi <-> 5G handoffs, dead zones).
104107
private var currentPath: NetworkPathSnapshot?
105108
private var lastPathChangeAt: UInt64 = 0
@@ -178,7 +181,13 @@ actor SessionPublisher: Publisher {
178181
await mixer.setVideoMixerSettings(vm)
179182
await mixer.startRunning()
180183

184+
// stop() may have interleaved during the setup awaits above (actor reentrancy)
185+
// and early-returned via its `guard isRunning` branch before we set isRunning.
186+
// Bail out cleanly rather than spawn long-lived tasks it could never cancel.
187+
guard !userInitiatedStop else { await mixer.stopRunning(); return }
188+
181189
isRunning = true
190+
startedAt = DispatchTime.now().uptimeNanoseconds
182191
startAudioConsumers()
183192
startFrameRepeat()
184193
// Path supervision spawned BEFORE the first connect so path gating covers it.
@@ -551,10 +560,11 @@ actor SessionPublisher: Publisher {
551560
guard now &- lastMediaAt < 10_000_000_000 else { watchdog.stalledTicks = 0; return }
552561
// Mic-stall failover: app audio flowing but the mic gone quiet -> promote
553562
// track 1 to the mix clock; the first mic buffer back flips it home.
554-
if settings.includeAppAudio, !micTrackStalled, lastMicAppendAt > 0,
563+
if settings.includeAppAudio, !micTrackStalled, startedAt > 0,
555564
MicStallEvaluator.shouldPromoteApp(now: now,
556565
lastMicAppendAt: lastMicAppendAt,
557-
lastAppAppendAt: lastAppAppendAt) {
566+
lastAppAppendAt: lastAppAppendAt,
567+
startedAt: startedAt) {
558568
micTrackStalled = true
559569
await applyAudioMixerSettings()
560570
sessionLog.warning("Mic buffers stalled >4s; app audio is now the mix clock")

StreamCore/MicStallEvaluator.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,20 @@ public enum MicStallEvaluator {
1717
public static let micStallThreshold: UInt64 = 4_000_000_000 // 4s
1818

1919
/// Whether app audio should take over the mix clock. The caller pre-checks the
20-
/// preconditions (`includeAppAudio`, not already stalled, `lastMicAppendAt > 0`)
21-
/// and owns the mixer re-apply; this is only the timing test. Uses wrapping
22-
/// subtraction to match the publisher's `uptimeNanoseconds` arithmetic.
20+
/// preconditions (`includeAppAudio`, not already stalled, capture live) and owns
21+
/// the mixer re-apply; this is only the timing test. Uses wrapping subtraction to
22+
/// match the publisher's `uptimeNanoseconds` arithmetic.
23+
///
24+
/// `startedAt` is when the broadcast went live. When the mic has NEVER produced a
25+
/// buffer (`lastMicAppendAt == 0`) its silence is measured from go-live, so a mic
26+
/// route that is dead from the start still promotes app audio — otherwise the main
27+
/// (mic) track never appends and HaishinKit renders the WHOLE mix silent, dropping
28+
/// the flowing app audio too.
2329
public static func shouldPromoteApp(now: UInt64,
2430
lastMicAppendAt: UInt64,
25-
lastAppAppendAt: UInt64) -> Bool {
26-
now &- lastAppAppendAt < appFreshWindow && now &- lastMicAppendAt > micStallThreshold
31+
lastAppAppendAt: UInt64,
32+
startedAt: UInt64) -> Bool {
33+
let micReference = lastMicAppendAt > 0 ? lastMicAppendAt : startedAt
34+
return now &- lastAppAppendAt < appFreshWindow && now &- micReference > micStallThreshold
2735
}
2836
}

StreamCoreTests/SharedSupervisionTests.swift

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,36 +173,70 @@ import StreamCore
173173
}
174174

175175
@Suite struct MicStallEvaluatorTests {
176+
// startedAt is irrelevant once the mic has appended at least once (micReference
177+
// = lastMicAppendAt); a fixed early value keeps these focused on the stall path.
178+
private let started: UInt64 = 1_000_000_000
179+
176180
@Test("App audio fresh + mic silent past 4s promotes app to the mix clock")
177181
func promotesWhenMicStalls() {
178182
#expect(MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
179183
lastMicAppendAt: 5_000_000_000, // 5s ago
180-
lastAppAppendAt: 9_000_000_000)) // 1s ago
184+
lastAppAppendAt: 9_000_000_000, // 1s ago
185+
startedAt: started))
181186
}
182187

183188
@Test("A mic quiet for under 4s does not promote")
184189
func micNotYetStalled() {
185190
#expect(!MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
186191
lastMicAppendAt: 7_000_000_000, // 3s ago
187-
lastAppAppendAt: 9_000_000_000))
192+
lastAppAppendAt: 9_000_000_000,
193+
startedAt: started))
188194
}
189195

190196
@Test("Stale app audio does not promote (nothing to promote to)")
191197
func appNotFresh() {
192198
#expect(!MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
193199
lastMicAppendAt: 5_000_000_000,
194-
lastAppAppendAt: 7_000_000_000)) // 3s ago > 2s
200+
lastAppAppendAt: 7_000_000_000, // 3s ago > 2s
201+
startedAt: started))
195202
}
196203

197204
@Test("Thresholds are exclusive at the exact boundary")
198205
func boundaries() {
199206
// micAge exactly 4s -> not stalled (strict >); appAge exactly 2s -> not fresh (strict <).
200207
#expect(!MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
201208
lastMicAppendAt: 6_000_000_000, // exactly 4s
202-
lastAppAppendAt: 9_000_000_000))
209+
lastAppAppendAt: 9_000_000_000,
210+
startedAt: started))
203211
#expect(!MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
204212
lastMicAppendAt: 5_000_000_000,
205-
lastAppAppendAt: 8_000_000_000)) // exactly 2s
213+
lastAppAppendAt: 8_000_000_000, // exactly 2s
214+
startedAt: started))
215+
}
216+
217+
@Test("A mic dead from go-live still promotes app audio once past the grace window")
218+
func promotesWhenMicNeverAppeared() {
219+
// lastMicAppendAt == 0: silence is measured from startedAt (go-live).
220+
#expect(MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
221+
lastMicAppendAt: 0, // never appeared
222+
lastAppAppendAt: 9_000_000_000, // app flowing
223+
startedAt: 1_000_000_000)) // 9s since go-live
224+
}
225+
226+
@Test("A mic dead from go-live does not promote within the grace window")
227+
func withinGraceAfterGoLive() {
228+
#expect(!MicStallEvaluator.shouldPromoteApp(now: 5_000_000_000,
229+
lastMicAppendAt: 0,
230+
lastAppAppendAt: 4_500_000_000, // app fresh
231+
startedAt: 2_000_000_000)) // only 3s since go-live
232+
}
233+
234+
@Test("A mic dead from go-live with no app audio does not promote")
235+
func neverAppearedButNoAppAudio() {
236+
#expect(!MicStallEvaluator.shouldPromoteApp(now: 10_000_000_000,
237+
lastMicAppendAt: 0,
238+
lastAppAppendAt: 0, // app never flowed
239+
startedAt: 1_000_000_000))
206240
}
207241
}
208242

0 commit comments

Comments
 (0)