"컴파일러가 버그를 놓친 게 아니야. 검사를 하나 끼워 넣었고, 그 검사가 코드가 한 번도 피하겠다고 약속한 적 없는 스레드에서 터졌어."
첫 단어에서 난 크래시
2026-09-15 에 Firekeeper 가 켜자마자 첫 녹음에서 죽기 시작했어. 크래시 리포트 둘 다 RealtimeMessenger.mServiceQueue 위의 EXC_BREAKPOINT 였어. AVFAudio 의 실시간 큐, 마이크 버퍼를 건네주는 스레드지. 터진 스택은 swift_task_isCurrentExecutor 를 지나고 있었어. Swift 가 직접 프로그램을 멈춘 거야.
업그레이드가 바꾼 것
그 전날 가족 전체 일괄 작업이 Firekeeper 를 Swift 6 와 Observation 프레임워크로 옮기면서 AudioCaptureService 에 @MainActor 를 붙였어. 별일 없어 보였지. 캡처는 UI 에서 시작하니까. 그런데 마이크 탭이 메인 액터 메서드 안에 인라인 클로저로 적혀 있었어. Swift 6 에선 @Sendable 이 안 붙은 클로저가 자기가 적힌 자리의 격리를 물려받아. 그래서 컴파일러는 그 탭을 메인 액터 코드로 보고, 메인 액터에서 도는지 확인하는 런타임 검사를 끼워 넣었어. AVFAudio 의 탭 블록 타입엔 sendable 표시가 없으니 그 추론을 막을 것도 없었고. 첫 버퍼가 실시간 큐로 들어오고, 검사가 실패하고, 앱이 멈췄어. 하루 늦게 드러난 건 그 사이 설치된 앱을 다시 빌드하지 않았기 때문이야.
@MainActor final class AudioCaptureService {
func start() {
input.installTap(...) { buffer, _ in // MainActor 메서드 안에 적힌 인라인 클로저
... // -> MainActor 로 추론 -> 런타임 실행자 검사
} // AVFAudio 가 실시간 큐에서 부름 -> TRAP
}
}
해법은 이미 있었어
가족 헬스 앱이 닷새 전에 정확히 이 크래시를 겪었고, 공용 키트엔 이미 답이 있었어. CwkKitSpeech.AudioTap.block(into:) 은 탭 블록을 액터 바깥에서 만들어. 그러니 어떤 격리도 거기 추론될 수가 없지. 일괄 작업이 그걸 안 썼을 뿐이야. 이제 Firekeeper 는 탭을 키트에서 받고, 절대 인라인으로 안 적어. 탭이 그다음에 부르는 레벨 콜백 타입에도 @Sendable 을 붙였어. 거기가 다음 덫이었을 테니까. 남은 Swift 6 경고 둘은 나중에 '조용히 시키지 말고 검사되는 구조로' 닫았어. 눌러 둔 경고는 미뤄 둔 덫이니까.
세대는 취소만이 아니라 쓰기도 지켜야 해
더블 탭 레슨에서 오디오 세션에 세대 토큰을 줬지. 낡은 취소가 아무것도 못 하게. 2026-09-27 교차 브레인 리뷰가 한 층 위에서 같은 유령을 찾았어. 전사가 나가 있는 동안 받아쓰기를 취소하고, 새로 녹음을 시작하고, 그다음 옛 전사가 돌아오게 해 봐. 옛 파이프라인의 취소 처리기가 새 녹음 위에다 공유 상태를 idle 로 바꿔 버렸어. 캡처 쪽 세대 가드는 새 마이크를 멈추는 걸 제대로 거부했지. 그래서 마이크는 켜진 채로 남고, 상태는 idle 이라고 말하고, Stop 은 무시됐어. 그래서 세대 확인을 취소에서 모든 쓰기로 넓혔어. DictationSessions 장부가 시작이나 취소마다 새 세션을 열고, 파이프라인은 녹음이 멈출 때 현재였던 세션을 들고 다니고, await 뒤의 쓰기는 전부 state(owning: session) 을 거쳐. 밀려난 파이프라인이면 그 함수는 아무것도 안 돌려줘. 늦게 온 쓰기는 아무 데도 안 떨어지는 거지.
@Sendable 로 표시해. 안 그러면 언어가 네가 한 적도 없는 가정을 검사해.