Skip to content

Comments

remove tap on engine start failure, fix converter single-use#390

Merged
Siddhesh2377 merged 1 commit intoRunanywhereAI:mainfrom
sakirr05:fix/198-audio-capture-manager-leak-and-converter
Feb 19, 2026
Merged

remove tap on engine start failure, fix converter single-use#390
Siddhesh2377 merged 1 commit intoRunanywhereAI:mainfrom
sakirr05:fix/198-audio-capture-manager-leak-and-converter

Conversation

@sakirr05
Copy link
Contributor

@sakirr05 sakirr05 commented Feb 19, 2026

Fixes #198 AudioCaptureManager: resource leak on engine start failure + audio corruption in converter

Description

Fixes two bugs in AudioCaptureManager (Swift SDK):

  1. Resource leak on engine start failure: If engine.start() threw after installing the tap on inputNode, the tap was never removed, leaking the tap and leaving the node in a bad state.
  2. Audio corruption in converter: The AVAudioConverterInputBlock in convert(buffer:using:to:) always returned the same buffer with .haveData on every call. AVAudioConverter.convert(to:error:withInputFrom:) can invoke the block multiple times, causing duplicated/corrupted audio.

Files changed:

  • sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift — both fixes
  • sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift — new unit tests
  • Package.swift — new test target RunAnywhereTests

Note: The issue references sdk/runanywhere-swift/Modules/ONNXRuntime/Sources/ONNXRuntime/AudioCaptureManager.swift; in this repo the same logic lives in sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift, and that file was updated.


Code examples

Fix 1 — Tap cleanup on engine start failure

Before (tap leaked if engine.start() threw):

inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { ... }

try engine.start()

self.audioEngine = engine
self.inputNode = inputNode

After (tap removed before rethrowing):

inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { ... }

// Start engine (remove tap on failure to avoid resource leak)
do {
    try engine.start()
} catch {
    inputNode.removeTap(onBus: 0)
    throw error
}

self.audioEngine = engine
self.inputNode = inputNode

Fix 2 — Converter input block single-use

Before (same buffer returned every call → duplication/corruption):

var error: NSError?
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
    outStatus.pointee = .haveData
    return buffer
}
converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock)

After (buffer provided once, then .endOfStream):

var error: NSError?
var hasProvidedData = false
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
    if hasProvidedData {
        outStatus.pointee = .endOfStream
        return nil
    }
    hasProvidedData = true
    outStatus.pointee = .haveData
    return buffer
}
converter.convert(to: convertedBuffer, error: &error, withInputFrom: inputBlock)

New test target (Package.swift):

.testTarget(
    name: "RunAnywhereTests",
    dependencies: ["RunAnywhere"],
    path: "sdk/runanywhere-swift/Tests/RunAnywhereTests"
),

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Refactoring

Testing

  • Lint passes locally
  • Added/updated tests for changes

Local verification (pre-commit on changed files):

trim trailing whitespace...................... Passed
fix end of files.............................. Passed
check for added large files................... Passed
check for merge conflicts.................... Passed
iOS SDK SwiftLint AutoFix..................... Passed
iOS SDK SwiftLint............................. Passed
iOS SDK Periphery (Unused Code)............... Passed

Unit tests added (AudioCaptureManagerTests.swift):

Test Purpose
testStartRecordingFailureLeavesCleanState After startRecording throws, isRecording is false and a subsequent start can be attempted (validates clean state / tap cleanup path).
testConvertProducesNonDuplicatedOutput Single convert() call produces output frame count consistent with one pass (no double feed).
testConvertIsIdempotentAcrossCalls Two separate convert() calls with the same buffer each produce valid, same-length output (per-call hasProvidedData).

Full Swift test run requires macOS (AVFoundation); CI runs swift test on macos-14 for this package.

Platform-Specific Testing (check all that apply)

Swift SDK / iOS Sample:

  • Tested on iPhone (Simulator or Device)
  • Tested on iPad / Tablet
  • Tested on Mac (macOS target)

PR author environment: Windows + WSL; Swift/AVFoundation tests run in GitHub Actions on macOS.

Kotlin SDK / Android Sample: N/A
Flutter SDK / Flutter Sample: N/A
React Native SDK / React Native Sample: N/A
Web SDK / Web Sample: N/A


Labels

SDKs:

  • Swift SDK — Changes to Swift SDK (sdk/runanywhere-swift)
  • Kotlin SDK
  • Flutter SDK
  • React Native SDK
  • Web SDK
  • Commons

Sample Apps: None


Checklist

  • Code follows project style guidelines (SwiftLint + Periphery passed)
  • Self-review completed
  • Documentation updated (if needed) — N/A; comments added in code where relevant

  • Lint: Pre-commit (SwiftLint, Periphery) passed on all changed files.
  • Tests: New tests live in sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift; full run on macOS via swift test --filter RunAnywhereTests or the repo’s iOS SDK CI.

Summary

  • Tap is removed when engine.start() throws, so no resource leak.
  • Converter input block returns the buffer once then .endOfStream, so no duplication/corruption.
  • New RunAnywhereTests target with three tests covering tap-cleanup behavior and converter single-use.
  • convert(buffer:using:to:) is internal for testability; no other API changes.

Important

Fixes resource leak and audio corruption in AudioCaptureManager by ensuring tap removal on engine start failure and single-use converter input block.

  • Bug Fixes in AudioCaptureManager.swift:
    • Remove tap on inputNode if engine.start() throws to prevent resource leak.
    • Ensure AVAudioConverterInputBlock returns buffer once, then .endOfStream to prevent audio duplication/corruption.
  • Testing:
    • Add AudioCaptureManagerTests.swift with tests for tap cleanup and converter single-use behavior.
    • Introduce RunAnywhereTests test target in Package.swift.

This description was created by Ellipsis for 8080b68. You can customize this summary. It will automatically update as commits are pushed.

Greptile Summary

Fixed two critical bugs in AudioCaptureManager that caused resource leaks and audio corruption.

Key Changes:

  • Resource leak fix: Added do-catch block in startRecording() to remove tap on inputNode if engine.start() throws, preventing leaked taps that leave the node in a bad state
  • Audio corruption fix: Modified converter input block in convert() to use hasProvidedData flag, ensuring buffer is provided once then returns .endOfStream, preventing AVAudioConverter from duplicating audio data
  • Testability: Changed convert() visibility from private to internal to enable direct unit testing
  • Test coverage: Added RunAnywhereTests target with 3 comprehensive tests validating both fixes

Impact:

  • Prevents resource leaks when audio engine initialization fails (e.g., permission denied)
  • Eliminates audio corruption/duplication in sample rate conversion during real-time capture
  • Ensures clean state recovery after failures, allowing retry without restart

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • Both fixes are surgical, address real bugs with well-understood root causes, and include comprehensive unit tests. The changes follow Swift best practices and the CLAUDE.md style guidelines.
  • No files require special attention

Important Files Changed

Filename Overview
sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift Fixed critical resource leak by adding tap cleanup on engine start failure, and fixed audio corruption by ensuring converter input block provides buffer only once per call
sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift Added comprehensive unit tests covering tap cleanup on failure and converter single-use behavior with proper validation
Package.swift Added new test target RunAnywhereTests with correct dependencies and path configuration

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[startRecording called] --> B[Configure AVAudioSession]
    B --> C[Create AVAudioEngine]
    C --> D[Install tap on inputNode]
    D --> E{engine.start<br/>succeeds?}
    E -->|Yes| F[Set isRecording = true]
    E -->|No - Before fix| G[Tap leaked on node]
    E -->|No - After fix| H[Remove tap with<br/>inputNode.removeTap]
    H --> I[Throw error]
    G --> J[Node in bad state,<br/>retry fails]
    I --> K[Clean state,<br/>retry possible]
    F --> L[Tap callback receives buffer]
    L --> M[convert called]
    M --> N{Converter input<br/>block invoked}
    N -->|Before fix| O[Always return buffer<br/>with .haveData]
    N -->|After fix| P{hasProvidedData?}
    P -->|No| Q[Set flag, return<br/>buffer with .haveData]
    P -->|Yes| R[Return nil<br/>with .endOfStream]
    O --> S[Converter duplicates<br/>audio data]
    Q --> T[Single-pass conversion]
    R --> T
    S --> U[Corrupted audio output]
    T --> V[Correct audio output]
Loading

Last reviewed commit: 8080b68

(3/5) Reply to the agent's comments like "Can you suggest a fix for this @greptileai?" or ask follow-up questions!

Summary by CodeRabbit

  • Tests

    • New comprehensive test suite added for audio capture, validating engine startup error handling, audio conversion behavior, and consistency across multiple conversion operations.
  • Bug Fixes

    • Improved error handling in audio capture with enhanced resource cleanup to properly handle startup failures.

Copy link

@ellipsis-dev ellipsis-dev bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Looks good to me! 👍

Reviewed everything up to 8080b68 in 17 seconds. Click for details.
  • Reviewed 228 lines of code in 3 files
  • Skipped 0 files when reviewing.
  • Skipped posting 0 draft comments. View those below.
  • Modify your settings and rules to customize what types of comments Ellipsis leaves. And don't forget to react with 👍 or 👎 to teach Ellipsis.

Workflow ID: wflow_7La6ANGWDAMQImll

You can customize Ellipsis by changing your verbosity settings, reacting with 👍 or 👎, replying to comments, or adding code review rules.

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

The changes fix two resource management issues in AudioCaptureManager: (1) cleanup the audio tap on engine start failure to prevent resource leaks, and (2) enforce single-use buffer consumption in the audio converter input block to prevent audio corruption. A new test target and comprehensive test suite validate these fixes.

Changes

Cohort / File(s) Summary
Package Configuration
Package.swift
Adds new test target RunAnywhereTests with dependency on RunAnywhere module.
Audio Capture Service
sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift
Wraps engine start in do-catch to remove input tap on failure. Changes convert() visibility from private to internal. Introduces hasProvidedData flag in converter input block to enforce single-use buffer provisioning and return .endOfStream on subsequent calls.
Audio Capture Tests
sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift
New test suite validating engine start failure handling with cleanup verification, single-use audio conversion behavior, and idempotence across successive convert calls.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A tap was stuck when engines failed,
And buffers danced—the audio wailed,
But guard clauses now clean up the mess,
With flags that track our single-use finesse,
Resources leap! The sounds are blessed! 🎧✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the two main fixes: tap removal on engine start failure and converter single-use behavior fix.
Description check ✅ Passed The description comprehensively covers all required sections: detailed problem explanation, code examples, type of change, testing with platform specifics, labels, and checklist items completed.
Linked Issues check ✅ Passed The PR fully addresses both objectives from issue #198: removes tap on engine start failure [#198], implements single-use converter input block [#198], and adds comprehensive unit tests covering both fixes [#198].
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the two bugs in AudioCaptureManager and adding supporting tests; no unrelated modifications or scope creep detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift (1)

197-205: Consider .noDataNow instead of .endOfStream for streaming semantics.

.endOfStream signals that the entire audio stream has ended, causing the converter to flush its internal delay buffers (resampler tail). For a continuous tap that processes chunks in sequence, .noDataNow is the correct status — it tells the converter "nothing more for this invocation" without terminating the stream — preserving resampler state across chunk boundaries and avoiding periodic filter-startup artifacts.

♻️ Suggested change
         if hasProvidedData {
-            outStatus.pointee = .endOfStream
+            outStatus.pointee = .noDataNow
             return nil
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift`
around lines 197 - 205, The AVAudioConverter input block (inputBlock) currently
sets outStatus.pointee = .endOfStream after sending a single buffer which
prematurely terminates the converter and flushes resampler state; change that
status to .noDataNow so the converter preserves its internal state between
chunked invocations (keep the hasProvidedData/return buffer logic but replace
.endOfStream with .noDataNow to signal "no data for this invocation" rather than
end-of-stream).
sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift (1)

150-156: frameLength equality is structurally guaranteed, not behavior-derived.

Both first and second get their output frameCapacity from the same deterministic formula (buffer.frameLength × targetRate/sourceRate), making first?.frameLength == second?.frameLength true regardless of converter internal state. The assertion correctly validates that hasProvidedData is not shared across calls (the PR's actual fix), but it would pass even if the converter had shared mutable state that corrupted audio content. This is acceptable scope for the PR; if full idempotence is a future goal, asserting on sample values (not just frame counts) would be stronger.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift`
around lines 150 - 156, The test in AudioCaptureManagerTests currently only
compares frameLength for two convert(buffer:using:to:) calls which is
deterministic and doesn't detect shared mutable state; update the test to assert
that the actual audio samples (PCM/sample values) in the returned
AVAudioPCMBuffer contents are identical across the two calls (e.g., compare
audio data bytes or per-sample floats) and also include an assertion that the
buffers are distinct instances (not identical references) to ensure no shared
state in the converter/manager; target the convert(buffer:using:to:) call
results and the converter instance used in the test when making these
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift`:
- Around line 38-45: The assertions are racing with DispatchQueue.main.async
updates inside startRecording/stopRecording: after calling
manager.startRecording and manager.stopRecording you must yield to the main
actor/queue before checking manager.isRecording. Fix by awaiting the main actor
or otherwise yielding the main queue (for example use await MainActor.run { } or
an XCTestExpectation fulfilled from DispatchQueue.main.async) immediately before
each XCTAssertTrue(manager.isRecording) and XCTAssertFalse(manager.isRecording)
so the async updates from startRecording/stopRecording have run.

---

Nitpick comments:
In
`@sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift`:
- Around line 197-205: The AVAudioConverter input block (inputBlock) currently
sets outStatus.pointee = .endOfStream after sending a single buffer which
prematurely terminates the converter and flushes resampler state; change that
status to .noDataNow so the converter preserves its internal state between
chunked invocations (keep the hasProvidedData/return buffer logic but replace
.endOfStream with .noDataNow to signal "no data for this invocation" rather than
end-of-stream).

In `@sdk/runanywhere-swift/Tests/RunAnywhereTests/AudioCaptureManagerTests.swift`:
- Around line 150-156: The test in AudioCaptureManagerTests currently only
compares frameLength for two convert(buffer:using:to:) calls which is
deterministic and doesn't detect shared mutable state; update the test to assert
that the actual audio samples (PCM/sample values) in the returned
AVAudioPCMBuffer contents are identical across the two calls (e.g., compare
audio data bytes or per-sample floats) and also include an assertion that the
buffers are distinct instances (not identical references) to ensure no shared
state in the converter/manager; target the convert(buffer:using:to:) call
results and the converter instance used in the test when making these
assertions.

@Siddhesh2377 Siddhesh2377 merged commit e8f3f4f into RunanywhereAI:main Feb 19, 2026
3 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AudioCaptureManager: Resource leak on engine start failure + audio corruption in converter

2 participants