Skip to content

Audit & hardening: fix memory-safety/UB defects, add tests, sanitizers & static analysis to CI - #1

Merged
tap merged 5 commits into
masterfrom
claude/rtaudio-fork-audit-7cnqfu
Jun 23, 2026
Merged

Audit & hardening: fix memory-safety/UB defects, add tests, sanitizers & static analysis to CI#1
tap merged 5 commits into
masterfrom
claude/rtaudio-fork-audit-7cnqfu

Conversation

@tap

@tap tap commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Overview

A memory-safety / correctness / portability audit of RtAudio and a first pass of fixes, plus the CI/test infrastructure to keep the library hardened going forward. Full findings and fix status are tracked in AUDIT.md.

All 16 CI jobs pass on this branch, including the new ones. Portable fixes were verified locally under -fsanitize=address,undefined; backend fixes are compile-verified across the platform matrix (CoreAudio on macOS, ALSA/Pulse/JACK/OSS on Linux, WASAPI/ASIO/DirectSound via MinGW) but, lacking the target hardware, were not runtime-tested — please review the backend changes with that in mind.

Phase 0 — test & analysis infrastructure

  • tests/unittest.cpp: a dependency-free unit test (runs under the dummy API on every platform), wired into the CMake, autotools and meson test suites. Previously only apinames was an actual test. Covers the C++/C API-name lookup tables and the C-API error-message copy (including the regression case for the overflow fixed below).
  • CI (.github/workflows/ci.yml):
    • Sanitizers — builds with ASan/UBSan and runs the test suite.
    • StrictWarnings — builds the portable config with -Wall -Wextra -Wpedantic -Werror on GCC and Clang.
    • StaticAnalysis — runs clang-tidy (.clang-tidy) and cppcheck (informational for now while the mature backends still have findings).
  • CMake: -Wextra enabled on GCC/Clang; new opt-in RTAUDIO_WARNINGS_AS_ERRORS. The previous behaviour of silently injecting -Werror into Debug builds was removed so downstream Debug builds aren't broken by new warnings.

Phase 1 — confirmed safety/correctness fixes

  • 🔴 C-API buffer overflow (rtaudio_c.cpp): strncpy(errmsg, text.c_str(), text.size()-1) overran the 512-byte buffer on long messages and underflowed to SIZE_MAX on empty ones (AddressSanitizer: negative-size-param), and didn't terminate on truncation. Now routed through a bounded, always-terminating helper (rtaudio_c_private.h) with a regression test.
  • 🟠 UB in convertBuffer: negative-signed left-shifts (pre-C++20 UB) now shift through an unsigned type (value-preserving). Plus a VLA (MB_CUR_MAXMB_LEN_MAX) and an ifelse if bug in the SINT8 output chain.
  • High-confidence backend fixes: CoreAudio closeStream NULL-deref and probeDeviceInfo unchecked malloc/CFStringGetCString; JACK jack_get_ports NULL-deref; PulseAudio handle/stream leak on open failure.

Phase 2 — realtime-thread & leak defects (compile-verified only)

  • 🔴 CoreAudio disconnect listener no longer calls closeStream() on the property-listener thread (deadlock-prone + races the audio callback); it now spawns a detached teardown thread, mirroring JACK.
  • 🔴 WASAPI resampler now checks its COM creation (was a NULL deref on the audio thread if the MFT is unavailable), guards the destructor, exposes isValid(), and Convert() clamps its output copy to the caller's buffer (overrun fix).
  • 🟠 WASAPI SPSC ring-buffer indices are now std::atomic (were a plain-int data race).
  • 🔴 DirectSound device/buffer COM objects no longer leak on open failures that occur before they're stored in the handle.

Not included (deferred — see AUDIT.md)

Behavior-changing items that genuinely need on-hardware validation: making stream_.state atomic across all backends (~180 sites), the JACK duplex-input error path / jackXrun-after-free, and the ASIO process-global state redesign.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt


Generated by Claude Code

claude added 5 commits June 22, 2026 21:57
This addresses three defects in the platform-independent code, all
verified locally (the core builds clean under -Wall -Wextra -Wpedantic
and the new tests pass under -fsanitize=address,undefined):

* C API buffer overflow (rtaudio_c.cpp): the error-callback used
  strncpy(errmsg, text.c_str(), text.size() - 1). The length was taken
  from the source string, so a message longer than the 512-byte buffer
  overran it, and an empty message made size()-1 wrap to SIZE_MAX
  (AddressSanitizer reports negative-size-param). The buffer was also
  left unterminated on truncation. The copy now goes through a bounded,
  always-NUL-terminating helper (rtaudio_c_private.h).

* Undefined behavior in convertBuffer (RtAudio.cpp): several integer
  format conversions left-shifted negative signed values
  (e.g. `out <<= 24`, `(Int32)(in << 16)`), which is UB before C++20.
  These now shift through an unsigned type, which is value-preserving
  for the existing non-negative cases and well-defined for negatives.

* VLA in convertCharPointerToStdString (RtAudio.cpp): `char dest[MB_CUR_MAX]`
  is a variable-length array because MB_CUR_MAX is a runtime expression
  in glibc. Replaced with the compile-time constant MB_LEN_MAX.

Also fixes a stray `if` (should be `else if`) in the SINT8 output
conversion chain.

New tests/unittest.cpp is a dependency-free test (runs under the dummy
API on every platform) covering the error-message copy edge cases
(empty/fit/truncate/embedded-NUL/degenerate buffers) and the C++ and C
API-name lookup tables, wired into the CMake, autotools and meson test
suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt
Establishes the missing analysis infrastructure on top of the existing
build matrix:

* Sanitizers job: Debug build with -fsanitize=address,undefined and the
  test suite run under ctest. Catches leaks and UB on the portable paths
  (the registered tests need no audio device).

* StrictWarnings job: builds the API-independent (dummy) configuration
  with -Wall -Wextra -Wpedantic -Werror on both GCC and Clang, locking
  in a warning-clean, well-defined portable core. Verified to pass
  locally for the library and all example programs.

* StaticAnalysis job: runs clang-tidy (driven by a curated .clang-tidy
  with high-signal correctness/safety checks) and cppcheck. The mature
  backends still have outstanding findings, so this job is informational
  (continue-on-error) for now while the output is surfaced in the logs.

Build-system changes:
* CMake now enables -Wall -Wextra on GCC/Clang and adds an opt-in
  RTAUDIO_WARNINGS_AS_ERRORS option (used by CI). The previous behaviour
  of silently adding -Werror in Debug builds is removed so downstream
  Debug builds are not broken by new warnings; -Werror is now explicit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt
These address clear-cut, unambiguous defects on error/teardown paths.
They are gated behind platform #ifdefs and are compile-verified by the
corresponding CI jobs (CoreAudio on macOS, JACK/PulseAudio on Linux);
the portable build remains warning-clean and passes its tests.

* CoreAudio closeStream(): the condition-variable teardown
  (pthread_cond_signal/destroy + delete handle) ran unconditionally
  while every other use of `handle` in the function is guarded by
  `if (handle)`. Reached via probeDeviceOpen()'s error path before
  apiHandle is set, this dereferenced a NULL handle. Now guarded.

* JACK probeDeviceOpen(): jack_get_ports() returns NULL when no ports
  match, but the result was indexed (ports[firstChannel]) without a
  NULL check. Now checks `ports` first.

* PulseAudio probeDeviceOpen(): the freshly allocated handle leaked on
  almost every open failure. An inner `PulseAudioHandle *pah` shadowed
  the function-scope pointer, and the error label only freed when
  callbackInfo.isRunning was true, which is never the case at that
  point. The handle is now published only once fully constructed, and
  the error path frees the handle (including its pa_simple streams and
  condition variable) when this call allocated it, while leaving a
  handle shared with an already-open direction for closeStream() to own.

* CoreAudio probeDeviceInfo(): malloc() results were used without a NULL
  check and CFStringGetCString() failures left the buffer uninitialized
  before strlen(). Both name lookups now check the allocation and
  pre-terminate the buffer so a failed conversion yields an empty string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt
Records the full memory-safety/correctness/portability audit, the
Phase 0 tooling/CI additions, the Phase 1 fixes, and the higher-risk
findings deliberately deferred for on-hardware validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt
Backend changes (compile-verified by the macOS and MinGW CI jobs; not
runtime-tested here for lack of the target hardware). The portable build
stays warning-clean and its tests pass.

* CoreAudio streamDisconnectListener() no longer calls closeStream()
  directly on the property-listener thread, which removes property
  listeners from within a listener (deadlock-prone) and frees state the
  audio callback is using. It now spawns a detached teardown thread,
  mirroring the JACK disconnect path.

* WASAPI WasapiResampler constructor checked no COM HRESULTs, so an
  unavailable resampler MFT produced a NULL interface that was
  dereferenced on the realtime audio thread. The interface pointers are
  now guarded, an isValid() accessor is added, the destructor is
  guarded, and Convert() falls back to a passthrough copy when the
  transform is absent.

* WASAPI WasapiResampler::Convert() now clamps the resampler output copy
  to the caller's output buffer capacity, preventing an overrun when the
  resampler emits more frames than requested at chunk boundaries.

* WASAPI ring-buffer indices (inIndex_/outIndex_) are now
  std::atomic<unsigned int>; they are written by one thread and read by
  the other, so the previous plain ints were a data race.

* DirectSound probeDeviceOpen() leaked the IDirectSound(Capture) device
  and its buffer on open failures occurring after the objects were
  created but before they were stored in the handle (the error path only
  released via the handle). The error path now releases the still-owned
  objects, and they are cleared once ownership transfers to the handle.

AUDIT.md updated to move these items to fixed and note the remaining
deferred work (notably making stream_.state atomic across all backends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHbT36gMxx7WH2tzZfiqUt
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