Skip to content

VST3: give the Linux wrapper a main thread when no editor is open - #529

Merged
baconpaul merged 1 commit into
free-audio:nextfrom
baconpaul:linux-idle-thread
Sep 3, 2026
Merged

baconpaul merged 1 commit into
free-audio:nextfrom
baconpaul:linux-idle-thread

Conversation

@baconpaul

Copy link
Copy Markdown
Collaborator

On Windows onIdle() is driven by a WM_TIMER on a message window and on macOS by a CFRunLoopTimer on the main run loop. Both already belong to the host's main thread, so they run whether or not an editor is open.

Linux has no equivalent. The only main-thread callback a VST3 host offers is Steinberg::Linux::IRunLoop, and it is reached through IPlugFrame, which means it exists only while an editor is open. LinuxHelper had the shape of the other two -- attach(), detach(), executeDefered() -- but nothing ever called executeDefered(), so with the plug-in window shut onIdle() did not run at all. Everything it drives was dead:

  • clap_host::request_callback never reached clap_plugin::on_main_thread
  • the queue of parameter edits bound for the host was never drained, so beginEdit/performEdit/endEdit never reached the host
  • a requested parameter flush never happened
  • restartComponent(kIoChanged | kLatencyChanged) was never sent, so latency changes went unannounced
  • clap timers never fired

Measured against Reaper with the editor closed, 14 of 14 request_callback calls went unanswered; under Steinberg's own validator, 372 of 372.

This adds the missing timer as a thread of the helper's own, at 100Hz. It stands in for the main thread the host does not provide, and covers that gap only: a plug object that has been handed a run loop answers hasOwnIdleSource() and is skipped, and when every attached object has one the thread parks on a condition variable rather than spinning. os::idleSourceChanged() wakes it when an editor closes. That is the same arrangement JUCE's Linux plug-in client uses, which runs a MessageThread of its own and stops it when the host's message thread appears.

Because that thread is not the host's main thread, two things follow.

Clap::Plugin::AlwaysMainThread() is raised around the idle so that clap_host_thread_check answers truthfully for its duration, and a recursive _mainThreadLock is taken there and in the VST3 entry points a host calls on the main thread, so that the two cannot be inside the plug-in at once. onIdle() takes it with try_lock and skips the tick if the host is already inside: the helper holds a module-wide lock while it idles, and blocking would stall every other instance behind it.

One fix this depends on: WrappedView::setFrame() now reports the run loop going away as well as arriving. It only ever set _runLoop, so a host that hands back a null frame while keeping the view -- across an editor being closed and reopened -- left the wrapper believing it still had a main thread, and the stand-in thread parked forever. _onRunLoopAvailable is renamed _onRunLoopChanged to match.

Tested on aarch64 Linux: Steinberg's validator passes 47/47; a purpose- built host that never creates a view serves every request_callback where it previously served none; opening and closing an editor mid-run hands the idle over in both directions without dropping one.

Assisted-by: Claude Opus 5 (1M context) noreply@anthropic.com

On Windows onIdle() is driven by a WM_TIMER on a message window and on
macOS by a CFRunLoopTimer on the main run loop. Both already belong to the
host's main thread, so they run whether or not an editor is open.

Linux has no equivalent. The only main-thread callback a VST3 host offers
is Steinberg::Linux::IRunLoop, and it is reached through IPlugFrame, which
means it exists only while an editor is open. LinuxHelper had the shape of
the other two -- attach(), detach(), executeDefered() -- but nothing ever
called executeDefered(), so with the plug-in window shut onIdle() did not
run at all. Everything it drives was dead:

  - clap_host::request_callback never reached clap_plugin::on_main_thread
  - the queue of parameter edits bound for the host was never drained, so
    beginEdit/performEdit/endEdit never reached the host
  - a requested parameter flush never happened
  - restartComponent(kIoChanged | kLatencyChanged) was never sent, so
    latency changes went unannounced
  - clap timers never fired

Measured against Reaper with the editor closed, 14 of 14 request_callback
calls went unanswered; under Steinberg's own validator, 372 of 372.

This adds the missing timer as a thread of the helper's own, at 100Hz. It
stands in for the main thread the host does not provide, and covers that
gap only: a plug object that has been handed a run loop answers
hasOwnIdleSource() and is skipped, and when every attached object has one
the thread parks on a condition variable rather than spinning.
os::idleSourceChanged() wakes it when an editor closes. That is the same
arrangement JUCE's Linux plug-in client uses, which runs a MessageThread
of its own and stops it when the host's message thread appears.

Because that thread is not the host's main thread, two things follow.

Clap::Plugin::AlwaysMainThread() is raised around the idle so that
clap_host_thread_check answers truthfully for its duration, and a
recursive _mainThreadLock is taken there and in the VST3 entry points a
host calls on the main thread, so that the two cannot be inside the
plug-in at once. onIdle() takes it with try_lock and skips the tick if the
host is already inside: the helper holds a module-wide lock while it
idles, and blocking would stall every other instance behind it.

One fix this depends on: WrappedView::setFrame() now reports the run loop
going away as well as arriving. It only ever set _runLoop, so a host that
hands back a null frame while keeping the view -- across an editor being
closed and reopened -- left the wrapper believing it still had a main
thread, and the stand-in thread parked forever. _onRunLoopAvailable is
renamed _onRunLoopChanged to match.

Tested on aarch64 Linux: Steinberg's validator passes 47/47; a purpose-
built host that never creates a view serves every request_callback where
it previously served none; opening and closing an editor mid-run hands the
idle over in both directions without dropping one.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@baconpaul

Copy link
Copy Markdown
Collaborator Author

This PR is substantially improving the linux vst3 situation but it still doesn't run timers or fd when the iRunLoop is closed. That's a separate larger issue that I think we need to consider.

This change, though, will guarantee that on_main_thread is called when you request_callback

@baconpaul

Copy link
Copy Markdown
Collaborator Author

Finally got some more time with a linux box to do some manual stress testing here and this all checks out. Timo reviewed and gave me go ahead. So merging.

@baconpaul
baconpaul merged commit aeb37d4 into free-audio:next Sep 3, 2026
28 checks passed
@baconpaul
baconpaul deleted the linux-idle-thread branch September 3, 2026 01:14
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