uttrflow / uttrflow/uttrflow-swift

Microphone source permanently retains its input session through the device-change callback

Open
#788 0 comments 0 reactions 0 assignees View on GitHub
area:dictation bug confirmed P2
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.