Skip to content

Commit 72fe1cd

Browse files
authored
Fix timeout races and other issues from the v2 review (#198)
* Fix races between timeouts and report/log writing - validate: guard the log stream with a mutex so the timeout thread can't reset it while a test is writing to it, and stop the timeout thread once the tests finish so it can't write its report over the real one. - test: the worker and the watchdog now claim the report with an atomic flag and only the first to claim it writes. The placeholder report is written before the watchdog starts. * Fix issues from the v2 review - Crash log: back in the per-user temp directory (path computed up front for the signal handler) instead of a shared /tmp file, opened with O_NOFOLLOW. - Crash handler: don't pass null image/symbol names to %s; cast the offset for %ld; include <string.h> and <algorithm>. - --output-filename without --output-dir is resolved against the working directory rather than building a juce::File from a relative path. - pluginval test accepts --name=value options, as validate does. - GitHub Actions: delete each report before running so a stale one from an earlier run is never read. - TestConfig::getPluginPathOrID uses settings_parser::resolvePluginPath. - vst3validator: delete the extracted binary if writing or chmod fails. - Typos, spacing, closing-namespace comments, an em dash, and the JUCE version in CLAUDE.md. * Fix remaining issues from the v2 review - pluginval test: --record-missing=<value> is an error rather than the value being taken as the config path. - Message-thread helpers share one callOnMessageThreadAndWait() that rethrows an exception on the calling thread, so a throw can't leave the caller waiting forever. Used by the VST3 helpers, the editor helpers, the plugin scan, message-thread tests and the acceptance plugin teardown, which now can't throw out of its destructor. - Child process output is decoded with ChildProcessOutputDecoder, so a UTF-8 character split across two reads is no longer garbled. - operator delete no longer throws (it's noexcept, so that terminated the process); operator new throws std::bad_alloc rather than returning null. Removed the unused violationError(). - Replaced the em/en dashes in CLAUDE.md and the acceptance design doc.
1 parent aabea03 commit 72fe1cd

23 files changed

Lines changed: 414 additions & 250 deletions

‎.github/actions/acceptance/acceptance.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ for config in ${configs[@]+"${configs[@]}"}; do
3939
index=$((index + 1))
4040
name="${config#"$config_dir"/}"
4141
report="$report_dir/$index-$(sanitise "$(basename "$config" .json)").xml"
42+
rm -f "$report" # so an earlier run's report can't be read if this one writes none
4243

4344
echo "::group::pluginval test: $name"
4445

‎.github/actions/validate/validate.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ for plugin in ${plugins[@]+"${plugins[@]}"}; do
2828

2929
index=$((index + 1))
3030
report="$report_dir/$index-$(sanitise "$(basename "$plugin")").xml"
31+
rm -f "$report" # so an earlier run's report can't be read if this one writes none
3132

3233
echo "::group::pluginval validate: $plugin"
3334
# INPUT_ARGS is deliberately unquoted so it splits into separate arguments.

‎CLAUDE.md‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,11 @@ The command line is structured into subcommands, peeled off by a thin verb
217217
dispatcher in front of the settings pipeline (it does **not** use CLI11-native
218218
subcommands, so the env/config/CLI layering below is untouched):
219219

220-
- `pluginval validate [options] <plugin>` — the default; `<plugin>` is a
220+
- `pluginval validate [options] <plugin>` - the default; `<plugin>` is a
221221
positional argument. `pluginval <plugin>` and `pluginval [options] <plugin>`
222222
(no verb) also resolve to validate.
223-
- `pluginval run-tests` — runs the internal unit tests.
224-
- `pluginval strictness-help [level]` — lists tests at a strictness level.
223+
- `pluginval run-tests` - runs the internal unit tests.
224+
- `pluginval strictness-help [level]` - lists tests at a strictness level.
225225

226226
`settings_parser::dispatch()` (in `SettingsParser.cpp`) takes the `tokenise()`d
227227
command line and returns a `DispatchResult { command, validateTokens,
@@ -248,7 +248,7 @@ that CLI11 binds to directly. A single instance is filled by successive layers,
248248
**lowest to highest precedence: defaults → environment → `--config` → CLI**
249249
(in `SettingsParser::parseTokens`):
250250

251-
1. **preprocess** the raw command line — rewrite the deprecated `strictnessLevel`,
251+
1. **preprocess** the raw command line - rewrite the deprecated `strictnessLevel`,
252252
strip the macOS `-NSDocumentRevisionsDebugMode YES` flag, and insert an
253253
implicit `--validate` when the last argument is a bare plugin path.
254254
2. **Environment layer.** Env-var names are *derived* from the registered
@@ -267,7 +267,7 @@ uses a `CheckedTransformer`, and the hex/int seed is a small callback.
267267
`PluginTests::Options` at the boundary.
268268

269269
Adding a new option is three edits: a struct member, an entry in the nlohmann
270-
macro list, and one `add_option(...)` line — its environment variable then works
270+
macro list, and one `add_option(...)` line - its environment variable then works
271271
automatically. `SettingsSerializer` handles JSON load/save plus the two
272272
remaining conversions (hex seed, disabled-tests file).
273273

@@ -279,7 +279,7 @@ are handled by CLI11 (auto usage + a footer with the env-var/commands notes).
279279
### Acceptance Testing (`pluginval test`)
280280

281281
A **parallel subsystem** to validate, in `source/acceptance/`. It answers "does
282-
this plugin produce the expected output for a known input + state?" — a
282+
this plugin produce the expected output for a known input + state?" - a
283283
deterministic *render + golden-file comparison*, not a unit-test pass/fail. Full
284284
spec: `tests/acceptance/Acceptance testing design.md`; end-user guide:
285285
`docs/Acceptance testing.md`.
@@ -289,13 +289,13 @@ spec: `tests/acceptance/Acceptance testing design.md`; end-user guide:
289289
`DispatchResult::testConfigPath`, plus `recordMissing` / `timeoutMs` from
290290
`--record-missing` / `--timeout-ms` or `RECORD_MISSING` / `TIMEOUT_MS`, and
291291
`testPluginOverride` from `--plugin`, which replaces the config's `plugin`
292-
in `runTestFile()` - so `plugin` is only required when `--plugin` is absent),
292+
in `loadAndRunTestFile()` - so `plugin` is only required when `--plugin` is absent),
293293
`isCommandLine()` and `getFooterText()`; `CommandLine.cpp`'s
294294
`performCommandLine()` has a `Command::test` branch that starts an
295295
`acceptance::TestRunner`, which renders on a **background thread** (message
296296
thread left free; plugin deleted on it), with a watchdog for the timeout, and
297297
quits via `callAsync` when done.
298-
- **Config**: `acceptance::TestConfig` (`TestConfig.cpp/h`) — std-typed struct,
298+
- **Config**: `acceptance::TestConfig` (`TestConfig.cpp/h`) - std-typed struct,
299299
**snake_case JSON keys** mapped via explicit `to_json`/`from_json` (members
300300
stay camelCase). It is **independent** of `PluginvalSettings` / the `--config`
301301
layering: the test config is a positional argument loaded standalone.
@@ -314,7 +314,7 @@ spec: `tests/acceptance/Acceptance testing design.md`; end-user guide:
314314
tolerance, default one 16-bit LSB = `1/32768`; `0` = bit-exact). Adding
315315
`spectrum`/`crosscorr`/etc. is one registry entry, no config/runner changes.
316316
- **Dogfood + self-tests**: three minimal deterministic `juce_add_plugin` targets
317-
behind `PLUGINVAL_BUILD_TEST_PLUGINS` — `tests/test_plugins/tone_generator/`
317+
behind `PLUGINVAL_BUILD_TEST_PLUGINS` - `tests/test_plugins/tone_generator/`
318318
(closed-form sine/square generator, phase resets on `prepareToPlay`),
319319
`tests/test_plugins/gain/` (a gain effect, dogfoods the `input.audio` path) and
320320
`tests/test_plugins/playhead_probe/` (writes the host transport to its output,
@@ -352,8 +352,10 @@ log are unaffected.
352352
completion, so a crash leaves an error report rather than a stale or missing
353353
one. On a timeout the thread that ends the process writes a "timed out" error
354354
report as its last act (`PluginsUnitTestRunner`'s `onTimeout` for validate, the
355-
acceptance `Watchdog` for test), so the report always agrees with the exit code
356-
even if the tests finish in the moment before the process is terminated.
355+
acceptance `Watchdog` for test). The two writers never overlap: validate stops
356+
the timeout thread before writing the real report, and for test the worker and
357+
the `Watchdog` each claim the report (an atomic flag) and only the first to
358+
claim it writes, so the report always agrees with the exit code.
357359

358360
### Test Framework
359361

@@ -399,8 +401,8 @@ The VST3 validator (Steinberg's vstvalidator) is embedded into pluginval when bu
399401
5. When the `VST3validator` test runs, it spawns the extracted validator as a subprocess
400402
401403
**Key files:**
402-
- `cmake/GenerateBinaryHeader.cmake` — binary-to-C-header conversion script
403-
- `source/vst3validator/VST3ValidatorRunner.h/cpp` — extracts embedded binary, returns `juce::File`
404+
- `cmake/GenerateBinaryHeader.cmake` - binary-to-C-header conversion script
405+
- `source/vst3validator/VST3ValidatorRunner.h/cpp` - extracts embedded binary, returns `juce::File`
404406
405407
**Disabling embedded validator:**
406408
```bash
@@ -449,6 +451,8 @@ Located in `TestUtilities.h`:
449451
- `countNaNs()`, `countInfs()`, `countSubnormals()` - Audio validation
450452
- `ScopedEditorShower` - RAII editor creation/destruction
451453
- `callPrepareToPlayOnMessageThreadIfVST3()` - VST3-safe lifecycle
454+
- `callOnMessageThreadAndWait()` - Run code on the message thread and wait; rethrows its exceptions on the caller
455+
- `ChildProcessOutputDecoder` - Decode child-process output read in chunks without splitting UTF-8 characters
452456
- `ScopedAllocationDisabler` - Detect allocations in audio thread
453457

454458
### Real-time Safety Checking
@@ -573,7 +577,7 @@ Without the actions:
573577
## Dependencies
574578
575579
### External
576-
- **JUCE** (v8.0.x) - Audio application framework (git submodule)
580+
- **JUCE** (v9.0.x) - Audio application framework (git submodule)
577581
- **magic_enum** (v0.9.7) - Enum reflection (fetched via CPM)
578582
- **CLI11** (v2.6.2) - CLI argument parsing, header-only (fetched via CPM)
579583
- **nlohmann/json** (3.12.0) - JSON settings layering/serialisation (fetched via CPM)

‎source/CommandLineTests.cpp‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,32 @@ struct CommandLineTests : public juce::UnitTest
586586
}
587587
}
588588

589+
beginTest ("Subcommand: test options in --name=value form");
590+
{
591+
{
592+
const auto d = settings_parser::dispatch (settings_parser::tokenise ("test --timeout-ms=500 --junit-xml=out/r.xml --json-results=out/r.json --plugin=build/P.vst3 config.json"));
593+
expect (d.error.isEmpty());
594+
expectEquals (d.timeoutMs, (juce::int64) 500);
595+
expectEquals (d.junitXmlPath, juce::String ("out/r.xml"));
596+
expectEquals (d.jsonResultsPath, juce::String ("out/r.json"));
597+
expectEquals (d.testPluginOverride, juce::String ("build/P.vst3"));
598+
expectEquals (d.testConfigPath, juce::String ("config.json"));
599+
}
600+
{
601+
const auto d = settings_parser::dispatch (settings_parser::tokenise ("test config.json --plugin="));
602+
expect (d.error.contains ("--plugin"));
603+
}
604+
{
605+
const auto d = settings_parser::dispatch (settings_parser::tokenise ("test --output-dir=out config.json"));
606+
expect (d.error.contains ("--output-dir"));
607+
}
608+
{
609+
// A flag doesn't take a value, so the value isn't taken as the config path
610+
const auto d = settings_parser::dispatch (settings_parser::tokenise ("test --record-missing=1 config.json"));
611+
expect (d.error.contains ("--record-missing"));
612+
}
613+
}
614+
589615
beginTest ("Report writer: JUnit XML");
590616
{
591617
report_writer::Report report;

‎source/CrashHandler.cpp‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
#if ! JUCE_WINDOWS
1919
#include "native/pluginval_native_posix.h"
20-
static const auto crashLogPath = "/tmp/pluginval_crash.txt";
20+
21+
// Set by initialiseCrashHandler(), as the crash handler can't allocate to build it
22+
static char crashLogPath[1024] {};
2123
#endif
2224

2325
namespace
@@ -29,11 +31,7 @@ namespace
2931

3032
static juce::File getCrashTraceFile()
3133
{
32-
#if JUCE_WINDOWS
3334
return juce::File::getSpecialLocation (juce::File::tempDirectory).getChildFile ("pluginval_crash.txt");
34-
#else
35-
return juce::File (crashLogPath);
36-
#endif
3735
}
3836

3937
#if JUCE_WINDOWS
@@ -66,7 +64,13 @@ namespace
6664
void initialiseCrashHandler()
6765
{
6866
// Delete the crash file, this will be created if possible
69-
getCrashTraceFile().deleteFile();
67+
const auto crashTraceFile = getCrashTraceFile();
68+
crashTraceFile.deleteFile();
69+
70+
#if ! JUCE_WINDOWS
71+
crashTraceFile.getFullPathName().copyToUTF8 (crashLogPath, sizeof (crashLogPath));
72+
#endif
73+
7074
juce::SystemStats::setApplicationCrashHandler (handleCrash);
7175
}
7276

‎source/MainComponent.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ namespace
259259

260260
void showOutputDirDialog()
261261
{
262-
juce::String message = TRANS("Set a desintation directory to place log files");
262+
juce::String message = TRANS("Set a destination directory to place log files");
263263
auto dir = getOutputDir();
264264

265265
if (dir.getFullPathName().isNotEmpty())

‎source/PluginTests.cpp‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,10 @@ void PluginTests::runTest()
117117
{
118118
beginTest ("Scan for plugins located in: " + fileOrID);
119119

120-
juce::WaitableEvent completionEvent;
121-
juce::MessageManager::callAsync ([&, this]() mutable
122-
{
123-
knownPluginList.scanAndAddDragAndDroppedFiles (formatManager, juce::StringArray (fileOrID), typesFound);
124-
completionEvent.signal();
125-
});
126-
completionEvent.wait();
120+
callOnMessageThreadAndWait ([&]
121+
{
122+
knownPluginList.scanAndAddDragAndDroppedFiles (formatManager, juce::StringArray (fileOrID), typesFound);
123+
});
127124

128125
logMessage ("Num plugins found: " + juce::String (typesFound.size()));
129126
expect (! typesFound.isEmpty(),
@@ -212,13 +209,7 @@ void PluginTests::testType (const juce::PluginDescription& pd)
212209

213210
if (t->needsToRunOnMessageThread())
214211
{
215-
juce::WaitableEvent completionEvent;
216-
juce::MessageManager::callAsync ([&, this]() mutable
217-
{
218-
t->runTest (*this, *instance);
219-
completionEvent.signal();
220-
});
221-
completionEvent.wait();
212+
callOnMessageThreadAndWait ([&] { t->runTest (*this, *instance); });
222213
}
223214
else
224215
{

‎source/ReportWriter.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,4 @@ namespace report_writer
7575
failed run).
7676
*/
7777
bool write (const Report&, const juce::File& junitXml, const juce::File& jsonResults);
78-
}
78+
} // namespace report_writer

‎source/SettingsParser.cpp‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,17 +393,41 @@ Precedence (lowest to highest): defaults, environment variables, --config, comma
393393

394394
// The options, plus the positional acceptance-test config (the first
395395
// non-option token after the verb).
396+
// Accept "--name=value" as well as "--name value", as validate (CLI11) does
397+
juce::StringArray tokens;
398+
396399
for (int i = 1; i < tokensIn.size(); ++i)
397400
{
398401
const auto& token = tokensIn.getReference (i);
399402

403+
if (token.startsWith ("--record-missing="))
404+
{
405+
result.error = "--record-missing doesn't take a value (got \"" + token + "\")";
406+
return result;
407+
}
408+
409+
if (token.startsWith ("--") && token.contains ("="))
410+
{
411+
tokens.add (token.upToFirstOccurrenceOf ("=", false, false));
412+
tokens.add (token.fromFirstOccurrenceOf ("=", false, false));
413+
}
414+
else
415+
{
416+
tokens.add (token);
417+
}
418+
}
419+
420+
for (int i = 0; i < tokens.size(); ++i)
421+
{
422+
const auto& token = tokens.getReference (i);
423+
400424
if (token == "--record-missing")
401425
{
402426
result.recordMissing = true;
403427
}
404428
else if (token == "--timeout-ms")
405429
{
406-
const auto value = i + 1 < tokensIn.size() ? tokensIn.getReference (++i) : juce::String();
430+
const auto value = i + 1 < tokens.size() ? tokens.getReference (++i) : juce::String();
407431
const auto digits = value.startsWith ("-") ? value.substring (1) : value;
408432

409433
if (digits.isEmpty() || ! digits.containsOnly ("0123456789"))
@@ -416,7 +440,7 @@ Precedence (lowest to highest): defaults, environment variables, --config, comma
416440
}
417441
else if (token == "--junit-xml" || token == "--json-results")
418442
{
419-
const auto value = i + 1 < tokensIn.size() ? tokensIn.getReference (++i) : juce::String();
443+
const auto value = i + 1 < tokens.size() ? tokens.getReference (++i) : juce::String();
420444

421445
if (value.isEmpty() || value.startsWith ("--"))
422446
{
@@ -428,7 +452,7 @@ Precedence (lowest to highest): defaults, environment variables, --config, comma
428452
}
429453
else if (token == "--plugin")
430454
{
431-
const auto value = i + 1 < tokensIn.size() ? tokensIn.getReference (++i) : juce::String();
455+
const auto value = i + 1 < tokens.size() ? tokens.getReference (++i) : juce::String();
432456

433457
if (value.isEmpty() || value.startsWith ("--"))
434458
{
@@ -599,4 +623,4 @@ Precedence (lowest to highest): defaults, environment variables, --config, comma
599623
args.addArray ({ "validate", "--config-base64", b64, fileOrID });
600624
return args;
601625
}
602-
}
626+
} // namespace settings_parser

‎source/SettingsParser.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,4 @@ namespace settings_parser
119119

120120
/** The "--version" output string. */
121121
juce::String getVersionString();
122-
}
122+
} // namespace settings_parser

0 commit comments

Comments
 (0)