Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/AudioRebuildPolicyTests.swift
enricobuehler e0c10bad85 fix(apple): the macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s
The voice-processing engine cannot start on some input devices (field case:
a 6-channel interface — 'combined engine failed to start', every time). The
device-change recovery re-tried it on every rebuild, and the failed attempt's
HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy
fallback engines, which posted the AVAudioEngineConfigurationChange that
scheduled the next rebuild: a self-sustaining ~2.5 s loop for the session's
whole life. Each ~1.9 s rebuild runs on the main thread — where macOS input
capture and sending live — so the stream's INPUT cut out on the same beat,
while video (own socket, own threads) ran untouched; the wire signature
matched network loss and the host's METRONOMIC heuristic pointed at the
display stack, which is what made the field report so misleading.

Three defenses, layered because no single one covers every feedback shape:
a VPIO start failure latches per input device (CombinedTopologyGate — a
rebuild goes straight to the split topology; a different default input earns
exactly one fresh attempt); a configuration change posted by an engine that
is RUNNING is the rebuild's own echo and is ignored (an engine stops itself
before posting, so a live poster was already restarted); and rebuilds that
chain anyway back off exponentially (RebuildBackoff, 0.5 s floor doubling to
a 30 s cap, reset by 10 s of quiet) with a WARN that names the condition.

Both policies extracted to AudioRebuildPolicy.swift where a unit test can
reach them: 7 new tests, the loop test plant-the-defect verified (the shipped
flat floor produces 800 rebuilds in the 10-minute sim; the ladder ≤ 25, and
responsiveness after quiet is asserted). iOS/tvOS semantics untouched.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
2026-08-14 12:40:51 +02:00

111 lines
5.0 KiB
Swift

// The two decisions that ended the 2026-08-14 rebuild loop, driven with a synthetic clock.
//
// The loop's shape, for the plant-the-defect cases below: the voice-processing engine fails to
// start (~1.9 s spent trying), the fallback comes up, and its own HAL fallout retriggers the
// recovery ~0.6 s later — forever. Restore either defect (retry the failed topology, or keep the
// flat 0.5 s floor) and the session pays an audio gap every ~2.5 s for as long as it lives.
import XCTest
@testable import PunktfunkKit
final class AudioRebuildPolicyTests: XCTestCase {
// MARK: - RebuildBackoff
/// The first trigger of a session keeps the old behaviour: the burst-coalescing debounce.
func testFirstTriggerWaitsOnlyTheDebounce() {
var backoff = RebuildBackoff()
XCTAssertEqual(backoff.delay(now: 1000), RebuildBackoff.debounce)
}
/// One rebuild, then quiet: the next real device switch minutes later is answered at full
/// responsiveness — the ladder must never make a HEALTHY recovery sluggish.
func testAnIsolatedSwitchLongAfterTheLastRebuildResetsTheChain() {
var backoff = RebuildBackoff()
_ = backoff.delay(now: 1000)
backoff.noteRebuild(at: 1000.2)
// Chained once (a second switch soon after — legitimate, e.g. AirPods out then back in).
_ = backoff.delay(now: 1001)
backoff.noteRebuild(at: 1002)
// Minutes of quiet, then a fresh switch: base debounce again, chain forgotten.
XCTAssertEqual(backoff.delay(now: 1300), RebuildBackoff.debounce)
XCTAssertEqual(backoff.chain, 0)
}
/// THE FIELD LOOP, against the real constants: a trigger 0.6 s after every rebuild, ten
/// minutes long. The flat 0.5 s floor produced a rebuild every ~2.5 s — ~240 audio gaps.
/// The ladder must cut that by an order of magnitude and settle at the floor cap.
func testAChainedLoopBacksOffToTheFloorCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
var rebuilds = 0
var lastDelay: TimeInterval = 0
let end: TimeInterval = 600
while now < end {
lastDelay = backoff.delay(now: now)
now += lastDelay // the scheduled rebuild fires...
backoff.noteRebuild(at: now)
rebuilds += 1
now += 0.6 // ...and its fallout retriggers the recovery 0.6 s later.
}
XCTAssertEqual(
lastDelay, RebuildBackoff.floorCap - 0.6, accuracy: 0.01,
"a persistent loop should settle at one rebuild per floorCap")
XCTAssertLessThanOrEqual(
rebuilds, 30,
"\(rebuilds) rebuilds in 10 min — the ladder is not escalating (the shipped flat "
+ "floor produced ~240)")
// And the loop's END must restore responsiveness: quiet, then a real switch.
XCTAssertEqual(backoff.delay(now: now + 120), RebuildBackoff.debounce)
}
/// The ladder's exponent is clamped — a loop that runs for hours must neither overflow nor
/// push the interval past the cap.
func testTheFloorNeverExceedsTheCap() {
var backoff = RebuildBackoff()
var now: TimeInterval = 0
for _ in 0..<1000 {
let delay = backoff.delay(now: now)
XCTAssertLessThanOrEqual(delay, RebuildBackoff.floorCap)
now += delay
backoff.noteRebuild(at: now)
now += 0.1
}
}
#if os(macOS)
// MARK: - CombinedTopologyGate
/// The loop's fuel: re-attempting the voice-processing start that just failed. Same input
/// device ⇒ never again.
func testAFailureLatchesForTheDeviceItFailedOn() {
var gate = CombinedTopologyGate()
XCTAssertTrue(gate.shouldTry(input: 42), "an unfailed gate must allow the attempt")
gate.noteFailure(input: 42)
XCTAssertFalse(gate.shouldTry(input: 42))
XCTAssertFalse(gate.shouldTry(input: 42), "the latch must hold across rebuilds")
}
/// The failure is a property of the DEVICE: a different default input earns a fresh attempt,
/// and its own failure latches again — one attempt per device change can never loop.
func testADifferentInputDeviceEarnsOneFreshAttempt() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: 42)
XCTAssertTrue(gate.shouldTry(input: 7))
gate.noteFailure(input: 7)
XCTAssertFalse(gate.shouldTry(input: 7))
// Back to the first device: the earlier failure may have been mid-transition — one fresh
// attempt again, not a permanent ban.
XCTAssertTrue(gate.shouldTry(input: 42))
}
/// "No resolvable input device" is a real failure key too, distinct from "never failed".
func testFailingWithNoInputDeviceLatchesForNoInputDevice() {
var gate = CombinedTopologyGate()
gate.noteFailure(input: nil)
XCTAssertFalse(gate.shouldTry(input: nil))
XCTAssertTrue(gate.shouldTry(input: 42), "a device appearing is a device change")
}
#endif
}