uttrflow / uttrflow/uttrflow-swift
Microphone source permanently retains its input session through the device-change callback
- Dominant language
- Swift
- Stars
- 4
- Forks
- 17
- Avg merge
- 3h 32m
- Merged PRs (30d)
- 277
Description
## Problem
On main `8d93f5b8574f3c7b8c30086d9c817bf7f5d4827d`, constructing `AVAudioEngineMicrophoneSource` creates a strong ownership cycle:
`InputDeviceSession.device` → `EngineDevice.changed` → stored handler closure → `InputDeviceSession`.
The source initializer captures `session` strongly in `device.whenChanged { session.deviceChanged() }` (`Sources/UttrflowAudio/AVAudioEngineMicrophoneSource.swift:150-153`). The session owns the device (`Sources/UttrflowAudio/InputDeviceSession.swift:49`). `stop(draining:)` closes the session but does not break this callback ownership.
The source itself deallocates, but its session, device, change handler and associated state do not. `Sources/uttrflow-eval/RecordCorpus.swift:123-128` creates a fresh source for each take and re-record attempt, accumulating these objects over the recording session. The app constructs one source at startup, so this finding does not establish per-dictation growth in the app or a microphone left running after stop.
## Reproduction
A Swift Testing test compiled against the unchanged production Audio/Core modules on macOS, without starting the microphone:
```swift
import Testing
@testable import UttrflowAudio
@Test func stoppedSourceReleasesSession() async throws {
weak var sourceReference: AVAudioEngineMicrophoneSource?
weak var sessionReference: InputDeviceSession?
do {
let source = AVAudioEngineMicrophoneSource()
sourceReference = source
let session = try #require(
Mirror(reflecting: source).children.first {
$0.label == "session"
}?.value as? InputDeviceSession)
sessionReference = session
await source.stop(draining: false)
}
#expect(sourceReference == nil)
#expect(sessionReference == nil)
}
```
Observed: the source assertion passes; the session assertion fails because the session remains allocated. Reflection is only used in this reproduction to observe the private ownership edge.
## Acceptance criteria
- Make the device-change callback non-owning with respect to the session, or otherwise define ownership so both objects deallocate after the source is released.
- Add a deterministic lifetime regression test that fails on the current code, including the stopped case; it must not require microphone permission.
- Preserve the existing device-change recovery and handler stack-depth tests.
Checked against open and closed issues and open PRs. #171 addresses engine publication racing stop; #411 reports separate OnboardingFlow and MLX cycles. Neither covers this callback cycle.
Contributor guide
Research direction
Start in Sources/UttrflowAudio/AVAudioEngineMicrophoneSource.swift:150-153 and trace the session ownership in Sources/UttrflowAudio/InputDeviceSession.swift. Add the lifetime regression test from the issue without starting the microphone, then run it against the Audio/Core modules. Done means the stopped source releases its session while device-change recovery and handler stack-depth tests still pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- audio-video-rtc, desktop, testing-qa
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100