Skip to content

Windows/VST3: intermittent 0xC0000005 at teardown after the final verdict - EditControllerParameterDispatcher's 60 Hz flush races plugin release (use-after-free) #177

Description

@Straniera

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).

  1. 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.

  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions