iOS unit-test host crashes intermittently in simulator TextToSpeech during launch (EXC_BAD_ACCESS in objc_retain) #1001

Closed
opened 2026-08-07 13:18:04 +00:00 by spikerj · 1 comment
Owner

Symptom

Running the SpikerSoft iOS unit-test scheme in the simulator intermittently segfaults ~1.5 s after launch:

  • `EXC_BAD_ACCESS (SIGSEGV)`, `KERN_INVALID_ADDRESS` in `objc_retain`, main thread
  • All crashing frames are inside Apple's TextToSpeech.framework (block dispatched to the main queue); the XCTWaiter frames below are just XCTest pumping the run loop
  • No app/test frames in the stack; the current unit tests are pure logic tests that never speak

Cause

The unit tests run inside the SpikerSoft host app, so `AppDependencies.shared` is built at launch and `AudioCueService.init` eagerly creates its `AVSpeechSynthesizer` + sets the delegate. Merely instantiating the synthesizer spins up the simulator's TTS stack (MacinTalk / SpeechDictionary / MauiTTSSupport, five AXSpeech threads), whose async voice-asset setup intermittently retains a dead pointer — a known-flaky area of the simulator runtime. Our ownership is correct (single long-lived synthesizer, delegate alive); the crash is in framework init, not our code.

Impact

Flaky local test runs, and an intermittent CI flake for the iOS coverage lane being added on `feat/test-coverage-sonar` (#1000).

Fix

Create the synthesizer lazily on first `play(_:)` in `AudioCueService` so unit-test runs (which never announce) never touch TextToSpeech. App behavior unchanged — first real cue constructs it on the main actor.

## Symptom Running the SpikerSoft iOS unit-test scheme in the simulator intermittently segfaults ~1.5 s after launch: - \`EXC_BAD_ACCESS (SIGSEGV)\`, \`KERN_INVALID_ADDRESS\` in \`objc_retain\`, main thread - All crashing frames are inside Apple's **TextToSpeech.framework** (block dispatched to the main queue); the XCTWaiter frames below are just XCTest pumping the run loop - No app/test frames in the stack; the current unit tests are pure logic tests that never speak ## Cause The unit tests run inside the SpikerSoft host app, so \`AppDependencies.shared\` is built at launch and \`AudioCueService.init\` eagerly creates its \`AVSpeechSynthesizer\` + sets the delegate. Merely instantiating the synthesizer spins up the simulator's TTS stack (MacinTalk / SpeechDictionary / MauiTTSSupport, five AXSpeech threads), whose async voice-asset setup intermittently retains a dead pointer — a known-flaky area of the simulator runtime. Our ownership is correct (single long-lived synthesizer, delegate alive); the crash is in framework init, not our code. ## Impact Flaky local test runs, and an intermittent CI flake for the iOS coverage lane being added on \`feat/test-coverage-sonar\` (#1000). ## Fix Create the synthesizer lazily on first \`play(_:)\` in \`AudioCueService\` so unit-test runs (which never announce) never touch TextToSpeech. App behavior unchanged — first real cue constructs it on the main actor.
Author
Owner

Verified complete 2026-08-07 — closing.

  • Code: spikersoft-ios@6926525 (PR #15, fix/audiocue-lazy-synthesizer-1001, commit 80ab510) — SpikerSoft/Core/Audio/AudioCueService.swift:27 is now
    private lazy var synthesizer: AVSpeechSynthesizer = {
        let synthesizer = AVSpeechSynthesizer()
        synthesizer.delegate = self
        return synthesizer
    }()
    
    I checked every remaining reference so the laziness actually holds on a test-host launch: the only touches are inside play(_:) (:52 stopSpeaking, :56 speak) and deactivateSession() (:94), and the latter short-circuits on guard sessionActive, … before evaluating synthesizer.isSpeaking — sessionActive is only set true by activateSession() from within play(_:). So a unit-test run that never announces never constructs the synthesizer and never spins up the simulator TTS stack. App behavior is unchanged: the first real cue builds it on the main actor.
  • Live: nothing runtime-observable — this is test-host/CI hygiene, so on-main code evidence is the whole bar. Corroborating: the iOS coverage lane it was blocking now runs clean — SonarQube shows project spikersoft-ios analyzed 2026-08-07T13:36:09+0000.
  • Shipped by: ios PR #15.

Not migrated: nothing left to do.

— Opus 5 Agent

Verified complete 2026-08-07 — closing. - **Code:** `spikersoft-ios@6926525` (PR #15, `fix/audiocue-lazy-synthesizer-1001`, commit `80ab510`) — `SpikerSoft/Core/Audio/AudioCueService.swift:27` is now ```swift private lazy var synthesizer: AVSpeechSynthesizer = { let synthesizer = AVSpeechSynthesizer() synthesizer.delegate = self return synthesizer }() ``` I checked every remaining reference so the laziness actually holds on a test-host launch: the only touches are inside `play(_:)` (`:52` `stopSpeaking`, `:56` `speak`) and `deactivateSession()` (`:94`), and the latter short-circuits on `guard sessionActive, …` before evaluating `synthesizer.isSpeaking` — `sessionActive` is only set true by `activateSession()` from within `play(_:)`. So a unit-test run that never announces never constructs the synthesizer and never spins up the simulator TTS stack. App behavior is unchanged: the first real cue builds it on the main actor. - **Live:** nothing runtime-observable — this is test-host/CI hygiene, so on-main code evidence is the whole bar. Corroborating: the iOS coverage lane it was blocking now runs clean — SonarQube shows project `spikersoft-ios` analyzed `2026-08-07T13:36:09+0000`. - **Shipped by:** ios PR #15. Not migrated: nothing left to do. — Opus 5 Agent
Sign in to join this conversation.