Files
Jonathan BennettandClaude Opus 5 329b7ea918 Fail the native simulator job on a sanitizer fault, and fix the one it found (#12205)
* Fail the native simulator job on a sanitizer fault, and fix the one it found

The integration step backgrounded meshtasticd and ended in `wait "$PID"
2>/dev/null || true`, which discards the simulator's exit status. The coverage
env is built with -fsanitize=address, so a fault there prints a full report and
exits non-zero on the way out, after the Python harness has already printed its
own success - and the job concluded green with an AddressSanitizer
heap-use-after-free sitting in the log.

Gate it: capture the simulator's output, read the status `wait` reports, and
scan the output for a sanitizer report. The pattern moves to bin/lib/sanitizer.sh
so bin/run-tests.sh and the new bin/check-sanitizer-log.sh share one matcher
rather than drifting apart; the output is uploaded as an artifact, since a fault
on the exit path prints after the last test has passed. Leak detection is off,
as in bin/test-config-check.sh - exit_simulator exit()s with the node's globals
still live.

The fault it had been hiding: OSThread::run() points currentThread at the
running thread for the whole of runOnce(), and exit_simulator calls exit(0) from
AdminModule while ServerAPI is that thread. ServerAPI is declared
`public StreamAPI, private OSThread`, so its Thread base frees ThreadName first
and PhoneAPI::~PhoneAPI's own LOG_DEBUG then reads it back through
currentThread. Clear the pointer in ~OSThread when it names the thread being
destroyed, which covers any thread deleted from inside its own runOnce, not
just this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw

* Detach observers when an Observable is destroyed

The new sanitizer gate turned the simulator job red on a second
heap-use-after-free on the same exit path, this one in
Observable::removeObserver.

~Observer removes itself from every observable it watches, but Observable has
no destructor and dies with its watchers still pointing at it. exit_simulator
exit()s from AdminModule, and static destruction takes the global xModem - whose
packetReady is an Observable that PhoneAPI::handleStartConfig attached to -
before the heap-allocated ServerAPI that is watching it. PhoneAPI::close() then
calls unobserve(&xModem.packetReady) on the dead observable, and ~Observer would
walk it again afterwards.

Both halves of the contract, so neither side has to outlive the other:
~Observable drops itself from each watcher's list, and unobserve() checks
membership before reading through the pointer it is given.

Also take CodeRabbit's grep -m 4 in bin/check-sanitizer-log.sh: with more than
four matching lines, head closing the pipe is a SIGPIPE that pipefail reports
and set -e exits on, so the script gave 141 where it documents 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 14:07:34 +00:00

107 lines
3.7 KiB
C++

// Unit tests for OSThread::currentThread's lifetime
// (src/concurrency/OSThread.cpp).
//
// currentThread is the only attribution a log line carries:
// RedirectablePrint::log reads currentThread->ThreadName and prints it as the
// "[Tag] " prefix. OSThread::run() points it at the running thread for the
// whole of runOnce() and clears it afterwards, so during a runOnce() the
// pointer is a borrowed reference to an object that runOnce() is itself allowed
// to destroy - and the simulator does: exit_simulator calls exit(0) from
// AdminModule while ServerAPI is the running thread, which runs ServerAPI's
// destructor with currentThread still pointing at it.
//
// The regression guarded: a destroyed thread left reachable through
// currentThread. Bases are destroyed in reverse declaration order, so for
// `ServerAPI : public StreamAPI, private OSThread` the Thread base frees
// ThreadName first and PhoneAPI::~PhoneAPI's own LOG_DEBUG then reads it.
// AddressSanitizer caught that as a heap-use-after-free on the native
// simulator's exit path, in a CI run that still concluded green. The destructor
// has to drop the pointer along with the name it refers to; the log call after
// the delete below is the read that faulted, so a regression reproduces here
// under the ASan-linked coverage env and not only in the simulator.
#include "configuration.h"
#include "TestUtil.h"
#include "concurrency/OSThread.h"
#include <unity.h>
namespace
{
class ProbeThread : public concurrency::OSThread
{
public:
ProbeThread() : OSThread("ProbeThread") {}
const concurrency::OSThread *seenWhileRunning = nullptr;
void runNow() { run(); }
protected:
int32_t runOnce() override
{
seenWhileRunning = currentThread;
return 1000;
}
};
} // namespace
void setUp(void)
{
concurrency::OSThread::currentThread = nullptr;
}
void tearDown(void)
{
concurrency::OSThread::currentThread = nullptr;
}
// The mechanism the destructor has to undo: inside runOnce() the pointer is
// this thread, and run() clears it on the way out, so a thread that is not
// running is never the current one.
static void test_run_points_currentThread_at_the_running_thread()
{
ProbeThread probe;
probe.runNow();
TEST_ASSERT_EQUAL_PTR(&probe, probe.seenWhileRunning);
TEST_ASSERT_NULL(concurrency::OSThread::currentThread);
}
// Destroying the running thread - what exit_simulator does from inside
// ServerAPI's runOnce - must leave nothing behind to read. The LOG_DEBUG is
// that read: it goes through RedirectablePrint::log, which dereferences
// currentThread for the tag.
static void test_destroying_the_running_thread_clears_currentThread()
{
auto *probe = new ProbeThread();
concurrency::OSThread::currentThread = probe; // as run() has it, mid-runOnce()
delete probe;
TEST_ASSERT_NULL(concurrency::OSThread::currentThread);
LOG_DEBUG("logging after the running thread was destroyed");
}
// A thread destroyed while some *other* thread is running must not clear the
// pointer: that would drop the running thread's tag from every later log line
// in the same runOnce().
static void test_destroying_another_thread_leaves_currentThread_alone()
{
ProbeThread running;
auto *other = new ProbeThread();
concurrency::OSThread::currentThread = &running;
delete other;
TEST_ASSERT_EQUAL_PTR(&running, concurrency::OSThread::currentThread);
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_run_points_currentThread_at_the_running_thread);
RUN_TEST(test_destroying_the_running_thread_clears_currentThread);
RUN_TEST(test_destroying_another_thread_leaves_currentThread_alone);
exit(UNITY_END());
}
void loop() {}