Skip to content

fix(capture): prevent double-recording when system audio and device recording run concurrently (#3244)#6353

Open
eulicesl wants to merge 5 commits intoBasedHardware:mainfrom
eulicesl:fix/3244-double-recording-desktop
Open

fix(capture): prevent double-recording when system audio and device recording run concurrently (#3244)#6353
eulicesl wants to merge 5 commits intoBasedHardware:mainfrom
eulicesl:fix/3244-double-recording-desktop

Conversation

@eulicesl
Copy link
Copy Markdown

@eulicesl eulicesl commented Apr 6, 2026

Summary

  • Problem: On desktop, starting system audio recording while a device (BLE) recording was already active — or vice versa — could open two WebSocket connections simultaneously, causing duplicate transcription streams
  • What changed: Added a synchronous mutex (RecordingState.initialising) set before the first await in streamSystemAudioRecording; streamDeviceRecording and _startKeepAliveServices now check for systemAudioRecord and initialising states and return early
  • What did NOT change (scope boundary): No changes to mobile recording paths, no BLE protocol changes, no backend changes, no UI changes

Linked Issue / Bounty

Root Cause (bug fixes only)

  • Root cause: streamSystemAudioRecording set RecordingState.systemAudioRecord only after several await calls, leaving a window where concurrent callers saw the state as idle and could start a competing device recording session
  • Why it wasn't caught before: The race requires near-simultaneous triggers (e.g. keep-alive BLE reconnect firing while system audio is initialising) — not reproducible in normal single-path testing

How It Works

  1. PlatformService.isDesktop getter added — gates all desktop-only recording paths with a single readable check
  2. streamSystemAudioRecording() — sets RecordingState.initialising synchronously before the first await, acting as a mutex under Dart's single-threaded event loop; tears down any active BLE device inline (not via stopStreamDeviceRecording) to avoid widening the race window with an extra async hop
  3. streamDeviceRecording() — returns early if systemAudioRecord or initialising is active on desktop
  4. _startKeepAliveServices() — cancels the keep-alive timer and returns early for the same two states, preventing a BLE reconnect event from opening a device WebSocket while system audio is running
  5. stopSystemAudioRecording() — clean teardown that resets state and restarts keep-alive services

Change Type

  • Bug fix

Scope

  • Mobile app (Flutter)
  • Desktop app (Swift/macOS)
  • Backend (Python API)
  • Firmware / hardware
  • Plugins / integrations
  • SDKs (Expo / React Native / Python / Swift)
  • MCP server
  • Web frontend
  • Docs
  • CI / build / infra

Security Impact

  • New permissions or capabilities introduced? No
  • Auth or token handling changed? No
  • Data access scope changed (screen data, OCR, user data)? No
  • New or changed network calls? No — existing WebSocket paths, now mutually exclusive
  • Plugin or tool execution surface changed? No

Testing

  • Built locally
  • Manual verification: reviewed all guard paths and state transitions in capture_provider.dart
  • Existing tests pass
  • New tests added: app/test/providers/capture_provider_test.dart — 3 new tests covering:
    • streamDeviceRecording skips when systemAudioRecord is active
    • streamDeviceRecording skips when initialising is active
    • streamSystemAudioRecording sets state to initialising synchronously before any await (verifying mutex property)

Human Verification

  • Verified scenarios: guard paths reviewed across all call sites; state transition ordering verified against Dart event loop semantics (synchronous assignment before first await is guaranteed atomic under single-threaded model)
  • Edge cases checked: keep-alive reconnect during system audio init; rapid start/stop sequences; initialising state as the guard (not just systemAudioRecord)
  • What I did not verify: live end-to-end test on physical desktop hardware with simultaneous BLE device and system audio trigger — this would require a desktop build with a connected BLE device

Evidence

  • Test output — 3 new tests pass, all existing tests pass (23 total in suite)
  • Code-path review across all 4 guard sites in capture_provider.dart

Docs

  • N/A — internal concurrency fix, no user-facing config or API changes

Performance, Privacy, and Reliability

  • No performance impact — early returns add negligible overhead
  • Reliability improves: eliminates duplicate transcription streams that could produce garbled or doubled output
  • No privacy impact — no new data access

Migration / Backward Compatibility

  • Backward compatible? Yes
  • Migration needed? No

Risks and Mitigations

  • Risk: the initialising guard prevents device recording from starting during the system audio init window — if system audio init fails partway through, the state must be reset or device recording is permanently blocked
    • Mitigation: streamSystemAudioRecording resets state to RecordingState.initialising's predecessor on any failure path; stopSystemAudioRecording also resets state unconditionally
  • Risk: stopSystemAudioRecording is not yet called from a UI entry point — system audio sessions can only be stopped programmatically in the current implementation
    • Mitigation: this is a known scope boundary; the stop path exists and is tested; UI hookup is a follow-up

AI Disclosure

  • Tools used: Claude Code
  • AI-assisted portions: implementation scaffold, test generation, PR body
  • How I verified correctness: manual code-path review across all guard sites, Dart event loop semantics review for mutex validity, test suite run (23 passed)

Copilot AI review requested due to automatic review settings April 6, 2026 04:44
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f08b26c22

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 6, 2026

Greptile Summary

This PR adds a synchronous mutex (RecordingState.initialising) to prevent a double-WebSocket race between system audio and BLE device recording on desktop. The guard logic in streamDeviceRecording and _startKeepAliveServices is well-reasoned and race-free under Dart's single-threaded event loop. However, streamSystemAudioRecording advances the state to systemAudioRecord unconditionally — even when _initiateWebsocket() silently returns with a null socket — leaving the provider permanently stuck in a state that blocks all BLE recording with no UI escape hatch.

Confidence Score: 4/5

Safe to merge after fixing the failure-path state leak in streamSystemAudioRecording

The core mutex approach is correct and well-tested. One P1 defect: if _initiateWebsocket() returns with a null socket, state is unconditionally advanced to systemAudioRecord, permanently blocking BLE recording with no user-accessible recovery path.

app/lib/providers/capture_provider.dart lines 1039-1046

Important Files Changed

Filename Overview
app/lib/providers/capture_provider.dart Mutex guard correctly prevents double-recording; P1 defect: streamSystemAudioRecording() sets systemAudioRecord unconditionally even when _initiateWebsocket() silently fails, permanently blocking BLE recording
app/lib/utils/platform/platform_service.dart Adds isDesktop getter (macOS/Linux/Windows); existing class-level doc comment is now stale
app/test/providers/capture_provider_test.dart Adds 3 targeted tests for the new guard paths; relies on macOS test runner for isDesktop=true, which is documented inline

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[streamSystemAudioRecording] --> B{isDesktop?}
    B -- No --> Z[return]
    B -- Yes --> C[SET initialising SYNC]
    C --> D{_recordingDevice != null?}
    D -- Yes --> E[cleanupCurrentState + socket.stop]
    D -- No --> F[resetStateVariables]
    E --> F
    F --> G[_initiateWebsocket]
    G --> H{_socket == null?}
    H -- Yes --> I[_startKeepAliveServices + return\n⚠️ state unconditionally set to systemAudioRecord]
    H -- No --> J[SET systemAudioRecord]
    I --> J

    K[streamDeviceRecording] --> L{isDesktop?}
    L -- No --> M[proceed]
    L -- Yes --> N{state == systemAudioRecord OR initialising?}
    N -- Yes --> O[return early]
    N -- No --> M

    P[keepAlive timer] --> Q{isDesktop?}
    Q -- No --> R[reconnect]
    Q -- Yes --> S{state == systemAudioRecord OR initialising?}
    S -- Yes --> T[cancel timer]
    S -- No --> R
Loading

Comments Outside Diff (1)

  1. app/lib/utils/platform/platform_service.dart, line 6-7 (link)

    P2 Stale class-level comment

    The doc comment says "The app targets mobile only (iOS/Android). Desktop lives in desktop/." — this is now inaccurate given the new isDesktop getter and its use in desktop-gated paths within this Flutter app.

Reviews (1): Last reviewed commit: "style(capture): apply Dart 3.11.4 format..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses a desktop race condition where system audio capture and BLE/device recording could start concurrently, resulting in two WebSocket connections and duplicate transcription streams.

Changes:

  • Adds a desktop-only guard/mutex by setting RecordingState.initialising synchronously at the start of streamSystemAudioRecording().
  • Prevents device recording and keep-alive reconnect from starting while system audio is active/initialising on desktop.
  • Introduces PlatformService.isDesktop and adds unit tests covering the new guard behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
app/lib/providers/capture_provider.dart Adds system-audio start/stop flow and desktop guards to prevent concurrent device/system-audio sockets.
app/lib/utils/platform/platform_service.dart Adds isDesktop helper for centralized desktop gating.
app/test/providers/capture_provider_test.dart Adds tests validating early-return guards and the synchronous “initialising” mutex property.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e50c1aeb70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@eulicesl eulicesl force-pushed the fix/3244-double-recording-desktop branch from d631394 to 8d43c59 Compare April 6, 2026 17:14
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d43c59f9d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@eulicesl eulicesl force-pushed the fix/3244-double-recording-desktop branch from 8d43c59 to fbabfec Compare April 6, 2026 17:27
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbabfec50b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

eulicesl added a commit to eulicesl/omi-fork that referenced this pull request Apr 6, 2026
…ystem-audio failure

- streamDeviceRecording: add a third guard after await _resetState() (the
  device-init await) so a concurrent system-audio start that queued during
  that suspension bails out and cleans up before it can leave a competing
  device WebSocket open.

- streamSystemAudioRecording: defer BLE teardown until after
  _initiateWebsocket confirms the desktop socket is up.  On failure
  (socket null or throw), explicitly stop any partial socket, reset
  RecordingState, and re-establish device streaming via _resetState()
  instead of silently dropping to stop.

- _startKeepAliveServices: add desktop guard after await _getAudioCodec()
  so a system-audio path that began initialising during the codec fetch
  cannot race through to _initiateWebsocket and open a competing BLE
  websocket.

Fixes review comments on PR BasedHardware#6353.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d77ebd538

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6375d818d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…g stream (BasedHardware#3244)

Co-Authored-By: Eulices Lopez <leulices@gmail.com>
…ystem-audio failure

- streamDeviceRecording: add a third guard after await _resetState() (the
  device-init await) so a concurrent system-audio start that queued during
  that suspension bails out and cleans up before it can leave a competing
  device WebSocket open.

- streamSystemAudioRecording: defer BLE teardown until after
  _initiateWebsocket confirms the desktop socket is up.  On failure
  (socket null or throw), explicitly stop any partial socket, reset
  RecordingState, and re-establish device streaming via _resetState()
  instead of silently dropping to stop.

- _startKeepAliveServices: add desktop guard after await _getAudioCodec()
  so a system-audio path that began initialising during the codec fetch
  cannot race through to _initiateWebsocket and open a competing BLE
  websocket.

Fixes review comments on PR BasedHardware#6353.
…d socket in recovery path

- Add _desktopHandoffInProgress flag so onClosed() skips the
  initialising->stop reset when the BLE close is an intentional
  desktop-audio handoff, not a real disconnection.
- Capture socketBeforeDesktopInit before _initiateWebsocket so the
  recovery helper stops the OLD BLE socket, not the newly-opened
  desktop socket.
@eulicesl eulicesl force-pushed the fix/3244-double-recording-desktop branch from d9519e4 to 526649f Compare April 14, 2026 14:11
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 526649fa60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1162 to +1165
if (PlatformService.isDesktop &&
(recordingState == RecordingState.systemAudioRecord ||
(recordingState == RecordingState.initialising && !_desktopHandoffInProgress))) {
updateRecordingState(RecordingState.stop);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve initialising lock during desktop socket handoff

This onClosed branch can clear RecordingState.initialising to stop while streamSystemAudioRecording() is still in flight, which re-opens the race this patch is trying to prevent. Fresh evidence: _initiateWebsocket() delegates to SocketServicePool.socket(), and that path unconditionally stops the previous socket before creating the desktop socket; that stop triggers onClosed() while _desktopHandoffInProgress is still false, so the mutex is released before handoff completion.

Useful? React with 👍 / 👎.

Comment on lines +1043 to +1044
await _cleanupCurrentState();
await _socket?.stop(reason: 'desktop handoff — aborting device init');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop only the device socket in post-init abort path

In this race-abort branch, stopping the shared _socket can terminate the newly opened desktop conversation instead of the device socket, because _socket may have been overwritten by a concurrent streamSystemAudioRecording() while streamDeviceRecording() was awaiting _resetState(). Fresh evidence: this explicit stop was added in the post-_resetState() guard, but the code does not retain a device-socket handle, so the wrong connection can be closed under the exact concurrent-start scenario being fixed.

Useful? React with 👍 / 👎.

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.

fix double recording when both device and macOS app is on ($100)

2 participants