test(cwx): fix teardown heap-use-after-free in drift-test Fixture (#3740)#3799
Conversation
…thersdr#3740) The weekly ASan/UBSan job trips a heap-use-after-free in cwx_local_keyer_drift_test at Fixture teardown. Members destruct in reverse declaration order, so with `keyer` declared before `states`, `states` is freed first. When a test leaves the keyer key-down (e.g. testLateTicksShortenNextWait, which starts "S", ticks twice, and never drains or stops), ~CwxLocalKeyer -> keyUpIfDown() -> emitKeyDown(false) fires the installed callback, which writes into the already-freed `states` vector. Reorder the two members so `states` is declared first and therefore destroyed last, outliving `keyer`. The keyer's destructor callback now writes into a live vector. This is robust against any future test that leaves the keyer keyed-down at teardown. Test-only; no production code change. Production already orders this safely (MainWindow resets the keyer before the audio sink, and the sidetone callback is null-guarded), so no runtime UAF is reachable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed against the source — this is a clean, correct fix. ✅
Verified the root-cause analysis holds:
- The current fixture (
cwx_local_keyer_drift_test.cpp:91-92) declareskeyerbeforestates, so reverse-order destruction freesstatesfirst whilekeyerstill lives — exactly the lifetime inversion described. - The production teardown chain is real:
~CwxLocalKeyer()(CwxLocalKeyer.cpp:48) →keyUpIfDown()(:235) →emitKeyDown(false)(:229) →m_onKeyDownChange, which is the fixture'sstates.push_back(...). With the buggy order that write lands in freed memory. emitKeyDown'sif (down == m_lastEmittedKeyDown) return;guard confirms the claim that only a test ending key-down trips it, andtestLateTicksShortenNextWaitdoes exactly that — starts"S", ticks twice, never drains or stops — so the keyer is still keyed at teardown. Matches the ASan trace.
On the fix itself:
- Reordering so
states(declared first → destroyed last) outliveskeyeris the right, minimal correction, and it's robust against any future test that leaves the keyer keyed-down rather than special-casing the one that does today. - Constructing
statesbeforekeyeris also safe on the construction side — the ctor body runs after both members exist, sosetOnKeyDownChangestill wires up correctly. - The load-bearing-order comment is genuinely valuable here; member-ordering bugs like this are easy to silently reintroduce, and the comment names the failure mode and the issue.
Test-only change, no production behavior affected, and the "why production is unaffected" reasoning (keyer reset before the sink, null-guarded sidetone callback) is consistent with the lifetime argument.
Thanks for the thorough writeup and the deterministic before/after repro, @jensenpat — nicely scoped fix.
🤖 aethersdr-agent · cost: $4.0867 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Correct, minimal fix for the member-destruction-order heap-UAF: reordering so states (declared first) outlives keyer (declared last) means the keyer dtor's keyUpIfDown → emitKeyDown → callback → states.push_back writes into a live vector. The load-bearing-order comment guards against re-regression. Test-only, no production surface (MainWindow resets the keyer before the sink + null-guarded callback), ASan trace reproduced frame-for-frame and the fixed test passes clean under -fsanitize=address. Thanks @jensenpat.
Summary
Fixes the weekly ASan/UBSan job failure in
cwx_local_keyer_drift_test(#3740): a heap-use-after-free atFixtureteardown.This is a test-only lifetime bug — no production code change.
Root cause
Fixturemembers destruct in reverse declaration order. Withkeyerdeclared beforestates,statesis freed first:When a test leaves the keyer key-down at teardown, the keyer's destructor still fires the callback into the already-freed vector:
~CwxLocalKeyer()(CwxLocalKeyer.cpp:48) →keyUpIfDown()(:235) →emitKeyDown(false)(:229) → installed callback →states.push_back(0)into freed memory.emitKeyDownearly-returns when the requested state already matchesm_lastEmittedKeyDown, so only a test that ends key-down trips it.testLateTicksShortenNextWaitstarts"S", ticks twice, and never drains or stops — leaving the key down — which is exactly the case the ASan trace points at.Fix
Reorder the two members so
statesis declared first (destroyed last) and outliveskeyer. The destructor callback now writes into a live vector. Robust against any future test that leaves the keyer keyed-down.Why production is unaffected
MainWindowresets the keyer before the audio sink it calls into.So no runtime UAF is reachable — this only broke the sanitizer job (
priority: low).Proof — agent automation bridge N/A; this is a sanitizer/unit-test bug
The automation bridge drives the running app and can't observe a unit-test teardown UAF, so the proof here is a direct before → after ASan run. The keyer's worker thread makes the live test's key-up/key-down state at teardown timing-dependent (hence a weekly flake); to make the proof deterministic I replicated the exact synchronous
TestKeyer(spawnWorker=false)path and thetestLateTicksShortenNextWaitsequence (which leavesstatesatsize=3, cap=4— matching the CI trace's "12 bytes inside of 16-byte region"):Before (buggy
states-freed-first order) — reproduces the CI stack frame-for-frame:After (fixed
states-outlives-keyerorder) — clean:Real test, fixed, built with
-fsanitize=address:54 OK, 0 FAIL, no ASan report,All tests passed.Related
good first issue,sanitizer,asan-ubsan,CW.Fixes #3740
💻 Generated with Claude Code (Opus 4.8) with architecture by @jensenpat