Skip to content

Commit efa16a2

Browse files
authored
fix: use Task { @mainactor in } in all view button actions to prevent SIGSEGV crash (#121)
Bare Task { } closures inside SwiftUI Button actions caused an EXC_BAD_ACCESS (SIGSEGV) crash on macOS 26 when the Swift concurrency runtime called MainActor.assumeIsolated() on a stale executor reference. This was reliably triggered by: 1. Replug a microphone while recording (causes AVAudioEngineConfigurationChange) 2. App auto-recovers via onAudioDeviceChanged → sets appStatus = .idle 3. User clicks Stop Recording button on the now-stale SwiftUI view node 4. _ButtonGesture dispatches via MainActor.assumeIsolated() → reads freed memory → crash Fix: annotate every Task spawned from a Button/onAppear in views with @mainactor, which removes the implicit assumeIsolated() call and binds the task directly to the main actor instead. Also reset silenceCallbackFired and maxDurationCallbackFired in AudioEngine.handleAudioConfigurationChange so stale callback state cannot fire spurious stop/timeout events after a device reconnect.
1 parent 9021f53 commit efa16a2

5 files changed

Lines changed: 14 additions & 12 deletions

File tree

Sources/VocaMac/Services/AudioEngine.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ final class AudioEngine {
8888
VocaLogger.warning(.audioEngine, "Configuration changed while recording — forcing stop and reset")
8989
// Tear down the stale recording state
9090
isCurrentlyRecording = false
91+
silenceCallbackFired = false
92+
maxDurationCallbackFired = false
9193
engine.inputNode.removeTap(onBus: 0)
9294
engine.stop()
9395
}

Sources/VocaMac/Views/MenuBarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ struct MenuBarView: View {
219219
// Stop/recovery button — visible during recording so the user
220220
// can unstick the app if the hotkey isn't responding
221221
Button {
222-
Task {
222+
Task { @MainActor in
223223
await appState.stopRecordingAndTranscribe()
224224
}
225225
} label: {

Sources/VocaMac/Views/OnboardingView.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ struct OnboardingView: View {
160160
}
161161
.frame(width: 600, height: 550)
162162
.onAppear {
163-
Task {
163+
Task { @MainActor in
164164
await appState.performStartup()
165165
}
166166
}
@@ -425,12 +425,12 @@ struct ModelSelectionStep: View {
425425
return recommended == prefix || recommended.hasPrefix(prefix + "-")
426426
}(),
427427
onSelect: {
428-
Task {
428+
Task { @MainActor in
429429
await appState.loadModel(modelInfo.size)
430430
}
431431
},
432432
onDownload: {
433-
Task {
433+
Task { @MainActor in
434434
await appState.downloadModel(modelInfo.size)
435435
}
436436
}
@@ -785,12 +785,12 @@ struct QuickTestStep: View {
785785

786786
private func toggleRecording() {
787787
if isRecording {
788-
Task {
788+
Task { @MainActor in
789789
await appState.stopRecordingAndTranscribe()
790790
isRecording = false
791791
}
792792
} else {
793-
Task {
793+
Task { @MainActor in
794794
await appState.startRecording()
795795
isRecording = true
796796
}

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -490,13 +490,13 @@ struct ModelRow: View {
490490
EmptyView()
491491
} else if model.isDownloaded {
492492
Button("Load") {
493-
Task { await appState.loadModel(model.size) }
493+
Task { @MainActor in await appState.loadModel(model.size) }
494494
}
495495
.controlSize(.small)
496496
.buttonStyle(.borderedProminent)
497497
} else {
498498
Button("Download & Load") {
499-
Task {
499+
Task { @MainActor in
500500
await appState.downloadModel(model.size)
501501
if appState.availableModels.first(where: { $0.size == model.size })?.isDownloaded == true {
502502
await appState.loadModel(model.size)
@@ -511,7 +511,7 @@ struct ModelRow: View {
511511
.alert("Use Unoptimized Model?", isPresented: $showForceDownloadAlert) {
512512
Button("Cancel", role: .cancel) {}
513513
Button(model.isDownloaded ? "Load Anyway" : "Download & Load", role: .destructive) {
514-
Task {
514+
Task { @MainActor in
515515
if !model.isDownloaded {
516516
await appState.downloadModel(model.size)
517517
}
@@ -673,7 +673,7 @@ struct AboutTab: View {
673673
.foregroundStyle(.tertiary)
674674

675675
Button {
676-
Task {
676+
Task { @MainActor in
677677
await appState.updateChecker.checkForUpdates()
678678
if case .updateAvailable(let info) = appState.updateChecker.updateState {
679679
updateInfoForSheet = info

Sources/VocaMac/Views/UpdateView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ struct UpdateDetailView: View {
103103
Spacer()
104104

105105
Button("Download & Install") {
106-
Task {
106+
Task { @MainActor in
107107
await appState.updateChecker.downloadUpdate(info)
108108
}
109109
}
@@ -168,7 +168,7 @@ struct UpdateDetailView: View {
168168
.buttonStyle(.bordered)
169169

170170
Button("Retry") {
171-
Task {
171+
Task { @MainActor in
172172
await appState.updateChecker.downloadUpdate(info)
173173
}
174174
}

0 commit comments

Comments
 (0)