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

@Straniera

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions