Skip to content

fix: Keep the input track out of the plugin's outputs when they are set - #66

Open
semnil wants to merge 1 commit into
masterfrom
fix/loopback-guard-race
Open

semnil wants to merge 1 commit into
masterfrom
fix/loopback-guard-race

Conversation

@semnil

@semnil semnil commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Problem

The plugin keeps its input mix track out of its own outputs from the audio thread: every callback reads the plugin's audio_mixers and, when the input track is among them, writes them back without it. That write is a read-modify-write. When the outputs change between the read and the write, for example when the user checks another track in Advanced Audio Properties, the write stores the value it read minus the input track, and the user's change is lost.

Until the audio thread's next callback removes it, the input track also stays stored: after the user checks the input track as an output, after a collection loads saved outputs that include it, or after the input moves to a track that is an output. A handler of the audio_mixers signal sees the input track in the meantime.

Fix

  • A handler on the plugin's own audio_mixers signal removes the input track from the value being set. libobs stores the value the signal returns (obs_source_set_audio_mixers in libobs/obs-source.c), so the input track is never stored, whoever sets the outputs.
  • When the input track changes, the new input track is removed from the outputs before the plugin connects to it.
  • The audio thread's check stays, for a set that another thread made with the old input track and that is stored after the input track has changed.
  • The plugin's own writes (that check, and the removal on an input change) do not write back the value they read. They mark the call with a THREAD_LOCAL value (libobs's util/threading.h), and the handler replaces the value with the outputs the latest audio_mixers signal handed to libobs, minus the track being removed. Signals run one at a time under the signal's lock, so a set handled before the plugin's write is kept.
  • A track number out of range in the settings leaves the outputs unchanged.

Impact

  • Output changes made in the meantime are no longer overwritten, neither by the audio thread nor by the removal on an input change.
  • Checking the input track as an output of the plugin is undone within the same set; a handler connected after the plugin's, as the Advanced Audio Properties checkboxes are, gets the stored value.
  • The README's statement that the input track cannot be an output still holds.
  • feat: Remove the plugin's output tracks from sources feeding its input #63 changes the same functions (mt2s_create, mt2s_update, mt2s_avoid_loopback) and adds its own own_mixers field, recorded in its own handler; whichever of the two merges second needs the two handlers merged into one.
  • libobs's setter still has steps outside any lock: between releasing the signal's lock and taking the global-callback lock, and between releasing that lock and storing the value (signal_handler_signal in libobs/callback/signal.c, obs_source_set_audio_mixers). No callback runs there, and any two writers share them (the frontend and obs-websocket, for example).

Verification

A test program built against this file and run on libobs 31.1.1 and 33.0.0 (the latter from OBS.app) drives the plugin with libobs's real audio thread. It wraps the plugin's own setter calls to count the ones made off the main thread, and can hold the first of them after the plugin has read the value it writes from.

Case master (aff14c8) This branch
The audio thread is held before its write while the user adds track 5 (outputs 33 → 49, input track 1) audio_mixers ends at 32 (expected 48); 1 audio-thread write 48; three callbacks pass without an audio-thread write
The user checks the input track (32 → 33) 33 is stored, and a handler connected later sees 33 32
The input moves to track 4 while the outputs are tracks 4 and 6 (40) 40 when the plugin connects to track 4 and when the update returns 32
A collection loads saved outputs 63 with input track 3 63 59
Track number 99 or -1 in the settings (outputs 40) 40 40
Another thread's set, made with the old input track, is stored after the input moves to track 4 32; the audio thread writes once 32; the audio thread writes once
The same, with the audio thread's check held before its write while the user sets tracks 5 and 6 (48) 32 48
The input moves to track 4 on another thread, whose own write is held after its read while the user adds track 5 (40 → 56) 56 (31.1.1) and 32 (33.0.0); expected 48 48
2000 random output changes while the audio thread runs 60 (31.1.1) and 69 (33.0.0) audio-thread writes; 0 stale outputs 0 writes and 0 stale outputs on both
  • A second, separately written test program passes all its checks on libobs 31.1.1 and 33.0.0, and again in a build with -fsanitize=undefined on 31.1.1. Its checks include the held audio-thread check while the user sets tracks 5 and 6, every output mask on every input track (stored value and the value a later handler sees), every input-track change with outputs 63 (outputs when the plugin connects and afterwards), loading and saving with every input track, out-of-range input tracks, and the waveform, frame count and timestamp the plugin passes on.
  • Guards: each part is checked by a mutation run on libobs 31.1.1. Not connecting the handler, leaving the value unchanged in the handler, dropping the removal in the update, removing after connecting, dropping the range check, dropping the audio thread's check, letting the plugin's own write keep the value it read, making the mark visible to every thread, not recording the latest outputs, or keeping the removed track in the plugin's own write each makes at least one case above fail. Leaving the handler connected at destroy is not observable: libobs emits no audio_mixers signal for a source after its destroy callback and frees the source's signal handler with it.
  • Compile: src/plugin-main.c compiles for arm64 and x86_64 under -Werror with the project's warning flags and no warnings; on master the same flags report a -Wshorten-64-to-32 warning in mt2s_avoid_loopback, which this change removes. clang-format 23 reports no changes for this file and for master's, which passes CI's clang-format 19.
  • CI on this head: the macOS, Ubuntu and Windows builds (including THREAD_LOCAL on MSVC) and the clang-format 19 check pass.
  • Not verified: behavior in the OBS GUI itself.

The audio thread checked the plugin's own output mixers on every
callback and, when the input track was among them, wrote them back
without it. When the outputs changed between that read and the write,
the write stored the old value and the change was lost.

- A handler on the plugin's own audio_mixers signal removes the input
  track from the value being set, so libobs never stores it, whoever
  sets the outputs.
- A new input track is removed from the outputs before the plugin
  connects to it.
- The audio thread's check stays, for a set that another thread made
  with the old input track and that is stored after the input track has
  changed.
- The plugin's own writes (that check, and the removal on an input
  change) take the track out of the outputs the latest audio_mixers
  signal handed to libobs rather than out of the value they read, so a
  set made in between is kept.
- A track number out of range leaves the outputs unchanged.
@semnil
semnil force-pushed the fix/loopback-guard-race branch from b709530 to d6ccb3b Compare October 25, 2026 05:29
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.

1 participant