Skip to content

0.16 review: VST3 preset selector, state-restore race, Linux idle, AUv2 restart silence - #546

Merged
defiantnerd merged 3 commits into
nextfrom
0.16-fix-vst3-linux
Sep 14, 2026
Merged

defiantnerd merged 3 commits into
nextfrom
0.16-fix-vst3-linux

Conversation

@defiantnerd

@defiantnerd defiantnerd commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

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_INFO loop skipped only isMidi, but the selector is not a CLAP parameter either: createPresetSelector leaves param_index_for_clap_get_info at 0, so get_info returned 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 _presetIndexInEffect still -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 (-1 none, -2 adopt, >= 0 request): 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, and onIdle() drains only values >= 0 so it can no longer disarm the adopt on its way past — which a plain exchange(-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: _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 observing the stale non-null value and never resume ticking; and idleSourceChanged() notified without holding _standInLock, so a notify could land after run() 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_timer can reach it from inside on_main_thread with _mainThreadLock already held, which the documented helper-then-plug-object lock order does not permit, and that transition is one register_timer cannot produce.

AUV2-1 — a restart played the last block on repeat instead of silence. Render() returned noErr without touching the output when _initialized was false. That is not silence: AUBase only refuses with kAudioUnitErr_Uninitialized while the AU is uninitialized, and the AU stays initialized right through a plugin-requested restart, so DoRenderBus copies 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 raises kAudioUnitRenderAction_OutputIsSilence; the zeroing is load-bearing, since AUBase never reads that flag.

Verification

  • Windows: VST3 target builds clean (MSVC, C++17).
  • Linux: VST3 target builds clean (g++ 11.4) — this is what actually compiles linux.cpp and every #if LIN block, so LIN-1 is compiler-verified rather than assumed.
  • clang-format clean.
  • AUV2-1 was not compiled — macOS-only, developed on Windows. It deliberately uses only calls this file and the process adapter already make (Outputs(), Output(i), PrepareBuffer(), memset) rather than AUBufferList::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.cpp already cites in its header comment. No change.

Reported, not changed

syncParameterValuesFromClap() and getParamValueByString() make the same isMidi-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.

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.

@baconpaul baconpaul left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 thread src/wrapasauv2.cpp Outdated
Comment-only change: the explanatory blocks are cut to the non-obvious why.

@baconpaul baconpaul left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The runloop changes are correct. The preset changes appear correct but its a bit hard to tell without diving into that code in detail.

@defiantnerd
defiantnerd merged commit b4835df into next Sep 14, 2026
28 checks passed
@defiantnerd
defiantnerd deleted the 0.16-fix-vst3-linux branch September 14, 2026 18:25
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.

2 participants