0.16 review: VST3 preset selector, state-restore race, Linux idle, AUv2 restart silence - #546
Merged
Merged
Conversation
Render() gated its whole body on _initialized and otherwise returned noErr without touching the output. That is not silence. AUBase only refuses a call with kAudioUnitErr_Uninitialized while the *AU* is uninitialized, and the AU stays initialized right through a plugin requested restart, so the call reaches Render(); once it returns noErr, DoRenderBus copies the output element's cache into the host's buffer regardless. The cache still holds the last block the plugin rendered, so the host got that block again, once per cycle, for as long as onIdle() took to cycle the CLAP - tens of milliseconds for a plugin that reallocates its DSP, and an audible buzz under a playing Logic transport where a latency or oversampling change should have been a dropout. Zero every output element's buffer on that path and raise kAudioUnitRenderAction_OutputIsSilence. The zeroing is the part that matters: AUBase never reads the flag, so a host that ignores it would still play the stale cache. All elements are zeroed, not just the bus being rendered, because RenderBus answers busses 1..n from the cache without calling Render() at all. The comments at the restart site claimed these renders behave "exactly as they do before the AU is initialized". They do not, for the reason above; corrected here and at the two other places that repeated it. Not compiled: macOS-only source, developed on Windows. Uses only calls this file and the process adapter already make - Outputs(), Output(i), PrepareBuffer() and memset - rather than AUBufferList::ZeroBuffer, which has no other use in this wrapper and could not be checked here.
Three fixes in wrapasvst3 and the Linux helper. Committed together because VST3-3 and LIN-1 both change state declared in wrapasvst3.h and neither compiles without the other's half of that header. VST3-2. The CLAP_PARAM_RESCAN_INFO loop skipped only isMidi, and the preset selector is not a CLAP parameter either: createPresetSelector leaves param_index_for_clap_get_info at 0, so get_info handed back CLAP parameter 0 and renamed the host's program-change control after it. A plugin whose macros rename themselves and rescan on every change turned the Cubase program control into "Macro 1". Skip isPreset as well. VST3-3. setState() dropped a queued request and armed the adopt in two separate atomics, and process() does not take _mainThreadLock, so a request could be decided against the unarmed flag and then stored into the just-cleared slot. onIdle() found a request, found _presetIndexInEffect still -1 - a state that never came from the preset list names no preset - and loaded the selector's saved preset over the project that had just been restored. Cubase sends the selector in every process block, so the race was armed on every project load there. Both facts now live in one word: setState() arms and drops in a single store, the audio thread decides and publishes in a single compare-exchange that fails and re-decides if the state landed in between, and onIdle() drains only values >= 0 so it can no longer disarm the adopt on its way past. LIN-1. The stand-in idle thread could park forever with the editor closed, which is the one state it exists to cover. _iRunLoop was a plain pointer read on the helper thread and written on the host's main thread, so on a weakly ordered machine the helper could keep seeing the old non-null value and never resume ticking; and idleSourceChanged() notified without holding _standInLock, so a notify could land in the window after run() evaluated its predicate and before it registered as a waiter, where it is simply lost - and that branch waits unbounded. Made _iRunLoop atomic and moved the notify inside the lock. attachTimers() now notifies only on a null -> run-loop transition: register_timer can reach it from inside on_main_thread with _mainThreadLock already held, which the documented helper-then-plug-object lock order does not allow, and that transition is one register_timer cannot produce. Verified: VST3 target builds clean on Windows (MSVC, C++17) and on Linux (g++ 11.4), the latter being what actually compiles linux.cpp and every #if LIN block. clang-format clean. Reported, not changed: syncParameterValuesFromClap() and getParamValueByString() make the same isMidi-only assumption and call CLAP with the selector's invented id. Both are harmless today because the plugin rejects the unknown id, but they are the same latent class as VST3-2.
This was referenced Sep 13, 2026
baconpaul
requested changes
Sep 14, 2026
baconpaul
left a comment
Collaborator
There was a problem hiding this comment.
This code is all fine and we can merge it. But the generated textual comments are kinda out of control. I wonder if we want to merge all these then do a final path to make all the comments, like, 70% shorter across the entire 16->17 diff.
Comment-only change: the explanatory blocks are cut to the non-obvious why.
baconpaul
approved these changes
Sep 14, 2026
baconpaul
left a comment
Collaborator
There was a problem hiding this comment.
The runloop changes are correct. The preset changes appear correct but its a bit hard to tell without diving into that code in detail.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second batch from the 0.16 release review of
main...next. Independent of #545.What is here
VST3-2 — the preset selector got renamed to a CLAP parameter. The
CLAP_PARAM_RESCAN_INFOloop skipped onlyisMidi, but the selector is not a CLAP parameter either:createPresetSelectorleavesparam_index_for_clap_get_infoat 0, soget_inforeturned CLAP parameter 0 and its name landed on the host's program-change control. A plugin whose macros rename themselves and rescan on each change turned the Cubase program control into "Macro 1". One added condition.VST3-3 — a state-restore race could silently lose a project's edits.
setState()dropped a queued preset request and armed the "adopt next value" flag as two separate atomics.process()does not take_mainThreadLock, so a request could be decided against the unarmed flag and then stored into the just-cleared slot;onIdle()then found a request, found_presetIndexInEffectstill-1(a state that never came from the preset list names no preset), and loaded the selector's saved preset over the freshly restored project. Cubase sends the selector in every process block, so on that host the race was armed on every project load.Both facts now live in one word (
-1none,-2adopt,>= 0request):setState()arms and drops in one store, the audio thread decides and publishes in one compare-exchange that fails and re-decides if the state landed in between, andonIdle()drains only values>= 0so it can no longer disarm the adopt on its way past — which a plainexchange(-1)did.LIN-1 — the Linux stand-in idle thread could park forever with the editor closed, which is the one state it exists to cover. Two independent holes:
_iRunLoopwas a plain pointer read on the helper thread and written on the host's main thread, so on a weakly ordered machine the helper could keep observing the stale non-null value and never resume ticking; andidleSourceChanged()notified without holding_standInLock, so a notify could land afterrun()evaluated its predicate and before it registered as a waiter — lost, and that branch waits unbounded. Both closed.attachTimers()now notifies only on a null → run-loop transition.register_timercan reach it from insideon_main_threadwith_mainThreadLockalready held, which the documented helper-then-plug-object lock order does not permit, and that transition is oneregister_timercannot produce.AUV2-1 — a restart played the last block on repeat instead of silence.
Render()returnednoErrwithout touching the output when_initializedwas false. That is not silence: AUBase only refuses withkAudioUnitErr_Uninitializedwhile the AU is uninitialized, and the AU stays initialized right through a plugin-requested restart, soDoRenderBuscopies the output element's cache — the last rendered block — into the host's buffer each cycle. Tens of milliseconds of buzz under a playing Logic transport where a latency change should have been a dropout. Now zeroes every output element and raiseskAudioUnitRenderAction_OutputIsSilence; the zeroing is load-bearing, since AUBase never reads that flag.Verification
linux.cppand every#if LINblock, so LIN-1 is compiler-verified rather than assumed.clang-formatclean.Outputs(),Output(i),PrepareBuffer(),memset) rather thanAUBufferList::ZeroBuffer, which has no other use in this wrapper and could not be checked here.LIN-2 closed, not fixed
The review also flagged that host callbacks now come off the wrapper's own thread on Linux when no editor is open. Confirmed as working-as-intended: it is the only way to do it, and it is what JUCE's Linux client does — a precedent
linux.cppalready cites in its header comment. No change.Reported, not changed
syncParameterValuesFromClap()andgetParamValueByString()make the sameisMidi-only assumption as VST3-2 and call CLAP with the selector's invented id. Harmless today because the plugin rejects the unknown id, but the same latent class.