Skip to content

Commit 5c34e32

Browse files
Address review findings: fix crash, data race, signal safety
Fixes found by quality-gate agents + braintrust (Claude/Gemini/Codex): - Fix KVO crash: removeObserver wrapped in #if false to match addObserver - Fix data race: move sample delegate to .main queue (sampleCount/lastSampleTime were written on background queue, read on main) - Fix signal handler: remove stopRunning() from signalShutdown() to avoid deadlock on Bluetooth devices during SIGTERM/SIGINT - Add stale heartbeat guard: check sessionID == sid before acting - Add stopRunning() watchdog: log warning if background stop hangs >10s - Log PID file write failures instead of swallowing with try? - Move no-op device/output listeners fully inside #if false - Simplify device ID lookup with first(where:)
1 parent 8b75628 commit 5c34e32

File tree

1 file changed

+23
-19
lines changed

1 file changed

+23
-19
lines changed

Sources/MicWarm/main.swift

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,12 @@ func logAllDevices(prefix: String) {
117117
let pidPath = "/tmp/mic-warm.pid"
118118

119119
func writePID() {
120-
try? "\(ProcessInfo.processInfo.processIdentifier)".write(
121-
toFile: pidPath, atomically: true, encoding: .utf8)
120+
do {
121+
try "\(ProcessInfo.processInfo.processIdentifier)".write(
122+
toFile: pidPath, atomically: true, encoding: .utf8)
123+
} catch {
124+
log("Warning: Could not write PID file \(pidPath): \(error.localizedDescription)")
125+
}
122126
}
123127

124128
func cleanupPID() {
@@ -191,7 +195,9 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
191195
let oldSid = sessionID
192196
let oldSamples = sampleCount
193197
log("[session-\(oldSid)] Stopping old session (samples received: \(oldSamples))")
198+
#if false // Enable for instrumented debugging
194199
old.removeObserver(self, forKeyPath: "running")
200+
#endif
195201
// Stop the old session on a background thread. stopRunning() can deadlock
196202
// if the session's Bluetooth device has already disconnected (CoreAudio hangs
197203
// in AudioObjectRemovePropertyListener waiting on a dead device).
@@ -200,6 +206,12 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
200206
old.stopRunning()
201207
log("[session-\(oldSid)] Background: old session stopped")
202208
}
209+
// Watchdog: warn if stopRunning() hangs (likely deadlocked on dead device)
210+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 10.0) { [weak old] in
211+
if let old, old.isRunning {
212+
log("[session-\(oldSid)] WARNING: stopRunning() did not complete in 10s (likely deadlocked on dead Bluetooth device)")
213+
}
214+
}
203215
}
204216
session = nil
205217
stopHeartbeat()
@@ -216,15 +228,9 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
216228
}
217229

218230
// Get CoreAudio device ID for this AVCaptureDevice
219-
let allDevices = getAllAudioDeviceIDs()
220-
let deviceUID = device.uniqueID
221-
currentDeviceID = 0
222-
for d in allDevices {
223-
if getStringProperty(d, selector: kAudioDevicePropertyDeviceUID) == deviceUID {
224-
currentDeviceID = d
225-
break
226-
}
227-
}
231+
currentDeviceID = getAllAudioDeviceIDs().first {
232+
getStringProperty($0, selector: kAudioDevicePropertyDeviceUID) == device.uniqueID
233+
} ?? 0
228234

229235
log("[session-\(sid)] Opening device: \(device.localizedName) [id=\(currentDeviceID)]")
230236

@@ -242,8 +248,7 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
242248
}
243249

244250
let output = AVCaptureAudioDataOutput()
245-
let queue = DispatchQueue(label: "mic-warm.audio", qos: .userInitiated)
246-
output.setSampleBufferDelegate(self, queue: queue)
251+
output.setSampleBufferDelegate(self, queue: .main)
247252
s.addOutput(output)
248253

249254
log("[session-\(sid)] Starting session...")
@@ -301,7 +306,7 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
301306
let timer = DispatchSource.makeTimerSource(queue: .main)
302307
timer.schedule(deadline: .now() + 5.0, repeating: 5.0)
303308
timer.setEventHandler { [weak self] in
304-
guard let self else { return }
309+
guard let self, self.sessionID == sid else { return }
305310
let delta = self.sampleCount - self.lastHeartbeatSampleCount
306311
let running = self.session?.isRunning ?? false
307312
let gap = self.lastSampleTime.map { Date().timeIntervalSince($0) } ?? -1
@@ -377,6 +382,7 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
377382
self.debouncedRestart(reason: "default input device changed (new: \(currentDevice?.localizedName ?? "nil"))")
378383
}
379384

385+
#if false // Enable for instrumented debugging
380386
// Device list changes (additions/removals)
381387
var devicesAddr = AudioObjectPropertyAddress(
382388
mSelector: kAudioHardwarePropertyDevices,
@@ -385,10 +391,8 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
385391
AudioObjectAddPropertyListenerBlock(
386392
AudioObjectID(kAudioObjectSystemObject), &devicesAddr, DispatchQueue.main
387393
) { _, _ in
388-
#if false // Enable for instrumented debugging
389394
log("[system] *** Device list changed")
390395
logAllDevices(prefix: "[system]")
391-
#endif
392396
}
393397

394398
// Default output device changes (relevant for Bluetooth mode switching)
@@ -399,15 +403,14 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
399403
AudioObjectAddPropertyListenerBlock(
400404
AudioObjectID(kAudioObjectSystemObject), &outputAddr, DispatchQueue.main
401405
) { _, _ in
402-
#if false // Enable for instrumented debugging
403406
let outputID: AudioDeviceID? = getAudioProperty(
404407
AudioObjectID(kAudioObjectSystemObject),
405408
selector: kAudioHardwarePropertyDefaultOutputDevice)
406409
if let oid = outputID {
407410
log("[system] Default output device changed -> \(describeDevice(oid))")
408411
}
409-
#endif
410412
}
413+
#endif
411414
}
412415

413416
#if false // Enable for instrumented debugging
@@ -591,7 +594,8 @@ class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
591594
}
592595

593596
func signalShutdown() {
594-
session?.stopRunning()
597+
// Skip stopRunning() here: it can deadlock on Bluetooth devices, and
598+
// _exit(0) follows immediately so the OS reclaims all resources.
595599
session = nil
596600
cleanupPID()
597601
}

0 commit comments

Comments
 (0)