From b6592e2b4d6caf5720daa28746864e2d530325b5 Mon Sep 17 00:00:00 2001 From: beluga <109521734+belugaming@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:28:53 -0400 Subject: [PATCH] Open even when macOS's audio service is stuck; 0.16.10 ClassNote spun forever at launch whenever coreaudiod was deadlocked (a macOS bug: seen on 27.2 when an audio plug-in device changed while a system audio tap was running). Bootstrap listed the microphones on the main thread, and that Core Audio query never returned, so the window never drew. The microphone list now loads on a background thread, one query at a time. If coreaudiod has not answered within 5 seconds the app still opens and the library works; starting a recording is refused with an explanation (restart the Mac or run "sudo killall coreaudiod") instead of freezing the app. Settings and Diagnostics show the same message. A late answer no longer makes the app forget the chosen microphone, and the menu bar's microphone name is read from the loaded list instead of asking Core Audio on every redraw. Version 0.16.10 (build 28). Co-Authored-By: Claude Opus 5.5 --- App/AppState.swift | 81 ++++++++++++++++--- Core/Capture/AudioSourceManager.swift | 3 + Core/Utils/L10n.swift | 2 + Features/Settings/SettingsView.swift | 5 +- .../AudioServiceStallTests.swift | 78 ++++++++++++++++++ project.yml | 4 +- 6 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 Tests/ClassNoteTests/AudioServiceStallTests.swift diff --git a/App/AppState.swift b/App/AppState.swift index be46b5e..9d43ce6 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -44,7 +44,14 @@ final class AppState: ObservableObject { /// Which engine writes notes, answers questions and explains highlights. @Published var llmBackend: LLMBackend = .openAICompatible @Published var interruptedSessions: [Session] = [] - @Published var microphoneDevices: [MicrophoneInputDevice] = [] + /// Starts as just "System default"; the real list loads in the background. + @Published var microphoneDevices: [MicrophoneInputDevice] = [.systemDefault] + /// True while a device query has gone unanswered far longer than a healthy + /// Core Audio ever takes, which means coreaudiod itself is stuck. Starting + /// a recording then would freeze the app, so it is refused with an + /// explanation instead. Cleared as soon as the query is answered. + @Published private(set) var isAudioServiceUnresponsive = false + private var microphoneQuery: Task? @AppStorage("preferredMicrophoneDeviceID", store: AppEnvironment.defaults) var preferredMicrophoneDeviceID: String = MicrophoneInputDevice.systemDefaultID @Published private var importOrchestrators: [String: SessionOrchestrator] = [:] /// Task Center ids for the running imports/re-transcriptions, keyed the same @@ -184,11 +191,53 @@ final class AppState: ObservableObject { } } - func refreshMicrophoneDevices() { - microphoneDevices = MicrophoneDeviceCatalog.availableInputDevices() - if selectedMicrophoneUniqueID != nil, - !microphoneDevices.contains(where: { $0.id == preferredMicrophoneDeviceID }) { - preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID + /// Lists the microphones without blocking the main thread. + /// + /// The list comes from coreaudiod, and when that daemon deadlocks (a macOS + /// bug: seen on 27.2 when an audio device plug-in changed while a system + /// audio tap was running) the query never returns. Asked on the main + /// thread at launch, it left the app spinning before its window had drawn. + /// `stallAfter` and `query` are for tests. + func refreshMicrophoneDevices( + stallAfter: Duration = .seconds(5), + query: @escaping @Sendable () -> [MicrophoneInputDevice] = { MicrophoneDeviceCatalog.availableInputDevices() } + ) { + // One query at a time: a stuck one holds its thread until coreaudiod + // recovers, and Settings asks again every time it appears. + guard microphoneQuery == nil else { return } + microphoneQuery = Task { + let watchdog = Task { + try? await Task.sleep(for: stallAfter) + guard !Task.isCancelled else { return } + self.isAudioServiceUnresponsive = true + NSLog("[ClassNote] Core Audio has not answered a device query in %@; coreaudiod looks stuck", + String(describing: stallAfter)) + } + let devices = await Self.runOffMainThread(query) + watchdog.cancel() + let answeredLate = isAudioServiceUnresponsive + microphoneQuery = nil + isAudioServiceUnresponsive = false + microphoneDevices = devices + // A late answer comes from a daemon that was stuck or has just been + // restarted, and can be missing devices; it is not a reason to + // forget the user's microphone. + if !answeredLate, selectedMicrophoneUniqueID != nil, + !devices.contains(where: { $0.id == preferredMicrophoneDeviceID }) { + preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID + } + } + } + + /// On a GCD thread rather than the Swift concurrency pool: the call can + /// block for good, and GCD makes another thread when one is stuck. + private nonisolated static func runOffMainThread( + _ query: @escaping @Sendable () -> [MicrophoneInputDevice] + ) async -> [MicrophoneInputDevice] { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: query()) + } } } @@ -196,9 +245,12 @@ final class AppState: ObservableObject { preferredMicrophoneDeviceID == MicrophoneInputDevice.systemDefaultID ? nil : preferredMicrophoneDeviceID } + /// From the loaded list only. The menu bar reads this on every redraw, so + /// it must never ask Core Audio itself. var selectedMicrophoneName: String { microphoneDevices.first { $0.id == preferredMicrophoneDeviceID }?.name - ?? MicrophoneDeviceCatalog.name(for: selectedMicrophoneUniqueID) + ?? selectedMicrophoneUniqueID + ?? MicrophoneInputDevice.systemDefault.name } func refreshInterruptedSessions() async { @@ -280,6 +332,10 @@ final class AppState: ObservableObject { source: AudioSourceKind, translationEnabled: Bool? = nil) async -> String? { guard !isStartingRecording, !isRecording else { return nil } + guard !isAudioServiceUnresponsive else { + setError(L10n.t("record.error.audioServiceUnresponsive")) + return nil + } isStartingRecording = true defer { isStartingRecording = false } do { @@ -301,6 +357,10 @@ final class AppState: ObservableObject { func startEphemeralTranslation(source: AudioSourceKind = .microphone) { guard !isStartingRecording, !isRecording else { return } + guard !isAudioServiceUnresponsive else { + setError(L10n.t("record.error.audioServiceUnresponsive")) + return + } isStartingRecording = true Task { @MainActor in defer { self.isStartingRecording = false } @@ -648,8 +708,11 @@ final class AppState: ObservableObject { status: Database.shared.dbPool == nil ? .failed : .ok, detail: Database.shared.dbPool == nil ? L10n.t("diagnostics.database.failed") : L10n.t("diagnostics.ok"))) checks.append(.init(name: L10n.t("diagnostics.microphone"), - status: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized ? .ok : .warning, - detail: "\(L10n.t("diagnostics.microphone.detail")) \(selectedMicrophoneName)")) + status: AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + && !isAudioServiceUnresponsive ? .ok : .warning, + detail: isAudioServiceUnresponsive + ? L10n.t("record.error.audioServiceUnresponsive") + : "\(L10n.t("diagnostics.microphone.detail")) \(selectedMicrophoneName)")) checks.append(.init(name: L10n.t("diagnostics.screen"), status: CGPreflightScreenCaptureAccess() ? .ok : .warning, detail: L10n.t("diagnostics.screen.detail"))) diff --git a/Core/Capture/AudioSourceManager.swift b/Core/Capture/AudioSourceManager.swift index 11d2930..ef43477 100644 --- a/Core/Capture/AudioSourceManager.swift +++ b/Core/Capture/AudioSourceManager.swift @@ -42,6 +42,9 @@ struct MicrophoneInputDevice: Identifiable, Equatable, Hashable { } enum MicrophoneDeviceCatalog { + /// Asks coreaudiod, and waits for as long as it takes to answer: forever if + /// it is deadlocked. Keep it off the main thread; the app goes through + /// `AppState.refreshMicrophoneDevices()`. static func availableInputDevices() -> [MicrophoneInputDevice] { let devices = audioDevices().map { MicrophoneInputDevice(id: $0.uniqueID, diff --git a/Core/Utils/L10n.swift b/Core/Utils/L10n.swift index a8229e9..3d511ee 100644 --- a/Core/Utils/L10n.swift +++ b/Core/Utils/L10n.swift @@ -595,6 +595,7 @@ enum L10n { "sync.error.noICloudDrive": "iCloud Drive is off on this Mac. Turn it on in System Settings → Apple Account → iCloud.", "player.downloading": "Downloading from iCloud…", "settings.audio.refresh": "Refresh devices", + "record.error.audioServiceUnresponsive": "macOS's audio service (coreaudiod) is not responding, so ClassNote can't use the microphone or system audio right now. Your library still works. Restart the Mac, or run “sudo killall coreaudiod” in Terminal, then try again.", "settings.shortcuts.global": "Global shortcuts", "settings.shortcuts.toggleRecording": "Toggle recording", "settings.shortcuts.markHighlight": "Mark highlight", @@ -1282,6 +1283,7 @@ enum L10n { "sync.error.noICloudDrive": "这台 Mac 没有打开 iCloud 云盘。请在“系统设置 → Apple 账户 → iCloud”中打开。", "player.downloading": "正在从 iCloud 下载…", "settings.audio.refresh": "刷新设备", + "record.error.audioServiceUnresponsive": "macOS 的系统音频服务(coreaudiod)没有响应,ClassNote 暂时无法使用麦克风或系统音频。课程库仍可正常使用。请重启 Mac,或在终端运行“sudo killall coreaudiod”后再试。", "settings.shortcuts.global": "全局快捷键", "settings.shortcuts.toggleRecording": "开始/停止录制", "settings.shortcuts.markHighlight": "标记重点", diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift index 681d66a..34816b3 100644 --- a/Features/Settings/SettingsView.swift +++ b/Features/Settings/SettingsView.swift @@ -118,7 +118,10 @@ struct GeneralSettingsView: View { Toggle(L10n.t("settings.engines.liveTranslationToggle"), isOn: $appState.translationEnabled) } - SettingsSection(title: L10n.t("settings.audio.input"), footer: L10n.t("settings.audio.inputNote")) { + SettingsSection(title: L10n.t("settings.audio.input"), + footer: appState.isAudioServiceUnresponsive + ? L10n.t("record.error.audioServiceUnresponsive") + : L10n.t("settings.audio.inputNote")) { HStack { Picker(L10n.t("settings.audio.microphone"), selection: $appState.preferredMicrophoneDeviceID) { ForEach(appState.microphoneDevices) { device in diff --git a/Tests/ClassNoteTests/AudioServiceStallTests.swift b/Tests/ClassNoteTests/AudioServiceStallTests.swift new file mode 100644 index 0000000..626a01b --- /dev/null +++ b/Tests/ClassNoteTests/AudioServiceStallTests.swift @@ -0,0 +1,78 @@ +import Foundation +import XCTest +@testable import ClassNote + +/// coreaudiod can deadlock (a macOS bug), and then every Core Audio query +/// waits forever. The app used to list microphones on the main thread at +/// launch, so it spun before its window had drawn. +@MainActor +final class AudioServiceStallTests: XCTestCase { + private let usbMic = MicrophoneInputDevice(id: "usb-mic", name: "USB Mic", uniqueID: "usb-mic") + + override func tearDown() async throws { + AppState.shared.preferredMicrophoneDeviceID = MicrophoneInputDevice.systemDefaultID + AppState.shared.lastError = nil + try await super.tearDown() + } + + func testAStuckDeviceQueryLeavesTheAppResponsiveAndRefusesToRecord() async throws { + let appState = AppState.shared + let stuck = DispatchSemaphore(value: 0) + appState.refreshMicrophoneDevices(stallAfter: .milliseconds(50)) { + stuck.wait() + return [.systemDefault] + } + // Getting here at all means the main actor was not blocked. + try await waitUntil { appState.isAudioServiceUnresponsive } + + let sessionId = await appState.startNewSession(courseId: nil, source: .microphone) + XCTAssertNil(sessionId) + XCTAssertEqual(appState.lastError, L10n.t("record.error.audioServiceUnresponsive")) + XCTAssertFalse(appState.isRecording) + + stuck.signal() + try await waitUntil { !appState.isAudioServiceUnresponsive } + } + + func testOnlyAPromptAnswerForgetsAMissingMicrophone() async throws { + let appState = AppState.shared + appState.preferredMicrophoneDeviceID = usbMic.id + + // Late, as from a daemon that was just restarted: the USB mic is + // missing from the answer, but the preference stays. + let late = DispatchSemaphore(value: 0) + appState.refreshMicrophoneDevices(stallAfter: .milliseconds(50)) { + late.wait() + return [.systemDefault] + } + try await waitUntil { appState.isAudioServiceUnresponsive } + late.signal() + // Cleared in the same main-actor turn that applies the answer. + try await waitUntil { !appState.isAudioServiceUnresponsive } + XCTAssertEqual(appState.preferredMicrophoneDeviceID, usbMic.id) + XCTAssertEqual(appState.selectedMicrophoneName, usbMic.id) + + // Prompt and still without it: the mic really is gone. + appState.refreshMicrophoneDevices { [.systemDefault] } + try await waitUntil { appState.preferredMicrophoneDeviceID == MicrophoneInputDevice.systemDefaultID } + } + + func testSelectedNameComesFromTheLoadedList() async throws { + let appState = AppState.shared + let mic = usbMic + appState.refreshMicrophoneDevices { [.systemDefault, mic] } + try await waitUntil { appState.microphoneDevices.contains(mic) } + appState.preferredMicrophoneDeviceID = mic.id + XCTAssertEqual(appState.selectedMicrophoneName, "USB Mic") + } + + private func waitUntil(_ condition: () -> Bool, + file: StaticString = #filePath, line: UInt = #line) async throws { + var waited = 0 + while !condition(), waited < 300 { + try await Task.sleep(nanoseconds: 10_000_000) + waited += 1 + } + XCTAssertTrue(condition(), "condition never became true", file: file, line: line) + } +} diff --git a/project.yml b/project.yml index f128270..86741f0 100644 --- a/project.yml +++ b/project.yml @@ -17,8 +17,8 @@ settings: SWIFT_STRICT_CONCURRENCY: complete SWIFT_TREAT_WARNINGS_AS_ERRORS: "NO" ENABLE_USER_SCRIPT_SANDBOXING: NO - MARKETING_VERSION: "0.16.9" - CURRENT_PROJECT_VERSION: "27" + MARKETING_VERSION: "0.16.10" + CURRENT_PROJECT_VERSION: "28" # Apple Silicon only, in every configuration: a Release build would # otherwise also carry an Intel slice nobody can use. ARCHS: arm64