fix(mac): prevent Option release deadlock and HUD layout storm

The hold-to-talk release path called AsyncStream.Continuation.finish()
while holding MacAudioRecorder's NSLock; onTermination re-entered the
same lock on the main thread and froze the app. Finish snapshot sinks
outside the lock and guard the audio tap once stop() begins.

The dictation HUD no longer reassigns its hosting view and forces a
synchronous relayout on every view-model tick; it uses a fixed panel
size and SwiftUI @ObservedObject refresh instead.

Adds OSGKeyboardMacTests with a regression test that fails under the
old lock re-entry pattern.
This commit is contained in:
Rocky
2026-07-29 16:50:32 +08:00
parent 65fe3a81b4
commit 2d44423f4c
7 changed files with 228 additions and 79 deletions
@@ -0,0 +1,57 @@
// MacAudioRecorderSnapshotStreamTests.swift
// OSGKeyboard · Mac tests
//
// Regression guard for the freeze that hit when the hold-to-talk key was
// released. `MacAudioRecorder` finished its snapshot continuation while holding
// a non-reentrant `NSLock`; `AsyncStream.Continuation.finish()` invokes
// `onTermination` synchronously on the calling thread, that handler re-took the
// same lock, and because the release path runs `stop()` on the main actor the
// whole app wedged.
import XCTest
@testable import OSGKeyboard
final class MacAudioRecorderSnapshotStreamTests: XCTestCase {
/// Installing a second stream finishes the first one. Run off-main and
/// bounded by a semaphore timeout so a reintroduced lock re-entry fails the
/// test instead of hanging the whole suite.
func testReplacingSnapshotStreamDoesNotDeadlock() {
let recorder = MacAudioRecorder()
let firstStream = recorder.makeSnapshotStream()
let drain = Task { for await _ in firstStream {} }
let installed = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
_ = recorder.makeSnapshotStream()
installed.signal()
}
XCTAssertEqual(
installed.wait(timeout: .now() + 2),
.success,
"Replacing the snapshot stream deadlocked: finish() ran while holding the recorder lock."
)
drain.cancel()
}
/// The outgoing stream's termination handler fires *during* the install of
/// its replacement, so it must recognise itself as stale and leave the new
/// sink attached otherwise live ASR silently receives no audio.
func testReplacingSnapshotStreamKeepsTheNewSinkAttached() {
let recorder = MacAudioRecorder()
let firstStream = recorder.makeSnapshotStream()
let drain = Task { for await _ in firstStream {} }
let secondStream = recorder.makeSnapshotStream()
XCTAssertTrue(
recorder.hasLiveSnapshotSink,
"The replaced stream's termination detached the sink that had just replaced it."
)
// The sink lives only as long as the stream: releasing `secondStream`
// early would terminate it and invalidate the assertion above.
withExtendedLifetime(secondStream) {}
drain.cancel()
}
}