Add a ThreadSanitizer config to enforce the main-thread macro-name copy #2

Closed
opened 2026-08-18 01:15:48 +00:00 by bcox · 2 comments
Owner

The plugin keeps a main-thread-owned copy of the macro names, macro_names_,
specifically so that paramsInfo never reads them out of the live
instrument_ — which the audio thread may be swapping under it via the
pending_ready_ handover.

That discipline is currently held by review and a comment, not by the
build. Nothing in the suite can observe it: a single-threaded test loads,
installs and then reads, so reading straight out of instrument_ would pass
too. plugin/host_smoke_test.cc says so in a comment rather than pretending
otherwise:

These cover the name plumbing. Neither covers the reason macro_names_ is a
separate main-thread copy [...] That the copy exists is a property to hold in
review, not one these tests can catch.

A ThreadSanitizer configuration would convert it into a property the build
enforces.

Shape of the test

Activate the plugin, run process() on a second thread in a loop, and from the
main thread repeatedly call preset_load->from_location() followed by
params->get_info(). Assert TSan reports no race.

The current design is expected to be clean under it. The check that it has
teeth: change paramsInfo to read
playground::MacroName(instrument_.parameters(), index) and TSan should flag
the read against AdoptPatch's write. If it does not, the test is not
exercising what it claims to.

What this does not cover

The pending_ single-writer guard in InstallPatchBytes is a different
property and TSan would likely not catch it without a dedicated stress
test — the window is one audio block. That guard has its own deterministic,
single-threaded test
(RefusesASecondPresetWhileAHandoverIsStillInFlight), which is sufficient for
the guard's behaviour even though it says nothing about the race itself.

Cost

This needs a --config=tsan in .bazelrc and a way to keep TSan off the
default lane — it is slow, and it needs a test that spawns a thread, which no
existing test does. Reasonable to defer until CI exists, since a sanitizer lane
nobody runs is worth nothing.

Depends on the CI issue.

The plugin keeps a main-thread-owned copy of the macro names, `macro_names_`, specifically so that `paramsInfo` never reads them out of the live `instrument_` — which the audio thread may be swapping under it via the `pending_ready_` handover. That discipline is currently held by **review and a comment**, not by the build. Nothing in the suite can observe it: a single-threaded test loads, installs and then reads, so reading straight out of `instrument_` would pass too. `plugin/host_smoke_test.cc` says so in a comment rather than pretending otherwise: > These cover the name plumbing. Neither covers the reason `macro_names_` is a > separate main-thread copy [...] That the copy exists is a property to hold in > review, not one these tests can catch. A ThreadSanitizer configuration would convert it into a property the build enforces. ## Shape of the test Activate the plugin, run `process()` on a second thread in a loop, and from the main thread repeatedly call `preset_load->from_location()` followed by `params->get_info()`. Assert TSan reports no race. The current design is expected to be clean under it. The check that it has teeth: change `paramsInfo` to read `playground::MacroName(instrument_.parameters(), index)` and TSan should flag the read against `AdoptPatch`'s write. If it does not, the test is not exercising what it claims to. ## What this does *not* cover The `pending_` single-writer guard in `InstallPatchBytes` is a different property and TSan would likely **not** catch it without a dedicated stress test — the window is one audio block. That guard has its own deterministic, single-threaded test (`RefusesASecondPresetWhileAHandoverIsStillInFlight`), which is sufficient for the guard's behaviour even though it says nothing about the race itself. ## Cost This needs a `--config=tsan` in `.bazelrc` and a way to keep TSan off the default lane — it is slow, and it needs a test that spawns a thread, which no existing test does. Reasonable to defer until CI exists, since a sanitizer lane nobody runs is worth nothing. Depends on the CI issue.
Author
Owner

This is a comment.

This is a comment.
Author
Owner

Landed on main: c82aef1, 28395b8, 977fb6e.

--config=tsan is in .bazelrc, scoped the same way asan is and for the same rules_cc_autoconf reason. //plugin:preset_race_test runs process() on a second thread with a voice sounding while the main thread hands over presets and reads back. //plugin:patch_format_test is tagged no-tsan — 1611s under TSan against 449s under asan, single-threaded throughout, so there is nothing there to find; the tag is a claim about threads, not speed. The lane also carries --test_timeout=180,600,1800,3600, because //dsp:fm_pair_layer_test is an honest small that intermittently ran past 60s under instrumentation.

The injection this issue specified works: with paramsInfo reading MacroName(instrument_.parameters(), index), TSan reports it against AdoptPatch's write, 20 runs in 20.

Two real defects turned up while building it, both fixed here.

paramsValue read instrument_.parameters().macro[id] — the same array AdoptPatch overwrites and WriteParameter stores into on every automation event inside process(). That is the race macro_names_ exists to avoid, one field over. macro_names_ was not the right shape to copy: a name has one writer so a main-thread copy suffices, a position has two, so macro_values_ is an array of relaxed atomics and that is what the host is told about.

stateSave handed the whole live instrument_ to WritePatch. TSan with halt_on_error=0 enumerated thirty-odd distinct races across layer count, layer kinds, the FM pair, the mod matrix, the LFOs, the frame curves, macros, macro names and trim. It now picks an instrument rather than a field, using pending_ready_ — which can only go true→false while stateSave runs, since both it and the only store of true are [main-thread].

The lane is the belt, not the braces. Both defects were also wrong answers on one thread, in the window between a preset load the host was told succeeded and the block that installs it. Three deterministic tests in host_smoke_test.cc pin that window and fail in every lane; they are the gate. TSan covers what they cannot — a race that leaves the right answer behind, which an aligned 8-byte load always does. Injected detection rates are recorded in preset_race_test.cc's header: 20/20 for state, 19–20/20 for names, 14/20 for values. That 14 is why it is not the gate.

What the issue anticipated held up. The pending_ single-writer guard is still not covered here and still has its deterministic test; and the note that a sanitizer lane nobody runs is worth nothing is still true — #1 stays open for the CI trigger.

Landed on main: `c82aef1`, `28395b8`, `977fb6e`. `--config=tsan` is in `.bazelrc`, scoped the same way asan is and for the same rules_cc_autoconf reason. `//plugin:preset_race_test` runs `process()` on a second thread with a voice sounding while the main thread hands over presets and reads back. `//plugin:patch_format_test` is tagged `no-tsan` — 1611s under TSan against 449s under asan, single-threaded throughout, so there is nothing there to find; the tag is a claim about threads, not speed. The lane also carries `--test_timeout=180,600,1800,3600`, because `//dsp:fm_pair_layer_test` is an honest `small` that intermittently ran past 60s under instrumentation. The injection this issue specified works: with `paramsInfo` reading `MacroName(instrument_.parameters(), index)`, TSan reports it against `AdoptPatch`'s write, 20 runs in 20. **Two real defects turned up while building it, both fixed here.** `paramsValue` read `instrument_.parameters().macro[id]` — the same array `AdoptPatch` overwrites and `WriteParameter` stores into on every automation event inside `process()`. That is the race `macro_names_` exists to avoid, one field over. `macro_names_` was not the right shape to copy: a name has one writer so a main-thread copy suffices, a position has two, so `macro_values_` is an array of relaxed atomics and that is what the host is told about. `stateSave` handed the whole live `instrument_` to `WritePatch`. TSan with `halt_on_error=0` enumerated thirty-odd distinct races across layer count, layer kinds, the FM pair, the mod matrix, the LFOs, the frame curves, macros, macro names and trim. It now picks an instrument rather than a field, using `pending_ready_` — which can only go true→false while `stateSave` runs, since both it and the only store of `true` are [main-thread]. **The lane is the belt, not the braces.** Both defects were also wrong answers on one thread, in the window between a preset load the host was told succeeded and the block that installs it. Three deterministic tests in `host_smoke_test.cc` pin that window and fail in every lane; they are the gate. TSan covers what they cannot — a race that leaves the right answer behind, which an aligned 8-byte load always does. Injected detection rates are recorded in `preset_race_test.cc`'s header: 20/20 for state, 19–20/20 for names, 14/20 for values. That 14 is why it is not the gate. What the issue anticipated held up. The `pending_` single-writer guard is still not covered here and still has its deterministic test; and the note that a sanitizer lane nobody runs is worth nothing is still true — #1 stays open for the CI trigger.
bcox closed this issue 2026-08-28 23:04:38 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bcox/tymbal#2
No description provided.