Skip to content

0.16 review: four crash-class fixes (PD-1, PD-3, PD-7, SA-1) - #545

Merged
defiantnerd merged 3 commits into
nextfrom
0.16-fix-crash-class
Sep 14, 2026
Merged

defiantnerd merged 3 commits into
nextfrom
0.16-fix-crash-class

Conversation

@defiantnerd

@defiantnerd defiantnerd commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

First of four batches from the 0.16 release review of main...next. These are the crash-class findings — the ones that take the host down or hang a scan. They are independent of each other and of the rest of the review.

What is here

PD-1 — Windows path encoding could kill the host. fs::path and the CLAP ABI disagree about what a string is. path::string() throws std::system_error for any filename the ANSI code page cannot express, and the crawl runs on a bare std::thread, so one パッド.preset in a user folder was std::terminate for the host. In the other direction fs::path{std::string} decoded a UTF-8 location as ACP, so a plugin declaring a path with a non-ASCII segment had that location silently skipped. Conversions now go through pathFromUtf8() and u8string(), as fsutil.cpp already did everywhere, and the thread body catches everything.

PD-3 — the crawl thread outlived the hosted CLAP. PresetIndex::resetCache() had no callers, and the ModuleTerminator deleted the library — deinit() and unmap — while the crawl was inside provider->get_metadata() in that module. Reaper's in-process rescan either tears the plugin's globals down under a running crawl, or joins it later from a static destructor under DLL_PROCESS_DETACH, where the loader lock stops the thread exiting and the scan hangs. Clearing the cache map alone was not enough, since a live wrapper's shared_ptr keeps its thread alive.

PD-7 — a listener could run on a destroyed wrapper. finish() copied the listener list, dropped the lock, then called the copies — and a copy cannot be recalled, so a destructor that removed its listener could still be called on freed memory. Logic and auval dispose an AU about 100 ms after instantiating it, squarely inside a folder crawl.

SA-1 — macOS quit crashed. applicationWillTerminate: held a shared_ptr copy of the plugin past mainFinish(), so it was the last owner and ~Plugin ran destroy() on an already-deinited entry. Windows already released before mainFinish (there is a comment about it in the WM_DESTROY handler); the macOS path was missed when that landed.

Verification, and its limits

  • VST3 target builds clean under C++17; clap-wrapper-shared-detail builds clean under C++20, which is the configuration that matters for the u8string() changes. clang-format clean.
  • SA-1 was not compiled. It is macOS-only and was developed on Windows. It is a pure block-scoping change introducing no new API, and the ordering was checked by reading mainFinish in entry.cpp and ~Plugin in clap_proxy.cpp — but it needs a real macOS build before it is trusted.
  • There are no tests covering PresetIndex, so the preset fixes are compile-and-reasoning, not runtime proof.

Deliberately left alone

A listener registered in the exact window where a crawl completes can still be called twice. That predates this change and both current consumers only set an idempotent atomic flag.

Note on fs::u8path

Not used, although it is the textbook C++17 idiom: it is deprecated in C++20 and this project builds with -Werror. u8string() is safe here because shared_prologue.cmake turns char8_t off whenever the standard is >= 20, so it returns std::string on every configuration this project builds.

applicationWillTerminate took a shared_ptr copy of the hosted plugin that
stayed alive until the method returned - past mainFinish(), which resets
the global and the host's references and then calls entry->deinit(). The
local copy was therefore the last owner, so ~Plugin ran destroy() on an
already-deinited entry and crashed on exit for any plugin whose deinit
tears down global state.

Windows already released its reference before mainFinish (see the comment
in windows_standalone.cpp's WM_DESTROY handler); the macOS path was missed
when that fix landed. Scope the reference to the GUI teardown that needs
it so the host's own reset inside mainFinish is the last owner.

Not compiled: macOS-only source, developed on Windows. Verified by reading
mainFinish (entry.cpp) and ~Plugin (clap_proxy.cpp) rather than by build.
Three crash-class bugs in the new preset-discovery subsystem, fixed
together because they are one story: the crawl thread was unsafe to run,
unsafe to stop, and unsafe to be listened to.

PD-1, encoding. fs::path and the CLAP ABI disagree about what a string
is: the ABI is UTF-8, fs::path is whatever the OS says. On Windows
fs::path{std::string} decodes through the ANSI code page, so a plugin
declaring "C:\Users\Jurgen\...\Presets" got a folder that does not exist
and the location was silently skipped; and path::string() *throws*
std::system_error for any name the code page cannot express, which on a
bare thread is std::terminate for the host - one odd filename in a user
folder was enough. Conversions now go through a pathFromUtf8() helper and
u8string(), the way fsutil.cpp already does everywhere. Deliberately not
fs::u8path(), which is deprecated in C++20 and this builds with -Werror;
u8string() stays std::string on every configuration because
shared_prologue.cmake turns char8_t off whenever the standard is >= 20.
The thread body also catches everything now, and still reports completion
when it does, so a waiter does not sit out its timeout over a failed crawl.

PD-3, lifetime. PresetIndex::resetCache() had no callers at all, and the
ModuleTerminator deleted the hosted library - deinit() and unmap - while
the crawl thread was inside provider->get_metadata() in that very module.
Reaper's in-process rescan is where that shows: either the plugin's
globals go out under a running crawl, or the crawl is joined later from
the static IndexCache destructor, which on Windows is under
DLL_PROCESS_DETACH, where the loader lock keeps the thread from exiting
and the scan simply hangs. The terminator now calls resetCache() first.
Clearing the map was not enough on its own, because a wrapper still
holding a shared_ptr keeps its thread alive, so resetCache() takes the
indices out under the cache lock and abandons and joins each one outside
it. ExitDll/bundleExit/ModuleExit are called by the host rather than from
DllMain, so that join is not under the loader lock.

PD-7, listeners. finish() copied the listener list, dropped the lock and
then called the copies - and a copy cannot be recalled, so a wrapper whose
destructor removed its listener could still be called, on freed memory.
Logic and auval dispose an AU about 100 ms after instantiating it, which
lands squarely inside a folder crawl. finish() now looks each listener up
at its turn and publishes which one it is running and on which thread;
removeCompletionListener() waits for exactly that one, and only when the
caller is not the crawl thread, so a listener removing itself does not
wait on itself. Listeners are still called outside the lock, because they
may re-enter the index.

Verified: VST3 target builds clean under C++17, clap-wrapper-shared-detail
under C++20 (the char8_t-sensitive path), clang-format clean. There are no
tests covering PresetIndex, so this is compile-and-reasoning, not runtime
proof.

Pre-existing and left alone: a listener registered in the exact window
where a crawl completes can still be called twice. Both current consumers
only set an idempotent atomic flag.
Keep the non-obvious why, drop the narration.
@defiantnerd
defiantnerd merged commit 7fce594 into next Sep 14, 2026
28 checks passed
@defiantnerd
defiantnerd deleted the 0.16-fix-crash-class branch September 14, 2026 18:41
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.

2 participants