From 705b74744e127aed202ce63c8e12b24eb49d79b4 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 20:36:14 +0700 Subject: [PATCH 1/8] perf(ios): pace capture healthy-state watchdog polls Co-authored-by: Cursor --- .../friend/ios/phonemic/PhoneMicPigeon.g.kt | 5 +- .../PhoneMic/PhoneMicBatchAudioWriter.swift | 4 +- .../Runner/PhoneMic/PhoneMicController.swift | 82 ++++++-- .../PhoneMic/PhoneMicEventEmitter.swift | 3 +- .../Runner/PhoneMic/PhoneMicPigeon.g.swift | 10 +- .../phone_mic_lifecycle_replay_test.swift | 114 +++++++++++ app/lib/phone_mic_interface.dart | 5 +- .../mic/native_mic_recorder_service.dart | 181 +++++++++++++++-- app/lib/services/services.dart | 9 +- .../native_mic_recorder_service_test.dart | 183 ++++++++++++++++++ 10 files changed, 552 insertions(+), 44 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt b/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt index f381412fce5..86e536ee0e9 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt @@ -284,8 +284,9 @@ class PhoneMicFlutterApi(private val binaryMessenger: BinaryMessenger, private v } } /** - * Emitted at 1Hz while batch capture runs. Its arrival is the liveness signal - * for the Dart batch watchdog; the value is derived from the number of frames + * Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + * foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart + * batch watchdog; the value is derived from the number of frames * actually written to disk, so mutes and interruptions freeze it while the * event keeps arriving. */ diff --git a/app/ios/Runner/PhoneMic/PhoneMicBatchAudioWriter.swift b/app/ios/Runner/PhoneMic/PhoneMicBatchAudioWriter.swift index c666a2166e4..51fc48f5f6a 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicBatchAudioWriter.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicBatchAudioWriter.swift @@ -144,8 +144,8 @@ final class PhoneMicBatchAudioWriter: BaseBatchAudioWriter { closeCurrentLocked(reason) } - /// Drain the latched storage-full transition. Called by the controller on its - /// 1Hz progress tick (inside `audioQueue.sync`) so at most one onCaptureError + /// Drain the latched storage-full transition. The write path reports the edge + /// immediately; the progress tick is the backup. At most one onCaptureError /// fires per false->true edge. func consumeStorageFullTransitionLocked() -> Bool { let pending = pendingStorageFullReport diff --git a/app/ios/Runner/PhoneMic/PhoneMicController.swift b/app/ios/Runner/PhoneMic/PhoneMicController.swift index 687ae17d9e9..da42844f7d5 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicController.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicController.swift @@ -22,7 +22,13 @@ final class PhoneMicController { private static let bringUpRetryDelay: TimeInterval = 0.35 private static let maxStartRetries = 2 private static let resumeTickInterval: TimeInterval = 3.0 - private static let progressTickInterval: TimeInterval = 1.0 + /// Fault cadence for the batch liveness heartbeat. Interruptions, rebuilds, + /// and the first beat after foreground use this so a gap is still visible + /// inside the Dart 10s batch deadline. + private static let fastProgressTickInterval: TimeInterval = 1.0 + /// Healthy `.running` cadence. 5s keeps a missed beat under the 10s Dart + /// stall threshold (one skipped tick is not itself a stall). + private static let healthyProgressTickInterval: TimeInterval = 5.0 private let controlQueue = DispatchQueue(label: "com.omi.phonemic.control", qos: .userInitiated) private let audioQueue = DispatchQueue(label: "com.omi.phonemic.audio", qos: .userInitiated) @@ -53,6 +59,9 @@ final class PhoneMicController { private var batchWriter: PhoneMicBatchWriting? private var batchMarker = "omibatchphone" private var progressTimer: DispatchSourceTimer? + /// Repeating interval currently armed, so a tick can retarget after a + /// state change that did not reschedule itself. + private var progressIntervalArmed: TimeInterval = 0 init(environment: PhoneMicControllerEnvironment) { self.environment = environment @@ -228,11 +237,12 @@ final class PhoneMicController { state = .running NSLog("[PhoneMic] running, route %@", environment.describeRoute()) emitter.emitState(.running, sessionId: currentSessionId) - // Batch progress ticker: armed once on first reach of running, kept alive + // Batch progress ticker: armed on first reach of running, kept alive // across interruptions/rebuilds (its arrival is the Dart liveness signal), - // cancelled only on stop/idle. - if mode == .batch, progressTimer == nil { - armProgressTimer() + // cancelled only on stop/idle. Healthy running uses the slow cadence; + // this call retargets after a fast anomaly window. + if mode == .batch { + applyProgressCadence() } resolvePendingStarts(.success(())) if pendingStop { @@ -343,6 +353,10 @@ final class PhoneMicController { if engine?.isRunning != true { NSLog("[PhoneMic] app active with dead engine, rebuilding") beginRebuild() + } else if mode == .batch { + // One fast liveness beat, then the healthy cadence. Pulls a + // far-off 5s deadline forward without leaving the 1s poll on. + syncProgressTimer(first: Self.fastProgressTickInterval, repeating: Self.healthyProgressTickInterval) } default: break @@ -382,6 +396,7 @@ final class PhoneMicController { state = .interrupted emitter.emitState(.interrupted, sessionId: currentSessionId) armResumeTicker() + applyProgressCadence() } /// Probe a resume from .interrupted. Failure is silent by design — during @@ -398,6 +413,7 @@ final class PhoneMicController { private func beginRebuild() { state = .rebuilding emitter.emitState(.rebuilding, sessionId: currentSessionId) + applyProgressCadence() if attemptBringUp() == nil { enterRunning() return @@ -465,12 +481,23 @@ final class PhoneMicController { let encoder = batchEncoder let writer = batchWriter let marker = batchMarker - return { data, _ in + // The audio bytes stay on this closure. The weak hop is only the + // storage-full fault edge, so the healthy 5s heartbeat cannot delay it. + return { [weak self] data, _ in guard let encoder, let writer else { return } let packets = encoder.encode(data) if !packets.isEmpty { writer.append(opusPackets: packets, marker: marker) } + if writer.consumeStorageFullTransitionLocked() { + self?.controlQueue.async { [weak self] in + guard let self, self.state != .idle else { return } + self.emitter.emitError( + code: "batch_storage_full", + message: "storage is low; batch capture paused until space frees up", + sessionId: self.currentSessionId) + } + } } } } @@ -519,26 +546,55 @@ final class PhoneMicController { resumeTicker = nil } - private func armProgressTimer() { - cancelProgressTimer() + private func desiredProgressInterval() -> TimeInterval { + state == .running ? Self.healthyProgressTickInterval : Self.fastProgressTickInterval + } + + /// Arm, or retarget, the batch heartbeat. `repeating` is what subsequent + /// ticks use; `first` can be shorter so an anomaly gets one fast beat + /// before returning to the healthy cadence. + private func syncProgressTimer(first: TimeInterval, repeating: TimeInterval) { + guard mode == .batch, state != .idle, batchWriter != nil else { return } + let leeway: DispatchTimeInterval = repeating >= Self.healthyProgressTickInterval + ? .milliseconds(500) + : .milliseconds(50) + if let timer = progressTimer { + timer.schedule(deadline: .now() + first, repeating: repeating, leeway: leeway) + progressIntervalArmed = repeating + return + } let timer = DispatchSource.makeTimerSource(queue: controlQueue) - timer.schedule(deadline: .now() + Self.progressTickInterval, repeating: Self.progressTickInterval) + timer.schedule(deadline: .now() + first, repeating: repeating, leeway: leeway) timer.setEventHandler { [weak self] in - self?.emitBatchProgressTick() + guard let self else { return } + self.emitBatchProgressTick() + let desired = self.desiredProgressInterval() + if self.state != .idle, self.progressIntervalArmed != desired { + self.syncProgressTimer(first: desired, repeating: desired) + } } timer.resume() progressTimer = timer + progressIntervalArmed = repeating + } + + private func applyProgressCadence() { + let interval = desiredProgressInterval() + syncProgressTimer(first: interval, repeating: interval) } private func cancelProgressTimer() { progressTimer?.cancel() progressTimer = nil + progressIntervalArmed = 0 } - /// 1Hz on controlQueue. Reads the writer's committed frame count and drains any - /// storage-full edge on its own (audio) queue, then emits progress. Emitted every + /// Heartbeat on controlQueue. Reads the writer's committed frame count and drains + /// any storage-full edge the write path did not already report, then emits + /// progress. Healthy running is 5s; interrupted/rebuilding is 1s. Emitted every /// tick even while interrupted/muted — the value freezes (frames-written derived), - /// but its arrival is the Dart watchdog's liveness signal. + /// but its arrival is the Dart watchdog's liveness signal. The Dart batch stall + /// threshold stays 10s, so the slower healthy beat is not itself a gap. private func emitBatchProgressTick() { guard state != .idle, let writer = batchWriter else { return } let (frames, storageFullEdge): (Int64, Bool) = audioQueue.sync { diff --git a/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift b/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift index f983e1ae0df..2cfbfc77da6 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift @@ -66,8 +66,9 @@ final class PhoneMicEventEmitter { } } - /// Batch-mode 1Hz liveness/progress. Not epoch-gated: unlike frames it carries + /// Batch-mode liveness/progress. Not epoch-gated: unlike frames it carries /// no audio, and its steady arrival is the Dart watchdog's liveness signal. + /// Healthy iOS capture emits this every 5s; fault states snap back to 1s. func emitBatchProgress(_ capturedSeconds: Double, sessionId: Int64) { DispatchQueue.main.async { [weak self] in self?.sink.onBatchProgress(capturedSeconds: capturedSeconds, sessionId: sessionId) diff --git a/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift b/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift index 7aaaebfefaa..3a0b4cd5a50 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift @@ -235,8 +235,9 @@ protocol PhoneMicFlutterApiProtocol { /// Non-fatal runtime failures (capture self-heals): converter_failed, /// rebuild_failed, resume_failed, media_services_reset, batch_storage_full. func onCaptureError(code codeArg: String, message messageArg: String, sessionId sessionIdArg: Int64, completion: @escaping (Result) -> Void) - /// Emitted at 1Hz while batch capture runs. Its arrival is the liveness signal - /// for the Dart batch watchdog; the value is derived from the number of frames + /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart + /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the /// event keeps arriving. func onBatchProgress(capturedSeconds capturedSecondsArg: Double, sessionId sessionIdArg: Int64, completion: @escaping (Result) -> Void) @@ -310,8 +311,9 @@ class PhoneMicFlutterApi: PhoneMicFlutterApiProtocol { } } } - /// Emitted at 1Hz while batch capture runs. Its arrival is the liveness signal - /// for the Dart batch watchdog; the value is derived from the number of frames + /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart + /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the /// event keeps arriving. func onBatchProgress(capturedSeconds capturedSecondsArg: Double, sessionId sessionIdArg: Int64, completion: @escaping (Result) -> Void) { diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index 08deb452a57..cb94ebf802a 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -120,6 +120,32 @@ final class FakeMonitor: PhoneMicInterruptionSource { func bindEngine(_ engine: AVAudioEngine?) {} } +final class FakeBatchEncoder: PhoneMicBatchEncoding { + func encode(_ pcm: Data) -> [Data] { [Data([0x01])] } + func discardPartial() {} +} + +final class FakeBatchWriter: PhoneMicBatchWriting { + var sessionFramesWritten: Int64 = 0 + var storageFull = false + + func append(opusPackets: [Data], marker: String) { + sessionFramesWritten += Int64(opusPackets.count) + } + + func closeNowLocked(_ reason: String) {} + + func consumeStorageFullTransitionLocked() -> Bool { + if !storageFull { return false } + storageFull = false + return true + } +} + +final class WriterBox { + var writer: FakeBatchWriter? +} + final class FakePermission: PhoneMicPermissionChecking { var status: PhoneMicPermissionStatus var requestResult: Bool @@ -189,6 +215,36 @@ final class ReplayWorld { makeWriter: { _, _ in fatalError("stream-mode vectors never build a batch writer") } )) } + + func makeBatchController(writers: WriterBox) -> PhoneMicController { + let sink = self.sink + let monitorBox = self.monitorBox + let engineBox = self.engineBox + return PhoneMicController(environment: PhoneMicControllerEnvironment( + sink: sink, + permission: FakePermission(status: .granted), + configureSession: {}, + describeRoute: { "harness-route" }, + makeMonitor: { controlQueue, onSignal in + let monitor = FakeMonitor(queue: controlQueue, handler: onSignal) + monitorBox.monitor = monitor + return monitor + }, + makeEngine: { _, onConvertedData, _ in + let engine = FakeEngine(onConvertedData: onConvertedData) + engineBox.engines.append(engine) + return engine + }, + batchDirectory: { "/tmp/omi-phonemic-replay" }, + batchAutoMarker: { false }, + makeEncoder: { FakeBatchEncoder() }, + makeWriter: { _, _ in + let writer = FakeBatchWriter() + writers.writer = writer + return writer + } + )) + } } func drainMain(seconds: Double) { @@ -276,6 +332,18 @@ func assertNoFrameAfterIdle(_ delivered: [RecordedDelivery], label: String) { } } +func progressCount(_ sink: RecordingSink) -> Int { + sink.deliveries.filter { $0.kind == .progress }.count +} + +func startBatch(_ controller: PhoneMicController, _ sessionId: Int64) -> Result { + let box = ResultBox() + controller.start(mode: .batch, sessionId: sessionId) { box.result = $0 } + waitUntil({ box.result != nil }, "batch start completion (sid \(sessionId))") + drainMain(seconds: 0.05) + return box.result! +} + func start(_ controller: PhoneMicController, _ sessionId: Int64) -> Result { let box = ResultBox() controller.start(mode: .stream, sessionId: sessionId) { box.result = $0 } @@ -594,6 +662,52 @@ struct Harness { precondition(world.sink.deliveries.count == before, "restored session leaked after stop") } + // Batch heartbeat pacing: healthy running stays quiet well past the old + // 1s tick; interruption and foreground snap a beat back inside 1s. + do { + let world = ReplayWorld() + let writers = WriterBox() + let controller = world.makeBatchController(writers: writers) + guard case .success = startBatch(controller, 7) else { + preconditionFailure("batch pacing: start failed") + } + let started = progressCount(world.sink) + drainMain(seconds: 1.5) + precondition(progressCount(world.sink) == started, + "healthy batch heartbeat must stay on the 5s cadence (ticked within 1.5s)") + + writers.writer?.storageFull = true + let engine = world.engineBox.engines[0] + engine.emitConverted(Data([1, 2, 3, 4]), epoch: engine.installedEpoch) + drainMain(seconds: 1.0) + precondition( + world.sink.deliveries.contains { $0.kind == .error && $0.code == "batch_storage_full" }, + "storage-full must be reported from the write, not the slow heartbeat") + + world.monitorBox.monitor!.fire(.interruptionBegan) + let beforeInterrupt = progressCount(world.sink) + drainMain(seconds: 3.0) + precondition(progressCount(world.sink) > beforeInterrupt, + "interruption must snap the heartbeat back to 1s") + + let resumedAt = world.sink.deliveries.count + world.monitorBox.monitor!.fire(.interruptionEnded(shouldResume: true)) + waitUntil({ + world.sink.deliveries.dropFirst(resumedAt).contains { $0.kind == .state && $0.state == "running" } + }, "batch pacing resumed") + drainMain(seconds: 0.05) + let afterResume = progressCount(world.sink) + drainMain(seconds: 1.5) + precondition(progressCount(world.sink) == afterResume, + "resumed healthy capture must return to the 5s cadence") + + world.monitorBox.monitor!.fire(.appBecameActive) + drainMain(seconds: 3.0) + precondition(progressCount(world.sink) > afterResume, + "foreground must schedule one fast heartbeat") + stop(controller) + } + print("phone-mic lifecycle replay: all 8 canonical vectors passed (stale/terminal-cleanup drops verified)") } } diff --git a/app/lib/phone_mic_interface.dart b/app/lib/phone_mic_interface.dart index ec1caf9c2a6..6ed0f2b9b58 100644 --- a/app/lib/phone_mic_interface.dart +++ b/app/lib/phone_mic_interface.dart @@ -77,8 +77,9 @@ abstract class PhoneMicFlutterApi { /// rebuild_failed, resume_failed, media_services_reset, batch_storage_full. void onCaptureError(String code, String message, int sessionId); - /// Emitted at 1Hz while batch capture runs. Its arrival is the liveness signal - /// for the Dart batch watchdog; the value is derived from the number of frames + /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart + /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the /// event keeps arriving. void onBatchProgress(double capturedSeconds, int sessionId); diff --git a/app/lib/services/mic/native_mic_recorder_service.dart b/app/lib/services/mic/native_mic_recorder_service.dart index 7bcd548b68d..2bfab2e99f9 100644 --- a/app/lib/services/mic/native_mic_recorder_service.dart +++ b/app/lib/services/mic/native_mic_recorder_service.dart @@ -27,14 +27,21 @@ import 'package:omi/utils/logger.dart'; class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterApi { static const Duration _stallThreshold = Duration(seconds: 3); static const Duration _stallCheckInterval = Duration(seconds: 1); - // Batch has no frames; liveness is the arrival of onBatchProgress (1Hz), which + // Healthy-state cadence used only when [paceHealthyChecks] is set (iOS). + // Capped by the stall deadline below, so a check is never scheduled later + // than the moment the existing threshold would trip. Faults snap back to + // [_stallCheckInterval]. + static const Duration _healthyCheckInterval = Duration(seconds: 5); + // Batch has no frames; liveness is the arrival of onBatchProgress, which // keeps ticking through mutes/interruptions. A longer window than the stream - // stall threshold tolerates the coarser cadence. + // stall threshold tolerates the coarser cadence. The threshold itself is + // unchanged: a missing beat is still a stall at 10s. static const Duration _batchStallThreshold = Duration(seconds: 10); final PhoneMicHostApi _hostApi; final Timer Function(Duration, void Function(Timer)) _periodic; final DateTime Function() _now; + final bool _paceHealthyChecks; Function(Uint8List bytes)? _onByteReceived; Function()? _onRecording; @@ -54,18 +61,22 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp DateTime? _lastByteAt; Timer? _stallTimer; bool _stallReported = false; + bool _stallFastCadence = false; DateTime? _lastBatchProgressAt; Timer? _batchStallTimer; bool _batchStallReported = false; + bool _batchFastCadence = false; NativeMicRecorderService({ PhoneMicHostApi? hostApi, bool registerFlutterApi = true, DateTime Function() now = DateTime.now, Timer Function(Duration, void Function(Timer))? periodic, + bool paceHealthyChecks = false, }) : _hostApi = hostApi ?? PhoneMicHostApi(), _now = now, - _periodic = periodic ?? Timer.periodic { + _periodic = periodic ?? Timer.periodic, + _paceHealthyChecks = paceHealthyChecks { if (registerFlutterApi) { PhoneMicFlutterApi.setUp(this); } @@ -167,6 +178,12 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp } _lastByteAt = _now(); _stallReported = false; + // Healthy frames must not reschedule the timer (that would be a wake per + // buffer). Only leave the fast cadence once a fresh frame proves the + // anomaly cleared. + if (_paceHealthyChecks && !_interrupted && _stallFastCadence) { + _armPacedStall(forceFast: false); + } _onByteReceived?.call(pcm16leMono16k); } @@ -194,7 +211,13 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp if (_batchMode) { // Batch emits no frames, so the stream stall watchdog would misfire. // Arm the batch (progress-arrival) watchdog once, on the first running. - if (_batchStallTimer == null) _startBatchWatchdog(); + // A later running (resume) drops the interruption fast-cadence if the + // heartbeat is fresh; a gap already in progress stays on the deadline. + if (_batchStallTimer == null) { + _startBatchWatchdog(); + } else if (_paceHealthyChecks) { + _armPacedBatch(forceFast: false); + } } else { // Armed here rather than in start(): the permission prompt can hold // start() open far longer than the stall threshold. @@ -207,11 +230,26 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp _interrupted = true; _onInterruption?.call(true); } + if (_paceHealthyChecks) { + if (_batchMode && _lastBatchProgressAt != null) { + _armPacedBatch(forceFast: true); + } else if (_stallTimer != null) { + _armPacedStall(forceFast: true); + } + } break; case PhoneMicCaptureState.rebuilding: // Frames pause briefly during a route/config rebuild — fresh grace - // period so the watchdog doesn't misread it as a stall. + // period so the watchdog doesn't misread it as a stall. Paced mode + // also snaps to the 1s poll for the grace window. _lastByteAt = _now(); + if (_paceHealthyChecks) { + if (_batchMode && _lastBatchProgressAt != null) { + _armPacedBatch(forceFast: true); + } else if (_stallTimer != null) { + _armPacedStall(forceFast: true); + } + } break; case PhoneMicCaptureState.idle: // Native-side terminal stop without a Dart stop() call. @@ -251,19 +289,81 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp // mutes/interruptions while events keep coming. _lastBatchProgressAt = _now(); _batchStallReported = false; + if (_paceHealthyChecks && !_interrupted && _batchFastCadence) { + _armPacedBatch(forceFast: false); + } } void _startStallWatchdog() { _cancelStallWatchdog(); _lastByteAt = _now(); _stallReported = false; - _stallTimer = _periodic(_stallCheckInterval, (_) { - if (_stallReported || _lastByteAt == null || _interrupted || !_sessionActive) return; - if (_now().difference(_lastByteAt!) >= _stallThreshold) { - _stallReported = true; - _onStalled?.call(); - } - }); + if (!_paceHealthyChecks) { + _stallTimer = _periodic(_stallCheckInterval, (_) { + if (_stallReported || _lastByteAt == null || _interrupted || !_sessionActive) return; + if (_now().difference(_lastByteAt!) >= _stallThreshold) { + _stallReported = true; + _onStalled?.call(); + } + }); + return; + } + _armPacedStall(forceFast: false); + } + + /// Next wake while paced. Never later than the stall deadline, and never + /// later than the active cadence. [fast] is the 1s fault poll. + Duration _pacedDelay({required Duration age, required Duration threshold, required bool fast}) { + final interval = fast ? _stallCheckInterval : _healthyCheckInterval; + final remaining = threshold - age; + if (remaining <= Duration.zero) return Duration.zero; + if (interval < remaining) return interval; + return remaining; + } + + void _armPacedStall({required bool forceFast}) { + _stallTimer?.cancel(); + _stallTimer = null; + if (!_sessionActive || _lastByteAt == null) return; + // Interrupted stream silence is expected. Keep the fast poll so resume is + // watched immediately, but do not declare a stall from this path. + if (_interrupted) { + _stallFastCadence = true; + _stallTimer = Timer(_stallCheckInterval, _onPacedStallTick); + return; + } + final age = _now().difference(_lastByteAt!); + if (age >= _stallThreshold) { + _stallFastCadence = true; + _stallTimer = Timer(Duration.zero, () { + if (_stallReported || _interrupted || !_sessionActive || _lastByteAt == null) return; + if (_now().difference(_lastByteAt!) >= _stallThreshold) { + _stallReported = true; + _onStalled?.call(); + } + }); + return; + } + final fast = forceFast || age >= _healthyCheckInterval; + _stallFastCadence = fast; + _stallTimer = Timer(_pacedDelay(age: age, threshold: _stallThreshold, fast: fast), _onPacedStallTick); + } + + void _onPacedStallTick() { + _stallTimer = null; + if (!_sessionActive || _lastByteAt == null) return; + if (_interrupted) { + _armPacedStall(forceFast: true); + return; + } + if (_stallReported) return; + final age = _now().difference(_lastByteAt!); + if (age >= _stallThreshold) { + _stallReported = true; + _onStalled?.call(); + return; + } + _armPacedStall(forceFast: age >= _healthyCheckInterval); } void _cancelStallWatchdog() { @@ -271,19 +371,57 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp _stallTimer = null; _lastByteAt = null; _stallReported = false; + _stallFastCadence = false; } void _startBatchWatchdog() { _cancelBatchWatchdog(); _lastBatchProgressAt = _now(); _batchStallReported = false; - _batchStallTimer = _periodic(_stallCheckInterval, (_) { - if (_batchStallReported || _lastBatchProgressAt == null || !_sessionActive) return; - if (_now().difference(_lastBatchProgressAt!) >= _batchStallThreshold) { - _batchStallReported = true; - _onBatchStalled?.call(); - } - }); + if (!_paceHealthyChecks) { + _batchStallTimer = _periodic(_stallCheckInterval, (_) { + if (_batchStallReported || _lastBatchProgressAt == null || !_sessionActive) return; + if (_now().difference(_lastBatchProgressAt!) >= _batchStallThreshold) { + _batchStallReported = true; + _onBatchStalled?.call(); + } + }); + return; + } + _armPacedBatch(forceFast: false); + } + + void _armPacedBatch({required bool forceFast}) { + _batchStallTimer?.cancel(); + _batchStallTimer = null; + if (!_sessionActive || _lastBatchProgressAt == null) return; + final age = _now().difference(_lastBatchProgressAt!); + if (age >= _batchStallThreshold) { + _batchFastCadence = true; + _batchStallTimer = Timer(Duration.zero, () { + if (_batchStallReported || !_sessionActive || _lastBatchProgressAt == null) return; + if (_now().difference(_lastBatchProgressAt!) >= _batchStallThreshold) { + _batchStallReported = true; + _onBatchStalled?.call(); + } + }); + return; + } + final fast = forceFast || _interrupted || age >= _healthyCheckInterval; + _batchFastCadence = fast; + _batchStallTimer = Timer(_pacedDelay(age: age, threshold: _batchStallThreshold, fast: fast), _onPacedBatchTick); + } + + void _onPacedBatchTick() { + _batchStallTimer = null; + if (!_sessionActive || _lastBatchProgressAt == null || _batchStallReported) return; + final age = _now().difference(_lastBatchProgressAt!); + if (age >= _batchStallThreshold) { + _batchStallReported = true; + _onBatchStalled?.call(); + return; + } + _armPacedBatch(forceFast: _interrupted || age >= _healthyCheckInterval); } void _cancelBatchWatchdog() { @@ -291,6 +429,7 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp _batchStallTimer = null; _lastBatchProgressAt = null; _batchStallReported = false; + _batchFastCadence = false; } /// Soft-rearm liveness after the app returns to foreground (#4706). @@ -305,10 +444,14 @@ class NativeMicRecorderService implements IMicRecorderService, PhoneMicFlutterAp if (_batchMode) { _lastBatchProgressAt = _now(); _batchStallReported = false; + // Foreground is an anomaly edge: watch the next window at 1s, and let + // the next progress beat return to the healthy cadence. + if (_paceHealthyChecks) _armPacedBatch(forceFast: true); return; } _lastByteAt = _now(); _stallReported = false; + if (_paceHealthyChecks) _armPacedStall(forceFast: true); } void _clearCallbacks() { diff --git a/app/lib/services/services.dart b/app/lib/services/services.dart index 6e3472a5ae1..6f248df922a 100644 --- a/app/lib/services/services.dart +++ b/app/lib/services/services.dart @@ -34,8 +34,15 @@ class ServiceManager { // Android (AudioRecord); chat voice memos and the speech profile stay on the // flutter_sound path via [mic]. The shared arbiter keeps the two stacks from // contending for the microphone. + // iOS lengthens the healthy-state watchdog poll (5s, deadline-capped). + // Android keeps the 1s poll; its native heartbeat also does mode checks + // that this flag must not slow down. sm._phoneMic = (Platform.isIOS || Platform.isAndroid) - ? ArbitratedMic(inner: NativeMicRecorderService(), arbiter: micArbiter, owner: 'conversation') + ? ArbitratedMic( + inner: NativeMicRecorderService(paceHealthyChecks: Platform.isIOS), + arbiter: micArbiter, + owner: 'conversation', + ) : sm._mic; sm._device = DeviceService(); sm._socket = SocketServicePool(); diff --git a/app/test/unit/native_mic_recorder_service_test.dart b/app/test/unit/native_mic_recorder_service_test.dart index 76b9cf68d30..d205361a76e 100644 --- a/app/test/unit/native_mic_recorder_service_test.dart +++ b/app/test/unit/native_mic_recorder_service_test.dart @@ -595,4 +595,187 @@ void main() { service.stop(); }); }); + + _iosHealthyWatchdogPacingTests(); +} + +/// iOS healthy-state pacing. The unpaced 1s poll (Android, and the tests above) +/// stays the default; these cover the deadline cap and the snap back to 1s. +void _iosHealthyWatchdogPacingTests() { + group('iOS healthy-state watchdog pacing', () { + late FakePhoneMicHostApi host; + late NativeMicRecorderService service; + late Callbacks cb; + + setUp(() { + host = FakePhoneMicHostApi(); + service = NativeMicRecorderService(hostApi: host, registerFlutterApi: false, paceHealthyChecks: true); + cb = Callbacks(); + }); + + Future startStream(DateTime Function() now) { + service = NativeMicRecorderService(hostApi: host, registerFlutterApi: false, now: now, paceHealthyChecks: true); + return service.start( + onByteReceived: cb.bytes.add, + onRecording: () => cb.recording++, + onStop: () => cb.stops++, + onStalled: () => cb.stalls++, + onInterruption: cb.interruptions.add, + ); + } + + Future startBatch(DateTime Function() now) { + service = NativeMicRecorderService(hostApi: host, registerFlutterApi: false, now: now, paceHealthyChecks: true); + return service.startBatch( + onStop: () => cb.stops++, + onInterruption: cb.interruptions.add, + onBatchStalled: () => cb.batchStalls++, + onError: (code, message) => cb.errors.add(code), + ); + } + + test('stream stall still trips at the 3s deadline, not after a 5s poll', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startStream(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + + elapse(const Duration(seconds: 2)); + expect(cb.stalls, 0); + elapse(const Duration(seconds: 1)); + expect(cb.stalls, 1, reason: 'paced checks are capped at the 3s threshold'); + + service.stop(); + }); + }); + + test('fresh frames keep the healthy cadence quiet, then a new gap trips at 3s', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startStream(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + + for (var t = 1; t <= 12; t++) { + elapse(const Duration(seconds: 1)); + service.onAudioFrame(Uint8List.fromList([1]), id); + } + expect(cb.stalls, 0); + + elapse(const Duration(seconds: 2)); + expect(cb.stalls, 0); + elapse(const Duration(seconds: 1)); + expect(cb.stalls, 1); + + service.stop(); + }); + }); + + test('batch progress every 5s does not false-stall; silence still stalls at 10s', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startBatch(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + + for (var i = 0; i < 6; i++) { + elapse(const Duration(seconds: 5)); + expect(cb.batchStalls, 0); + service.onBatchProgress(i.toDouble(), id); + } + + elapse(const Duration(seconds: 9)); + expect(cb.batchStalls, 0); + elapse(const Duration(seconds: 1)); + expect(cb.batchStalls, 1, reason: '10s batch deadline is unchanged'); + + service.stop(); + }); + }); + + test('interruption and foreground snap back to the fast poll', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startBatch(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + service.onStateChanged(PhoneMicCaptureState.interrupted, id); + + // Progress through the interruption keeps the fast poll quiet. + elapse(const Duration(seconds: 3)); + service.onBatchProgress(1, id); + elapse(const Duration(seconds: 3)); + service.onBatchProgress(1, id); + expect(cb.batchStalls, 0); + + // Silence during the interruption still hits the 10s deadline. + elapse(const Duration(seconds: 9)); + expect(cb.batchStalls, 0); + elapse(const Duration(seconds: 1)); + expect(cb.batchStalls, 1); + + service.onBatchProgress(2, id); + service.onStateChanged(PhoneMicCaptureState.running, id); + expect(cb.batchStalls, 1); + + // Foreground re-arms fast. A 10s gap from the re-arm still stalls; + // it must not wait out an extra healthy interval. + service.probeStallAfterForeground(); + elapse(const Duration(seconds: 9)); + expect(cb.batchStalls, 1); + elapse(const Duration(seconds: 1)); + expect(cb.batchStalls, 2); + + service.stop(); + }); + }); + + test('rebuilding grants stream grace and still stalls at 3s without frames', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startStream(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + elapse(const Duration(seconds: 2)); + service.onStateChanged(PhoneMicCaptureState.rebuilding, id); + elapse(const Duration(seconds: 2)); + expect(cb.stalls, 0, reason: 'rebuild resets the stall clock'); + elapse(const Duration(seconds: 1)); + expect(cb.stalls, 1); + + service.stop(); + }); + }); + }); } From 1813c28b38c031c79d08dd1fa13e37758307d403 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 20:38:31 +0700 Subject: [PATCH 2/8] chore(app): exempt iOS capture watchdog pacing from store notes Co-authored-by: Cursor --- .../unreleased/20261013-ios-capture-watchdog-pacing.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/changelog/unreleased/20261013-ios-capture-watchdog-pacing.json diff --git a/app/changelog/unreleased/20261013-ios-capture-watchdog-pacing.json b/app/changelog/unreleased/20261013-ios-capture-watchdog-pacing.json new file mode 100644 index 00000000000..d19ac9ebd65 --- /dev/null +++ b/app/changelog/unreleased/20261013-ios-capture-watchdog-pacing.json @@ -0,0 +1 @@ +{"kind": "none"} From d94192b736bba681b126bfcb36687c58e49c8dd3 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 21:22:13 +0700 Subject: [PATCH 3/8] test(ios): pin healthy watchdog cadence and snap-backs A silent change to the 5s batch interval, or a dropped snap to 1s on rebuild or a stale beat, now fails the recorder and native replay tests. Co-authored-by: Cursor --- .../phone_mic_lifecycle_replay_test.swift | 23 +++++++- .../native_mic_recorder_service_test.dart | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index cb94ebf802a..e2c39e447f4 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -686,7 +686,9 @@ struct Harness { world.monitorBox.monitor!.fire(.interruptionBegan) let beforeInterrupt = progressCount(world.sink) - drainMain(seconds: 3.0) + // Stay under the 3s resume ticker so this window observes the 1s + // snap and the explicit interruptionEnded below is what resumes. + drainMain(seconds: 2.0) precondition(progressCount(world.sink) > beforeInterrupt, "interruption must snap the heartbeat back to 1s") @@ -708,6 +710,25 @@ struct Harness { stop(controller) } + // Pins the native 5s constant itself: quiet through 4.3s (rejects 1s–4s), + // then a beat before the 500ms leeway can hide a 5s timer. + do { + let world = ReplayWorld() + let writers = WriterBox() + let controller = world.makeBatchController(writers: writers) + guard case .success = startBatch(controller, 11) else { + preconditionFailure("batch cadence: start failed") + } + let started = progressCount(world.sink) + drainMain(seconds: 4.3) + precondition(progressCount(world.sink) == started, + "healthy batch heartbeat is 5s; it must not tick within 4.3s") + drainMain(seconds: 2.2) + precondition(progressCount(world.sink) > started, + "healthy batch heartbeat must tick at 5s (none by ~6.5s)") + stop(controller) + } + print("phone-mic lifecycle replay: all 8 canonical vectors passed (stale/terminal-cleanup drops verified)") } } diff --git a/app/test/unit/native_mic_recorder_service_test.dart b/app/test/unit/native_mic_recorder_service_test.dart index d205361a76e..9871056cb6f 100644 --- a/app/test/unit/native_mic_recorder_service_test.dart +++ b/app/test/unit/native_mic_recorder_service_test.dart @@ -634,6 +634,14 @@ void _iosHealthyWatchdogPacingTests() { ); } + /// The one timer the paced batch watchdog has armed. Length and duration + /// are the pin: a 1s periodic poll or a different healthy interval fails. + Duration pacedDeadline(FakeAsync async) { + final timers = async.pendingTimers; + expect(timers, hasLength(1), reason: 'paced batch watchdog arms exactly one timer'); + return timers.single.duration; + } + test('stream stall still trips at the 3s deadline, not after a 5s poll', () { fakeAsync((async) { var now = DateTime(2026, 1, 1); @@ -755,6 +763,56 @@ void _iosHealthyWatchdogPacingTests() { }); }); + test('batch healthy cadence is 5s until a beat goes stale', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + void elapse(Duration d) { + now = now.add(d); + async.elapse(d); + } + + startBatch(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + + expect(pacedDeadline(async), const Duration(seconds: 5)); + + // A beat that arrives on the healthy cadence keeps the 5s poll. + elapse(const Duration(seconds: 5)); + expect(cb.batchStalls, 0); + service.onBatchProgress(5, id); + expect(pacedDeadline(async), const Duration(seconds: 5)); + + // Silence through the healthy interval is a stale beat: snap to 1s, + // still short of the 10s stall deadline. + elapse(const Duration(seconds: 5)); + expect(cb.batchStalls, 0); + expect(pacedDeadline(async), const Duration(seconds: 1)); + + service.stop(); + }); + }); + + test('batch rebuild snaps the poll from 5s back to 1s', () { + fakeAsync((async) { + var now = DateTime(2026, 1, 1); + + startBatch(() => now); + async.flushMicrotasks(); + final id = host.lastStartSessionId; + service.onStateChanged(PhoneMicCaptureState.running, id); + expect(pacedDeadline(async), const Duration(seconds: 5)); + + service.onStateChanged(PhoneMicCaptureState.rebuilding, id); + expect(cb.batchStalls, 0); + expect(pacedDeadline(async), const Duration(seconds: 1), + reason: 'rebuild is an anomaly; the fast poll must be armed immediately'); + + service.stop(); + }); + }); + test('rebuilding grants stream grace and still stalls at 3s without frames', () { fakeAsync((async) { var now = DateTime(2026, 1, 1); From 08063935b6ed981b98cc6728d7de72c81036392d Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:38:10 +0700 Subject: [PATCH 4/8] fix(ios): keep watchdog margin for a missed beat and preserve recovery's fast beat Address review feedback on the paced batch heartbeat: - Healthy cadence 5s -> 4s (P2 r4259674208): strictly under half the unchanged 10s Dart batch-stall deadline. One lost or coalesced beat now lands the next at ~8.5s (2x4s + 500ms leeway) instead of exactly at the >=10s deadline, so a transient missed callback no longer restarts a healthy batch capture. - Preserve the pending fast first fire when recovery reaches .running (P1 r4259674193): applyProgressCadence previously rescheduled the existing timer to now+5s, so a quick interruption/rebuild recovery could stretch the beat gap past 10s and false-restart a recovered session. It now keeps the ~1s first fire (the shape the foreground path already used) and only switches the repeating cadence. - Align the Pigeon cadence doc across Dart/Swift/Kotlin outputs (P2 r4259674216): onBatchProgress is iOS 4s healthy / 1s fault, Android 1Hz. Verified: flutter test test/unit/native_mic_recorder_service_test.dart (29 passed) and ruby app/ios/test/phone_mic_lifecycle_replay_test.rb (14 assertions), which now also pins the 4s cadence and the preserved fast beat on resume. --- .../friend/ios/phonemic/PhoneMicPigeon.g.kt | 2 +- .../Runner/PhoneMic/PhoneMicController.swift | 23 +++++++++++---- .../PhoneMic/PhoneMicEventEmitter.swift | 2 +- .../Runner/PhoneMic/PhoneMicPigeon.g.swift | 4 +-- .../phone_mic_lifecycle_replay_test.swift | 28 ++++++++++++------- app/lib/gen/phone_mic_pigeon.g.dart | 5 ++-- app/lib/phone_mic_interface.dart | 2 +- 7 files changed, 43 insertions(+), 23 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt b/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt index 86e536ee0e9..ac16fd2652e 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/phonemic/PhoneMicPigeon.g.kt @@ -284,7 +284,7 @@ class PhoneMicFlutterApi(private val binaryMessenger: BinaryMessenger, private v } } /** - * Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + * Emitted while batch capture runs (iOS: 4s while healthy, 1s on fault or * foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart * batch watchdog; the value is derived from the number of frames * actually written to disk, so mutes and interruptions freeze it while the diff --git a/app/ios/Runner/PhoneMic/PhoneMicController.swift b/app/ios/Runner/PhoneMic/PhoneMicController.swift index da42844f7d5..f13006620a3 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicController.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicController.swift @@ -26,9 +26,11 @@ final class PhoneMicController { /// and the first beat after foreground use this so a gap is still visible /// inside the Dart 10s batch deadline. private static let fastProgressTickInterval: TimeInterval = 1.0 - /// Healthy `.running` cadence. 5s keeps a missed beat under the 10s Dart - /// stall threshold (one skipped tick is not itself a stall). - private static let healthyProgressTickInterval: TimeInterval = 5.0 + /// Healthy `.running` cadence. Strictly under half the 10s Dart stall + /// threshold: even if one beat is lost or coalesced, the next lands at + /// ~8.5s (2x4s + the 500ms leeway) — one skipped tick is not itself a + /// stall. A 5s cadence would put the second beat exactly at the deadline. + private static let healthyProgressTickInterval: TimeInterval = 4.0 private let controlQueue = DispatchQueue(label: "com.omi.phonemic.control", qos: .userInitiated) private let audioQueue = DispatchQueue(label: "com.omi.phonemic.audio", qos: .userInitiated) @@ -355,7 +357,7 @@ final class PhoneMicController { beginRebuild() } else if mode == .batch { // One fast liveness beat, then the healthy cadence. Pulls a - // far-off 5s deadline forward without leaving the 1s poll on. + // far-off 4s deadline forward without leaving the 1s poll on. syncProgressTimer(first: Self.fastProgressTickInterval, repeating: Self.healthyProgressTickInterval) } default: @@ -482,7 +484,7 @@ final class PhoneMicController { let writer = batchWriter let marker = batchMarker // The audio bytes stay on this closure. The weak hop is only the - // storage-full fault edge, so the healthy 5s heartbeat cannot delay it. + // storage-full fault edge, so the healthy 4s heartbeat cannot delay it. return { [weak self] data, _ in guard let encoder, let writer else { return } let packets = encoder.encode(data) @@ -580,6 +582,15 @@ final class PhoneMicController { private func applyProgressCadence() { let interval = desiredProgressInterval() + if interval == Self.healthyProgressTickInterval, + progressIntervalArmed != 0, progressIntervalArmed != Self.healthyProgressTickInterval { + // Retargeting out of a fault window (resume/rebuild recovery reaching + // `.running`): keep the already-scheduled fast first fire so the beat + // gap cannot silently grow past the Dart 10s stall deadline, and only + // switch the repeating cadence back to the slow interval afterward. + syncProgressTimer(first: Self.fastProgressTickInterval, repeating: interval) + return + } syncProgressTimer(first: interval, repeating: interval) } @@ -591,7 +602,7 @@ final class PhoneMicController { /// Heartbeat on controlQueue. Reads the writer's committed frame count and drains /// any storage-full edge the write path did not already report, then emits - /// progress. Healthy running is 5s; interrupted/rebuilding is 1s. Emitted every + /// progress. Healthy running is 4s; interrupted/rebuilding is 1s. Emitted every /// tick even while interrupted/muted — the value freezes (frames-written derived), /// but its arrival is the Dart watchdog's liveness signal. The Dart batch stall /// threshold stays 10s, so the slower healthy beat is not itself a gap. diff --git a/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift b/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift index 2cfbfc77da6..682e5f88b37 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicEventEmitter.swift @@ -68,7 +68,7 @@ final class PhoneMicEventEmitter { /// Batch-mode liveness/progress. Not epoch-gated: unlike frames it carries /// no audio, and its steady arrival is the Dart watchdog's liveness signal. - /// Healthy iOS capture emits this every 5s; fault states snap back to 1s. + /// Healthy iOS capture emits this every 4s; fault states snap back to 1s. func emitBatchProgress(_ capturedSeconds: Double, sessionId: Int64) { DispatchQueue.main.async { [weak self] in self?.sink.onBatchProgress(capturedSeconds: capturedSeconds, sessionId: sessionId) diff --git a/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift b/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift index 3a0b4cd5a50..06b56447b23 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicPigeon.g.swift @@ -235,7 +235,7 @@ protocol PhoneMicFlutterApiProtocol { /// Non-fatal runtime failures (capture self-heals): converter_failed, /// rebuild_failed, resume_failed, media_services_reset, batch_storage_full. func onCaptureError(code codeArg: String, message messageArg: String, sessionId sessionIdArg: Int64, completion: @escaping (Result) -> Void) - /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// Emitted while batch capture runs (iOS: 4s while healthy, 1s on fault or /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the @@ -311,7 +311,7 @@ class PhoneMicFlutterApi: PhoneMicFlutterApiProtocol { } } } - /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// Emitted while batch capture runs (iOS: 4s while healthy, 1s on fault or /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index e2c39e447f4..4dd0dfe2c96 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -699,19 +699,27 @@ struct Harness { }, "batch pacing resumed") drainMain(seconds: 0.05) let afterResume = progressCount(world.sink) - drainMain(seconds: 1.5) - precondition(progressCount(world.sink) == afterResume, - "resumed healthy capture must return to the 5s cadence") + // Recovery reaching .running must preserve the pending fast first + // beat: emitting it ~1s later keeps the beat gap under the Dart 10s + // stall deadline instead of silently retargeting to the slow cadence. + drainMain(seconds: 2.0) + precondition(progressCount(world.sink) > afterResume, + "recovery must emit its pending fast beat within ~1s, not wait out the healthy cadence") + let fastBeatCount = progressCount(world.sink) + // After that fast beat the repeating cadence is the slow one. + drainMain(seconds: 2.0) + precondition(progressCount(world.sink) == fastBeatCount, + "after the preserved fast beat the heartbeat must return to the healthy cadence") world.monitorBox.monitor!.fire(.appBecameActive) drainMain(seconds: 3.0) - precondition(progressCount(world.sink) > afterResume, + precondition(progressCount(world.sink) > fastBeatCount, "foreground must schedule one fast heartbeat") stop(controller) } - // Pins the native 5s constant itself: quiet through 4.3s (rejects 1s–4s), - // then a beat before the 500ms leeway can hide a 5s timer. + // Pins the native 4s constant itself: quiet through 3.3s (rejects 1s–3s), + // then a beat before the 500ms leeway can hide a 4s timer. do { let world = ReplayWorld() let writers = WriterBox() @@ -720,12 +728,12 @@ struct Harness { preconditionFailure("batch cadence: start failed") } let started = progressCount(world.sink) - drainMain(seconds: 4.3) + drainMain(seconds: 3.3) precondition(progressCount(world.sink) == started, - "healthy batch heartbeat is 5s; it must not tick within 4.3s") - drainMain(seconds: 2.2) + "healthy batch heartbeat is 4s; it must not tick within 3.3s") + drainMain(seconds: 2.0) precondition(progressCount(world.sink) > started, - "healthy batch heartbeat must tick at 5s (none by ~6.5s)") + "healthy batch heartbeat must tick at 4s (none by ~5.3s)") stop(controller) } diff --git a/app/lib/gen/phone_mic_pigeon.g.dart b/app/lib/gen/phone_mic_pigeon.g.dart index 4f43eefa85b..19f2e585e97 100644 --- a/app/lib/gen/phone_mic_pigeon.g.dart +++ b/app/lib/gen/phone_mic_pigeon.g.dart @@ -204,8 +204,9 @@ abstract class PhoneMicFlutterApi { /// rebuild_failed, resume_failed, media_services_reset, batch_storage_full. void onCaptureError(String code, String message, int sessionId); - /// Emitted at 1Hz while batch capture runs. Its arrival is the liveness signal - /// for the Dart batch watchdog; the value is derived from the number of frames + /// Emitted while batch capture runs (iOS: 4s while healthy, 1s on fault or + /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart + /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the /// event keeps arriving. void onBatchProgress(double capturedSeconds, int sessionId); diff --git a/app/lib/phone_mic_interface.dart b/app/lib/phone_mic_interface.dart index 6ed0f2b9b58..cf61a04400e 100644 --- a/app/lib/phone_mic_interface.dart +++ b/app/lib/phone_mic_interface.dart @@ -77,7 +77,7 @@ abstract class PhoneMicFlutterApi { /// rebuild_failed, resume_failed, media_services_reset, batch_storage_full. void onCaptureError(String code, String message, int sessionId); - /// Emitted while batch capture runs (iOS: 5s while healthy, 1s on fault or + /// Emitted while batch capture runs (iOS: 4s while healthy, 1s on fault or /// foreground; Android: 1Hz). Its arrival is the liveness signal for the Dart /// batch watchdog; the value is derived from the number of frames /// actually written to disk, so mutes and interruptions freeze it while the From f17efcd770e58d4b908cb571438c622cd3300fc5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 01:22:06 +0700 Subject: [PATCH 5/8] docs(app): document the paced batch heartbeat cadence in the module guide Codex review P2 on #21406: the phone-mic module guide still defined batch liveness as 1 Hz after watchdog pacing landed. Document the verified per-platform healthy/fault cadence and note that Dart's batch stall deadline is unchanged. Co-authored-by: Cursor --- app/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/AGENTS.md b/app/AGENTS.md index 3fc83cda8a5..f731e4c0476 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -60,7 +60,7 @@ Never run `flutterfire configure` — it overwrites prod credentials. Config fil - Modules: iOS `ios/Runner/PhoneMic/` (self-healing AVAudioEngine capture; interruptions/routes recover natively); Android `android/app/src/main/kotlin/com/friend/ios/phonemic/` (AudioRecord with self-healing rebuild + silencing detection; `PhoneMicForegroundService` keeps background capture alive; batch opus encode via a JNI shim over the plugin-shipped libopus) - Dart service: `lib/services/mic/native_mic_recorder_service.dart` behind `ServiceManager.phoneMic`; chat memos/speech profile stay on flutter_sound via `ServiceManager.mic`; `MicArbiter` arbitrates the two stacks - Events carry a Dart-minted session id (`start(mode, sessionId)`); Dart drops foreign-id events, `start()` onto a live session adopts the new id and re-emits state, and `stop()` always forwards to native then tears down locally once -- Two capture modes fixed at `start(mode)`: `stream` (realtime frames → Dart → socket/WAL) and `batch` (Transcribe Later — native opus encode → WAL-compatible `audio_omibatchphone[auto]_…bin`; no frames cross to Dart; liveness = 1Hz `onBatchProgress`). Selection: `CaptureController.streamRecording` (explicit or offline fallback); `omibatchphoneauto` auto-uploads on reconnect +- Two capture modes fixed at `start(mode)`: `stream` (frames → Dart → socket/WAL) and `batch` (Transcribe Later — opus → WAL-compatible `audio_omibatchphone[auto]_…bin`; no frames cross to Dart; liveness = `onBatchProgress`: iOS 4s healthy/1s fault, Android 1Hz; Dart 10s unchanged). Selection: `CaptureController.streamRecording` (explicit/offline); `omibatchphoneauto` uploads on reconnect On-device speech deadlines and cleanup: [contract](../.github/agent-docs/on-device-speech.md). From 2dc8d94ce353d689a995bc3577c6875c5f846ecb Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:10:31 +0700 Subject: [PATCH 6/8] fix(ios): keep the armed fast heartbeat deadline on late recovery Address review thread PRRT_kwDOLkKqys6r_cxj: the recovery preservation branch re-scheduled the pending fault fire to now+1s with the healthy cadence's 500ms leeway (leeway derived from `repeating`), so a recovery late in the fault window could deliver its beat after Dart's unchanged 10s batch stall deadline and restart a fully recovered session. - applyProgressCadence now leaves the already-armed fast fire untouched; the tick handler retargets to the healthy cadence after it emits. - syncProgressTimer leeway now sized for min(first, repeating) so a fast first fire keeps the 50ms tolerance. - Replay regression: interrupt, resume 0.6s into the fault window, expect the beat on the armed deadline (~0.4s out), not resume+1s; verified it fails on the previous code and passes on the fix. --- .../Runner/PhoneMic/PhoneMicController.swift | 17 +++++--- .../phone_mic_lifecycle_replay_test.swift | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/app/ios/Runner/PhoneMic/PhoneMicController.swift b/app/ios/Runner/PhoneMic/PhoneMicController.swift index f13006620a3..b152a861e1b 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicController.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicController.swift @@ -554,10 +554,12 @@ final class PhoneMicController { /// Arm, or retarget, the batch heartbeat. `repeating` is what subsequent /// ticks use; `first` can be shorter so an anomaly gets one fast beat - /// before returning to the healthy cadence. + /// before returning to the healthy cadence. Leeway is sized for the + /// soonest fire so a fast recovery beat cannot be coalesced into the + /// looser healthy tolerance. private func syncProgressTimer(first: TimeInterval, repeating: TimeInterval) { guard mode == .batch, state != .idle, batchWriter != nil else { return } - let leeway: DispatchTimeInterval = repeating >= Self.healthyProgressTickInterval + let leeway: DispatchTimeInterval = min(first, repeating) >= Self.healthyProgressTickInterval ? .milliseconds(500) : .milliseconds(50) if let timer = progressTimer { @@ -585,10 +587,13 @@ final class PhoneMicController { if interval == Self.healthyProgressTickInterval, progressIntervalArmed != 0, progressIntervalArmed != Self.healthyProgressTickInterval { // Retargeting out of a fault window (resume/rebuild recovery reaching - // `.running`): keep the already-scheduled fast first fire so the beat - // gap cannot silently grow past the Dart 10s stall deadline, and only - // switch the repeating cadence back to the slow interval afterward. - syncProgressTimer(first: Self.fastProgressTickInterval, repeating: interval) + // `.running`): keep the already-armed fast fire exactly as armed. + // Re-scheduling here would re-anchor its deadline to `now + 1s` with + // the healthy cadence's looser leeway, so a recovery late in the + // fault window could deliver its beat after Dart's unchanged 10s + // stall deadline and restart a fully recovered session. The tick + // handler switches the repeating cadence back to the slow interval + // once the preserved fire has emitted the recovery beat. return } syncProgressTimer(first: interval, repeating: interval) diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index 4dd0dfe2c96..59a4d7c6980 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -737,6 +737,46 @@ struct Harness { stop(controller) } + // Late-window recovery must keep the already-armed fast deadline. The + // 1s fault fire is armed when the interruption lands; recovering 0.85s + // into that window leaves only ~0.15s on it. Re-anchoring the deadline + // to resume + 1s (the regression this pins) would delay the beat past + // the check window — and, late enough, past Dart's unchanged 10s stall + // deadline, restarting a fully recovered session. + do { + let world = ReplayWorld() + let writers = WriterBox() + let controller = world.makeBatchController(writers: writers) + guard case .success = startBatch(controller, 21) else { + preconditionFailure("preserved deadline: start failed") + } + drainMain(seconds: 0.5) + world.monitorBox.monitor!.fire(.interruptionBegan) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "interrupted" } + }, "preserved deadline: interrupted") + // 0.6s into the fault window: no fault beat has fired yet, and the + // armed one is ~0.4s out — enough margin that queue latency cannot + // let it fire before recovery completes. + drainMain(seconds: 0.6) + world.monitorBox.monitor!.fire(.interruptionEnded(shouldResume: true)) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "running" } + }, "preserved deadline: resumed") + precondition(progressCount(world.sink) == 0, + "preserved deadline: no beat expected before recovery (timing drifted)") + drainMain(seconds: 0.7) + precondition(progressCount(world.sink) > 0, + "recovery must keep the already-armed fast fire (~0.15s out), not re-anchor it to resume + 1s") + let preservedBeatCount = progressCount(world.sink) + // After the preserved beat the cadence returns to healthy: no + // second beat on the old 1s fault repeat. + drainMain(seconds: 2.0) + precondition(progressCount(world.sink) == preservedBeatCount, + "after the preserved beat the heartbeat must return to the healthy cadence") + stop(controller) + } + print("phone-mic lifecycle replay: all 8 canonical vectors passed (stale/terminal-cleanup drops verified)") } } From 5c3ec61a6b9cf5cf01fdf915c8091d5c1145edce Mon Sep 17 00:00:00 2001 From: David Zhang <9387252+Git-on-my-level@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:16:41 +0700 Subject: [PATCH 7/8] fix(ios): never re-anchor an armed fast heartbeat deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review thread PRRT_kwDOLkKqys6sETFD: the recovery guard only preserved fast-to-healthy transitions, so a second route/config fault arriving before the preserved 1s recovery beat fell through and re-anchored the fire to now+1s. With one lost healthy beat, repeated quick faults could push the only liveness event past Dart's unchanged 10s batch stall deadline and restart a recovered session. applyProgressCadence now keeps any already-armed deadline — preserved fast fire, second fault before it, or unchanged healthy window — and only a fault pulling a far-off healthy deadline in to the fast cadence re-schedules. Replay regression: interrupt, resume, then a second fault before the preserved fire; verified it fails on the previous code and passes on the fix. --- .../Runner/PhoneMic/PhoneMicController.swift | 27 +++++++----- .../phone_mic_lifecycle_replay_test.swift | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/app/ios/Runner/PhoneMic/PhoneMicController.swift b/app/ios/Runner/PhoneMic/PhoneMicController.swift index b152a861e1b..94ce61b0e2b 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicController.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicController.swift @@ -584,17 +584,22 @@ final class PhoneMicController { private func applyProgressCadence() { let interval = desiredProgressInterval() - if interval == Self.healthyProgressTickInterval, - progressIntervalArmed != 0, progressIntervalArmed != Self.healthyProgressTickInterval { - // Retargeting out of a fault window (resume/rebuild recovery reaching - // `.running`): keep the already-armed fast fire exactly as armed. - // Re-scheduling here would re-anchor its deadline to `now + 1s` with - // the healthy cadence's looser leeway, so a recovery late in the - // fault window could deliver its beat after Dart's unchanged 10s - // stall deadline and restart a fully recovered session. The tick - // handler switches the repeating cadence back to the slow interval - // once the preserved fire has emitted the recovery beat. - return + if progressIntervalArmed != 0 { + // An armed fire's deadline is at most one interval out, so + // re-anchoring it to `now + interval` can only delay the next + // liveness beat. Keep any already-armed deadline — a preserved + // fast fire through recovery, a second fault arriving before that + // fire, or an unchanged healthy window — and let the tick handler + // retarget the repeating cadence after the beat fires. Only a + // fault pulling a far-off healthy deadline in to the fast cadence + // re-schedules. Delaying the beat is what let a late-window + // recovery or a repeated fault push the only liveness event past + // Dart's unchanged 10s stall deadline, restarting a recovered + // session. + if progressIntervalArmed == Self.fastProgressTickInterval + || progressIntervalArmed == interval { + return + } } syncProgressTimer(first: interval, repeating: interval) } diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index 59a4d7c6980..af4227eb101 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -777,6 +777,50 @@ struct Harness { stop(controller) } + // A fault arriving before the preserved fire must not re-anchor it + // either. Recovery re-enters `.running` with the fast fire still + // pending; a rebuild in that window must keep the original deadline — + // re-anchoring to `now + 1s` on every fault is what let repeated + // faults push the only liveness beat past Dart's unchanged 10s stall + // deadline after one lost healthy beat. + do { + let world = ReplayWorld() + let writers = WriterBox() + let controller = world.makeBatchController(writers: writers) + guard case .success = startBatch(controller, 22) else { + preconditionFailure("repeated fault: start failed") + } + drainMain(seconds: 0.5) + world.monitorBox.monitor!.fire(.interruptionBegan) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "interrupted" } + }, "repeated fault: interrupted") + drainMain(seconds: 0.6) + world.monitorBox.monitor!.fire(.interruptionEnded(shouldResume: true)) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "running" } + }, "repeated fault: resumed") + // A second fault lands before the preserved fire (~0.4s out) and + // recovers; neither may re-anchor the deadline. + world.monitorBox.monitor!.fire(.routeChanged(reasonDescription: "fault")) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "rebuilding" } + }, "repeated fault: rebuilding") + waitUntil({ + world.sink.deliveries.contains { + $0.kind == .state && $0.state == "running" && $0.sessionId == 22 + } && world.sink.deliveries.filter { + $0.kind == .state && $0.state == "running" + }.count >= 2 + }, "repeated fault: running again") + precondition(progressCount(world.sink) == 0, + "repeated fault: no beat expected before the second fault (timing drifted)") + drainMain(seconds: 0.7) + precondition(progressCount(world.sink) > 0, + "a fault before the preserved fire must keep the original deadline, not re-anchor it") + stop(controller) + } + print("phone-mic lifecycle replay: all 8 canonical vectors passed (stale/terminal-cleanup drops verified)") } } From e589cef5f697875799366e2a6425286c49f5719e Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 05:23:25 +0700 Subject: [PATCH 8/8] fix(ios): preserve an already-fast heartbeat deadline across repeated faults Codex review P2: a second route/config/interruption signal before the preserved 1s recovery beat fell through the healthy-only guard and rescheduled the already-fast deadline later. Keep the armed fast fire when the interval is already fast; only the healthy 4s cadence arms after the beat fires. Replay test pins the repeated-fault case. Co-authored-by: Cursor --- .../Runner/PhoneMic/PhoneMicController.swift | 20 ++++---- .../phone_mic_lifecycle_replay_test.swift | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/app/ios/Runner/PhoneMic/PhoneMicController.swift b/app/ios/Runner/PhoneMic/PhoneMicController.swift index b152a861e1b..2a6285038dc 100644 --- a/app/ios/Runner/PhoneMic/PhoneMicController.swift +++ b/app/ios/Runner/PhoneMic/PhoneMicController.swift @@ -584,16 +584,16 @@ final class PhoneMicController { private func applyProgressCadence() { let interval = desiredProgressInterval() - if interval == Self.healthyProgressTickInterval, - progressIntervalArmed != 0, progressIntervalArmed != Self.healthyProgressTickInterval { - // Retargeting out of a fault window (resume/rebuild recovery reaching - // `.running`): keep the already-armed fast fire exactly as armed. - // Re-scheduling here would re-anchor its deadline to `now + 1s` with - // the healthy cadence's looser leeway, so a recovery late in the - // fault window could deliver its beat after Dart's unchanged 10s - // stall deadline and restart a fully recovered session. The tick - // handler switches the repeating cadence back to the slow interval - // once the preserved fire has emitted the recovery beat. + if progressIntervalArmed == Self.fastProgressTickInterval { + // Already at the fast cadence: the fault window, or the preserved + // recovery beat that has not fired yet. Keep that deadline exactly + // as armed. The previous guard only covered the fast→healthy + // transition, so a second route/config/interruption before the beat + // fell through and re-anchored the fire to `now + 1s` — late enough + // in a missed-beat window, that walks the only liveness event past + // Dart's unchanged 10s stall deadline and restarts a recovered + // session. The tick handler arms the healthy 4s repeating cadence + // only after this beat actually fires. return } syncProgressTimer(first: interval, repeating: interval) diff --git a/app/ios/test/phone_mic_lifecycle_replay_test.swift b/app/ios/test/phone_mic_lifecycle_replay_test.swift index 59a4d7c6980..041f00b2e5a 100644 --- a/app/ios/test/phone_mic_lifecycle_replay_test.swift +++ b/app/ios/test/phone_mic_lifecycle_replay_test.swift @@ -777,6 +777,53 @@ struct Harness { stop(controller) } + // A second fault before the preserved 1s recovery beat must not push + // that deadline later. Interruption arms the 1s fire; recovering halfway + // through the window leaves it armed; a route rebuild that returns to + // .running before the fire used to fall through the healthy-only guard + // and re-anchor to now+1s. The beat must still land on the original + // deadline, then the healthy 4s cadence resumes. + do { + let world = ReplayWorld() + let writers = WriterBox() + let controller = world.makeBatchController(writers: writers) + guard case .success = startBatch(controller, 22) else { + preconditionFailure("repeated fault: start failed") + } + drainMain(seconds: 0.5) + world.monitorBox.monitor!.fire(.interruptionBegan) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "interrupted" } + }, "repeated fault: interrupted") + // Halfway through the fault window: the armed fire is ~0.5s out, + // enough for resume plus a second rebuild to land before it. + drainMain(seconds: 0.5) + world.monitorBox.monitor!.fire(.interruptionEnded(shouldResume: true)) + waitUntil({ + world.sink.deliveries.contains { $0.kind == .state && $0.state == "running" } + }, "repeated fault: resumed") + precondition(progressCount(world.sink) == 0, + "repeated fault: no beat expected before the second signal (timing drifted)") + let beforeRebuild = world.sink.deliveries.count + world.monitorBox.monitor!.fire(.routeChanged(reasonDescription: "3")) + waitUntil({ + world.sink.deliveries.dropFirst(beforeRebuild).contains { $0.kind == .state && $0.state == "running" } + }, "repeated fault: rebuilt") + precondition(progressCount(world.sink) == 0, + "repeated fault: second signal must land before the preserved beat (timing drifted)") + // Original deadline is ~0.5s out from the interruption wait; the + // regression re-anchors to rebuild+1s. 0.75s covers the original + // fire and not the pushed one. + drainMain(seconds: 0.75) + precondition(progressCount(world.sink) > 0, + "a second fault must keep the already-armed fast fire, not push it to now+1s") + let repeatedBeatCount = progressCount(world.sink) + drainMain(seconds: 2.0) + precondition(progressCount(world.sink) == repeatedBeatCount, + "after the preserved beat the heartbeat must return to the healthy cadence") + stop(controller) + } + print("phone-mic lifecycle replay: all 8 canonical vectors passed (stale/terminal-cleanup drops verified)") } }