From cc5671761fa630c05480c7e00c7dc98b850d45e7 Mon Sep 17 00:00:00 2001 From: Chukwuebuka-2003 Date: Wed, 29 Jul 2026 22:52:47 +0000 Subject: [PATCH 1/2] fix: eliminate data races in MicRecorder and SystemAudioRecorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both recorder classes shared mutable state (file, firstBufferAt) between the main thread and audio-tap / IOProc callbacks without synchronization — a Swift undefined behavior under strict concurrency and a real crash risk under load or on arm64 with compiler reordering. Replace bare stored properties with OSAllocatedUnfairLock-backed computed properties. The LockedState struct holds the two fields; every read/write on either thread goes through state.withLock { }. Liveness-check fields (livenessFrames, livenessPeak, livenessSettled) remain plain because they are written only in the tap callback and read only via DispatchQueue.main.async (fallBackToRaw), which establishes a happens-before ordering. --- Sources/quill/Audio/MicRecorder.swift | 24 ++++++++++++++++--- Sources/quill/Audio/SystemAudioRecorder.swift | 21 ++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Sources/quill/Audio/MicRecorder.swift b/Sources/quill/Audio/MicRecorder.swift index 88485ed..7cba0ec 100644 --- a/Sources/quill/Audio/MicRecorder.swift +++ b/Sources/quill/Audio/MicRecorder.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import os.lock /// Records the default input device to a file via AVAudioEngine, encoding AAC /// mono. Buffers stream straight to disk — nothing is held in memory, so @@ -28,15 +29,32 @@ final class MicRecorder: @unchecked Sendable { } private var engine = AVAudioEngine() - private var file: AVAudioFile? private var url: URL? private(set) var isRecording = false + + // Thread-safe shared state: accessed from both the main thread and the + // audio-tap callback (background audio thread) without further sync. + private struct LockedState { + var file: AVAudioFile? + var firstBufferAt: Date? + } + private let state = OSAllocatedUnfairLock(initialState: LockedState()) + + private var file: AVAudioFile? { + get { state.withLock { $0.file } } + set { state.withLock { $0.file = newValue } } + } + /// Wall-clock time of the first captured buffer — the track's true start, /// used to offset-align the two tracks' transcript timestamps. - private(set) var firstBufferAt: Date? + private(set) var firstBufferAt: Date? { + get { state.withLock { $0.firstBufferAt } } + set { state.withLock { $0.firstBufferAt = newValue } } + } // Liveness check state (voice-processing path only). Written from the tap - // callback, read on main when deciding to fall back. + // callback, read on main when deciding to fall back. The dispatch to main + // in fallBackToRaw creates a happens-before, so these need no lock. private var livenessFrames = 0 private var livenessPeak: Float = 0 private var livenessSettled = false diff --git a/Sources/quill/Audio/SystemAudioRecorder.swift b/Sources/quill/Audio/SystemAudioRecorder.swift index 7e062ce..be3b437 100644 --- a/Sources/quill/Audio/SystemAudioRecorder.swift +++ b/Sources/quill/Audio/SystemAudioRecorder.swift @@ -1,6 +1,7 @@ import AVFoundation import CoreAudio import Foundation +import os.lock /// Records all system audio output to a file via a Core Audio process tap /// (macOS 14.2+). No virtual device, no kernel extension — the tap mixes every @@ -32,12 +33,28 @@ final class SystemAudioRecorder { private var tapID = AudioObjectID(kAudioObjectUnknown) private var aggregateID = AudioObjectID(kAudioObjectUnknown) private var procID: AudioDeviceIOProcID? - private var file: AVAudioFile? private let queue = DispatchQueue(label: "com.digimata.quill.system-tap") private(set) var isRecording = false + + // Thread-safe shared state: accessed from both the main thread and the + // IOProc callback (background serial queue) without further sync. + private struct LockedState { + var file: AVAudioFile? + var firstBufferAt: Date? + } + private let state = OSAllocatedUnfairLock(initialState: LockedState()) + + private var file: AVAudioFile? { + get { state.withLock { $0.file } } + set { state.withLock { $0.file = newValue } } + } + /// Wall-clock time of the first captured buffer — the track's true start, /// used to offset-align the two tracks' transcript timestamps. - private(set) var firstBufferAt: Date? + private(set) var firstBufferAt: Date? { + get { state.withLock { $0.firstBufferAt } } + set { state.withLock { $0.firstBufferAt = newValue } } + } /// Start capturing system audio, encoding AAC into `url` (use a .caf /// extension — CAF needs no finalization pass, so a crash mid-meeting From df01e07dbca5a625d70017948f805c9601a3c9fd Mon Sep 17 00:00:00 2001 From: Chukwuebuka-2003 Date: Wed, 29 Jul 2026 23:12:25 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20repair=20invalid=20feather=20SVG=20?= =?UTF-8?q?=E2=80=94=20trailing=20backslashes=20broke=20XML=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each line of the inlined SVG ended with `\\` (escaped backslash) which produced a literal `\` before the newline in the runtime string. Inside the opening tag this is not valid XML — a bare `\` is neither whitespace nor a valid attribute name character. NSImage's WebKit-based SVG parser silently recovers from the error in practice, but this is fragile. The trailing `\` line continuations were superfluous anyway (the newlines and indentation are valid XML whitespace), so removing them yields a correct, parseable SVG. --- Sources/quill/UI/MenuBarController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/quill/UI/MenuBarController.swift b/Sources/quill/UI/MenuBarController.swift index 30676b6..4f2dc9f 100644 --- a/Sources/quill/UI/MenuBarController.swift +++ b/Sources/quill/UI/MenuBarController.swift @@ -90,12 +90,12 @@ final class MenuBarController { // has no separate resource bundle to install alongside it — true // single-binary. private static let featherSVG = """ - \ - \ - \ - \ + + + + """