Skip to content

0.16 review: size the preset selector once and grow it in place (PD-2, PD-4, PD-6, VST3-1) - #548

Merged
defiantnerd merged 5 commits into
nextfrom
0.16-fix-preset-knot
Sep 14, 2026
Merged

defiantnerd merged 5 commits into
nextfrom
0.16-fix-preset-knot

Conversation

@defiantnerd

@defiantnerd defiantnerd commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Fourth and last code batch from the 0.16 release review of main...next.

Based on #546, not on next — VST3-1's fix lands in onIdle, which #546 also changes. GitHub will retarget this to next when #546 merges. Review #546 first.

Why these four are one change

They interlock, and fixing one on its own makes another worse.

VST3-1's teardown branch requires _presetListChanged set while _presetParamId is still kNoParamId. The listener that sets that flag was only registered after the id was assigned — so PD-2, the early return that skipped registering it, was the only reason VST3-1 was not firing. Moving that registration up, which is exactly what PD-2 asks for in isolation, would have made a use-after-free live and frequent.

That also corrects the review: VST3-1's stated failure scenario could not actually happen, because seeing zero presets returned early without registering any listener at all.

So the teardown is removed rather than worked around.

What changed

PD-2. setupPresets() no longer returns before registering the completion listener. The selector is created whatever the crawl has found so far — hidden, stepCount 0, when that is nothing — and the listener registered straight after, so a registered listener always has a selector to grow. Previously, a plugin whose folder crawl outlived initialize() got no program list for the life of that instance. (AUv2 already did this correctly.)

PD-4. stepCount was frozen at creation while getProgramListInfo reported the live index size. A host listed 2100 programs against a 99-step parameter; picking #1500 clamped to 1.0 and loaded preset 99 — silently. There is now one number, Vst3Parameter::presetCount(), read by getProgramListInfo, getProgramName, the onIdle clamp and preset_loaded. The live size is never published, and only a complete crawl is published at all.

PD-6. waitUntilComplete(1000) is gone from initialize().

VST3-1. onIdle no longer calls setupParameters(), so parameters.removeAll() can no longer destroy Parameter objects while the process adapter dereferences them on the audio thread. The completion path writes stepCount, min/max_value and the hidden flag on the existing parameter under the same spin lock process() takes, then announces kParamTitlesChanged — which ivsteditcontroller.h defines as covering "titles, default values, stepCount or flags" — alongside notifyProgramListChange. param_rescan already rewrites getInfo().title in place and announces it the same way. setupParameters() is now reachable only from initialize() and param_rescan(RESCAN_ALL), which CLAP forbids while active.

kReloadComponent was considered and rejected: the SDK's own wrapper says // kReloadComponent is Not supported (basewrapper.cpp:1418).

Two things found while implementing

A no-op guard. The index is shared per module, so from the second instance onward the crawl is already complete at initialize() and the listener fires straight back with nothing to say. Without the guard every instantiation would restartComponent for nothing.

Selector catch-up. A preset the plugin loads from its own UI during the crawl has no slot yet, so the selector stays at 0 — and on a host that streams the selector every block, that 0 becomes a load request the moment the list grows, putting preset 0 over the user's choice. This is a bug the fix would have introduced. After the grow, onIdle moves the selector to _presetIndexInEffect when it is now in range.

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.
Comment-only change: the explanatory blocks are cut to the non-obvious why.
…lace

Four findings with one root cause, and they interlocked: the obvious fix
for one armed another. VST3-1's teardown branch needs _presetListChanged
set while _presetParamId is still kNoParamId, and the listener that sets
that flag was only registered after the id was assigned - so PD-2, the
early return that skipped registering it, was the only reason VST3-1 was
not firing. Moving that registration up, which is what PD-2 asks for on
its own, would have made a use-after-free live and frequent.

So the teardown goes instead of being worked around.

PD-2. setupPresets() no longer returns before registering the completion
listener. The selector is created whatever the crawl has found so far -
hidden, stepCount 0, when that is nothing - and the listener is registered
straight after, so a registered listener always has a selector to grow. A
plugin whose folder crawl outlived initialize() used to get no program
list for the life of that instance.

PD-4. The selector's stepCount was frozen at creation while
getProgramListInfo reported the live index size, so a host listed 2100
programs against a 99-step parameter and picking #1500 clamped to 1.0 and
loaded preset 99 - silently, and not the preset chosen. One number now:
Vst3Parameter::presetCount(), stepCount+1 or 0 while hidden, read by
getProgramListInfo, getProgramName, the onIdle clamp and preset_loaded.
The live size is never published, and only a complete crawl is published
at all.

PD-6. waitUntilComplete(1000) is gone from initialize().

VST3-1. onIdle no longer calls setupParameters(), so parameters.removeAll()
can no longer run while the component is active and destroy Parameter
objects the process adapter is dereferencing on the audio thread. The
completion path instead writes stepCount, min/max_value and the hidden
flag on the existing parameter under the same spin lock process() takes,
then announces kParamTitlesChanged - which the SDK defines as covering
"titles, default values, stepCount or flags" - alongside
notifyProgramListChange. param_rescan already rewrites getInfo().title in
place and announces it the same way. setupParameters() is now reached only
from initialize() and from param_rescan(RESCAN_ALL), which CLAP forbids
while active.

Two things the design did not anticipate, both found while implementing:

A no-op guard. The index is shared per module, so from the second instance
on the crawl is already complete at initialize() and the listener fires
straight back with nothing to say. Restarting the component for that on
every instantiation is not free on hosts that rebuild parameter views.

Selector catch-up. A preset the plugin loads from its own UI while the
crawl is still running has no slot yet, so the selector stays at 0. On a
host that streams the selector every block that 0 becomes a load request
the moment the list grows, putting preset 0 over the user's choice. After
the grow, onIdle moves the selector to _presetIndexInEffect when it is
now in range. The bracketed edit tail was factored out of preset_loaded
and shared.

asVst3Value() returns 0 for a zero-width range rather than dividing. That
also stops a CLAP parameter declaring min == max from handing the host a
NaN.

Verified: builds clean (MSVC, C++17), clang-format clean. Not verified in
a host, and it needs to be - see the PR for what to check.
Keep the host quirks and the audio-thread hazards, drop the narration.
Base automatically changed from 0.16-fix-vst3-linux to next September 14, 2026 18:25
@defiantnerd
defiantnerd merged commit 9617822 into next Sep 14, 2026
2 checks passed
@defiantnerd
defiantnerd deleted the 0.16-fix-preset-knot branch September 14, 2026 18:27
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