Summary
Validating a VST3 on Windows intermittently dies with 0xC0000005 after the final verdict has already been printed: the log ends with SUCCESS (or FAILURE), and the process then crashes during teardown, so the exit code disagrees with the verdict. This breaks exit-code-based CI gating (a fully passing validation is reported as failed).
The crash is a use-after-free in the hosting layer: EditControllerParameterDispatcher's 60 Hz timer flushes cached parameter values into IEditController::setParamNormalized() after the controller has been terminated and released. The VST3 lifecycle forbids host calls into the plug-in after IPluginBase::terminate(), so there is nothing a plug-in can do to defend itself.
Observed on the v1.0.4 release binary and on a local Release build of develop @ 4c5adc2 (Windows, --strictness-level 10 --skip-gui-tests --validate <plugin>; the CLI path runs validation in-process per CommandLine.cpp -> ValidationType::inProcess).
Crash signature
- Full log ends with the final verdict; process exit code is
-1073741819 (0xC0000005).
- Windows Application-Error event (ID 1000) blames the plug-in module at a constant per-build RVA which symbolizes (matched PDB) to the plug-in's parameter lookup inside
IEditController::setParamNormalized:
pluginval.exe (message thread, teardown - after the verdict was printed)
-> IEditController::setParamNormalized (host-initiated write)
-> plug-in parameter-map lookup <- AV reading address 0x8
The fault address (null + 8) is the plug-in's already-destructed parameter container: terminate() had legally cleared it, the module is still mapped, so the late call lands in valid code that reads destructed members.
- The race is timing-sensitive: with a debugger attached it did not reproduce in 80 consecutive runs, while unattached runs crash at the rates below. We captured it via WER events and an in-process vectored exception handler instead.
Root cause (JUCE 8.0.13, modules/juce_audio_processors_headless/format_types/juce_VST3PluginFormatImpl.h)
juce_audio_processors' VST3 hosting includes this header (juce_VST3PluginFormat.cpp:37), so this is the code compiled into pluginval (1.0.4 ships the same pattern via JUCE 8.0.3).
-
EditControllerParameterDispatcher (line ~128) holds a raw Vst::IEditController* controller and runs a 60 Hz Timer (startTimerHz (60) in start(), line ~148). timerCallback() -> flush() (line ~151) calls controller->setParamNormalized (...) with no lifetime guard - push() checks controller == nullptr, flush() does not, and controller is never nulled anyway.
-
The cache is loaded off the message thread throughout validation, so values are almost always pending near the end of a run:
VST3Parameter::setValue() -> parameterDispatcher.push (...) (line ~2026) - i.e. every parameter write made by the tests, including the parameter fuzz;
processBlock output parameter changes -> push (...) (line ~2489);
- plug-in
performEdit -> push (...) (line ~3319).
-
Teardown ordering (VST3PluginInstanceHeadless, line ~2149):
~VST3PluginInstanceHeadless() override
{
MessageManager::callSync ([this] { cleanup(); }); // controller terminated (l.~2169)
} // and released (l.~2183) here...
// ...then, back on the *calling* thread, member destruction eventually
// reaches `parameterDispatcher` (member at l.~2997) whose destructor
// finally calls stopTimer() (l.~131).
Between cleanup() finishing (controller terminated + deleted, message thread) and ~EditControllerParameterDispatcher running stopTimer() (caller's thread), the 60 Hz timer is still armed with a dangling controller pointer. A tick landing in that window calls setParamNormalized on the dead controller. stopTimer() from a non-message thread can also race an in-flight timerCallback().
What makes a plug-in "tickle" it
Any plug-in whose parameters are written late in the run (the fuzz test guarantees pending cache entries). In our case the rate is amplified by the spec-canonical post-restore handshake - restartComponent (kParamValuesChanged) emitted from setComponentState - which re-syncs all values near the end of the run. A sibling plug-in on the same framework that never emits the late refresh has not crashed in any of our runs.
Repro
# Windows, any VST3 whose parameters get written near the end of the run:
for /l %i in (1,1,10) do pluginval --strictness-level 10 --skip-gui-tests --validate Plugin.vst3 & echo rc=%errorlevel%
Crash = log ends with the verdict, rc = -1073741819, Application event log shows the AV in the plug-in module.
Measured rates, same plug-in binary, same machine (Windows 11 Pro build 26200, in-process CLI validation):
| build |
runs |
teardown crashes (verdict printed, rc=0xC0000005) |
| v1.0.4 release binary |
40 |
11 / 40 (27.5 %) |
develop @ 4c5adc2 (local Release build, JUCE 8.0.13) |
40 |
1 / 40 (2.5 %) |
(Earlier series agree: v1.0.4 showed 6/30 and 10/29 on other builds of the same plug-in. develop also crashed once after a FAILURE verdict in a separate 30-run series - the race is verdict-agnostic; develop's extra in-process tests appear to narrow the timing window without closing it.)
Suggested direction
Disarm the dispatcher before the controller goes away, on the message thread - e.g. stop the timer / destroy the dispatcher at the top of cleanup() (before editController->terminate()), and/or make flush() lifetime-guarded the way push() already is. Since the dispatcher and the controller die on different threads today, just nulling the pointer isn't enough on its own.
This is stock JUCE 8 hosting code, so the underlying issue presumably affects other JUCE-8-based hosts as well; happy to file it against juce-framework/JUCE too if you'd prefer it fixed upstream - pluginval is where it bites us (CI gating), and pluginval controls its own teardown ordering regardless.
Happy to test patches or provide full cdb/WER captures.
Summary
Validating a VST3 on Windows intermittently dies with
0xC0000005after the final verdict has already been printed: the log ends withSUCCESS(orFAILURE), and the process then crashes during teardown, so the exit code disagrees with the verdict. This breaks exit-code-based CI gating (a fully passing validation is reported as failed).The crash is a use-after-free in the hosting layer:
EditControllerParameterDispatcher's 60 Hz timer flushes cached parameter values intoIEditController::setParamNormalized()after the controller has been terminated and released. The VST3 lifecycle forbids host calls into the plug-in afterIPluginBase::terminate(), so there is nothing a plug-in can do to defend itself.Observed on the v1.0.4 release binary and on a local Release build of
develop@4c5adc2(Windows,--strictness-level 10 --skip-gui-tests --validate <plugin>; the CLI path runs validation in-process perCommandLine.cpp->ValidationType::inProcess).Crash signature
-1073741819(0xC0000005).IEditController::setParamNormalized:The fault address (
null + 8) is the plug-in's already-destructed parameter container:terminate()had legally cleared it, the module is still mapped, so the late call lands in valid code that reads destructed members.Root cause (JUCE 8.0.13,
modules/juce_audio_processors_headless/format_types/juce_VST3PluginFormatImpl.h)juce_audio_processors' VST3 hosting includes this header (juce_VST3PluginFormat.cpp:37), so this is the code compiled into pluginval (1.0.4 ships the same pattern via JUCE 8.0.3).EditControllerParameterDispatcher(line ~128) holds a rawVst::IEditController* controllerand runs a 60 HzTimer(startTimerHz (60)instart(), line ~148).timerCallback()->flush()(line ~151) callscontroller->setParamNormalized (...)with no lifetime guard -push()checkscontroller == nullptr,flush()does not, andcontrolleris never nulled anyway.The cache is loaded off the message thread throughout validation, so values are almost always pending near the end of a run:
VST3Parameter::setValue()->parameterDispatcher.push (...)(line ~2026) - i.e. every parameter write made by the tests, including the parameter fuzz;processBlockoutput parameter changes ->push (...)(line ~2489);performEdit->push (...)(line ~3319).Teardown ordering (
VST3PluginInstanceHeadless, line ~2149):Between
cleanup()finishing (controller terminated + deleted, message thread) and~EditControllerParameterDispatcherrunningstopTimer()(caller's thread), the 60 Hz timer is still armed with a dangling controller pointer. A tick landing in that window callssetParamNormalizedon the dead controller.stopTimer()from a non-message thread can also race an in-flighttimerCallback().What makes a plug-in "tickle" it
Any plug-in whose parameters are written late in the run (the fuzz test guarantees pending cache entries). In our case the rate is amplified by the spec-canonical post-restore handshake -
restartComponent (kParamValuesChanged)emitted fromsetComponentState- which re-syncs all values near the end of the run. A sibling plug-in on the same framework that never emits the late refresh has not crashed in any of our runs.Repro
Crash = log ends with the verdict,
rc=-1073741819, Application event log shows the AV in the plug-in module.Measured rates, same plug-in binary, same machine (Windows 11 Pro build 26200, in-process CLI validation):
4c5adc2(local Release build, JUCE 8.0.13)(Earlier series agree: v1.0.4 showed 6/30 and 10/29 on other builds of the same plug-in. develop also crashed once after a
FAILUREverdict in a separate 30-run series - the race is verdict-agnostic; develop's extra in-process tests appear to narrow the timing window without closing it.)Suggested direction
Disarm the dispatcher before the controller goes away, on the message thread - e.g. stop the timer / destroy the dispatcher at the top of
cleanup()(beforeeditController->terminate()), and/or makeflush()lifetime-guarded the waypush()already is. Since the dispatcher and the controller die on different threads today, just nulling the pointer isn't enough on its own.This is stock JUCE 8 hosting code, so the underlying issue presumably affects other JUCE-8-based hosts as well; happy to file it against juce-framework/JUCE too if you'd prefer it fixed upstream - pluginval is where it bites us (CI gating), and pluginval controls its own teardown ordering regardless.
Happy to test patches or provide full cdb/WER captures.