Files
firmware/test/test_observer/test_main.cpp
T
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

426 lines
14 KiB
C++

// Unit tests for src/Observer.h: notification order, the nonzero-return abort chain,
// CallbackObserver dispatch, ~Observer and ~Observable auto-detach, and list mutation from inside
// onNotify.
#include "Arduino.h"
#include "Observer.h"
#include "TestUtil.h"
#include <cstdio>
#include <string>
#include <unity.h>
// Tags of observers in the order their onNotify ran, e.g. "ABC". Cleared in setUp.
static std::string callOrder;
// An observer that records its calls and can optionally mutate observer lists from inside
// onNotify - the mid-notify hazard the detach/attach-during-notify tests drive.
class RecordingObserver : public Observer<int>
{
public:
explicit RecordingObserver(char _tag) : tag(_tag) {}
char tag;
int returnCode = 0;
int calls = 0;
int lastArg = 0;
// When set, onNotify detaches detachWho from detachFrom before returning.
Observer<int> *detachWho = nullptr;
Observable<int> *detachFrom = nullptr;
// When set, onNotify attaches attachWho to attachTo before returning.
Observer<int> *attachWho = nullptr;
Observable<int> *attachTo = nullptr;
protected:
int onNotify(int arg) override
{
callOrder += tag;
calls++;
lastArg = arg;
if (detachWho && detachFrom)
detachWho->unobserve(detachFrom);
if (attachWho && attachTo)
attachWho->observe(attachTo);
return returnCode;
}
};
// Target class for the CallbackObserver member-pointer dispatch tests.
class CallbackTarget
{
public:
int calls = 0;
int lastArg = 0;
int handle(int arg)
{
calls++;
lastArg = arg;
return 0;
}
int handleAbort(int arg)
{
calls++;
lastArg = arg;
return 42;
}
};
// --- basic delivery ---
void test_notify_with_no_observers_returns_zero()
{
Observable<int> subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(99));
}
void test_notify_order_and_arg()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
TEST_ASSERT_EQUAL(0, subject.notifyObservers(42));
TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // insertion order
TEST_ASSERT_EQUAL(42, a.lastArg);
TEST_ASSERT_EQUAL(42, b.lastArg);
TEST_ASSERT_EQUAL(42, c.lastArg);
// Delivery is not one-shot: a second notify reaches everyone again.
TEST_ASSERT_EQUAL(0, subject.notifyObservers(43));
TEST_ASSERT_EQUAL_STRING("ABCABC", callOrder.c_str());
TEST_ASSERT_EQUAL(2, b.calls);
TEST_ASSERT_EQUAL(43, b.lastArg);
}
// --- abort contract ---
void test_nonzero_return_aborts_chain_and_propagates()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
b.returnCode = 7;
TEST_ASSERT_EQUAL(7, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str());
TEST_ASSERT_EQUAL(0, c.calls); // chain stopped before C
// Clearing the abort restores full delivery.
b.returnCode = 0;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str());
}
// --- CallbackObserver ---
void test_callback_observer_dispatches_member_function()
{
Observable<int> subject;
CallbackTarget target;
CallbackObserver<CallbackTarget, int> cb(&target, &CallbackTarget::handle);
cb.observe(&subject);
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1234));
TEST_ASSERT_EQUAL(1, target.calls);
TEST_ASSERT_EQUAL(1234, target.lastArg);
}
void test_callback_observer_return_code_aborts_chain()
{
Observable<int> subject;
CallbackTarget target;
CallbackObserver<CallbackTarget, int> cb(&target, &CallbackTarget::handleAbort);
RecordingObserver after('X');
cb.observe(&subject);
after.observe(&subject);
TEST_ASSERT_EQUAL(42, subject.notifyObservers(5));
TEST_ASSERT_EQUAL(1, target.calls);
TEST_ASSERT_EQUAL(0, after.calls); // callback's abort code stopped the chain
}
// --- lifecycle: destructor auto-detach ---
void test_destroyed_observer_is_not_notified()
{
Observable<int> subject;
RecordingObserver a('A'), c('C');
a.observe(&subject);
RecordingObserver *b = new RecordingObserver('B');
b->observe(&subject);
c.observe(&subject);
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str());
delete b; // ~Observer must remove it from the observable's list
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2)); // ASan-clean: no dangling pointer left behind
TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str());
}
void test_observer_watching_two_observables_detaches_from_both()
{
Observable<int> subject1;
Observable<int> subject2;
{
RecordingObserver x('X');
x.observe(&subject1);
x.observe(&subject2); // re-target onto a second observable: both now deliver
subject1.notifyObservers(1);
subject2.notifyObservers(2);
TEST_ASSERT_EQUAL(2, x.calls);
TEST_ASSERT_EQUAL(2, x.lastArg);
} // x destroyed here - must have detached from both observables
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject1.notifyObservers(3));
TEST_ASSERT_EQUAL(0, subject2.notifyObservers(4));
TEST_ASSERT_EQUAL_STRING("", callOrder.c_str());
}
// The other half of the same contract: an Observable destroyed while it still has watchers. On the
// simulator's exit path exit_simulator exit()s from AdminModule, and static destruction takes the
// global xModem (an XModemAdapter, whose packetReady is an Observable) before the heap-allocated
// ServerAPI that PhoneAPI::handleStartConfig attached to it. PhoneAPI::close() then calls
// unobserve(&xModem.packetReady) on the dead observable, and ~Observer would walk it afterwards.
// AddressSanitizer reported both as heap-use-after-free in Observable::removeObserver.
//
// The regression guarded: ~Observable not clearing its watchers' back-pointers, or unobserve()
// dereferencing an observable this observer does not hold. Both observables are heap-allocated so
// that the reads are on freed memory and ASan sees them; a stack object would merely be stale.
void test_unobserve_after_the_observable_is_destroyed_is_noop()
{
auto *dead = new Observable<int>();
RecordingObserver a('A');
a.observe(dead);
delete dead; // ~Observable must drop itself from a's list
a.unobserve(dead); // the exit-path call: a no-op, never a read through the freed observable
TEST_ASSERT_EQUAL(0, a.calls);
}
void test_destroyed_observable_leaves_no_dangling_pointer_in_the_observer()
{
auto *dead = new Observable<int>();
Observable<int> alive;
{
RecordingObserver a('A');
a.observe(dead);
a.observe(&alive);
delete dead;
// Still attached to the survivor: ~Observable cleared only its own entry.
TEST_ASSERT_EQUAL(0, alive.notifyObservers(1));
TEST_ASSERT_EQUAL(1, a.calls);
} // ~Observer here - must not walk the freed observable
callOrder.clear();
TEST_ASSERT_EQUAL(0, alive.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("", callOrder.c_str());
}
// --- duplicate observe / unobserve semantics ---
void test_duplicate_observe_delivers_twice_and_unobserve_removes_all()
{
Observable<int> subject;
RecordingObserver a('A');
a.observe(&subject);
a.observe(&subject); // current semantics: second observe means double delivery
TEST_ASSERT_EQUAL(0, subject.notifyObservers(9));
TEST_ASSERT_EQUAL_STRING("AA", callOrder.c_str());
TEST_ASSERT_EQUAL(2, a.calls);
// One unobserve removes every entry (std::list::remove semantics), not just one.
a.unobserve(&subject);
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(10));
TEST_ASSERT_EQUAL_STRING("", callOrder.c_str());
TEST_ASSERT_EQUAL(2, a.calls);
}
void test_unobserve_of_never_observed_observable_is_noop()
{
Observable<int> subject;
RecordingObserver a('A'), stranger('S');
a.observe(&subject);
stranger.unobserve(&subject); // never attached: must be a safe no-op
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("A", callOrder.c_str());
TEST_ASSERT_EQUAL(0, stranger.calls);
}
// --- list mutation from inside onNotify (the safe cases) ---
void test_detach_of_earlier_observer_during_notify()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
b.detachWho = &a; // B removes already-visited A mid-notify
b.detachFrom = &subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("ABC", callOrder.c_str()); // A was visited before removal; C unaffected
b.detachWho = nullptr;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("BC", callOrder.c_str()); // A stays detached
}
void test_detach_of_later_observer_during_notify()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
a.detachWho = &c; // A removes not-yet-visited C mid-notify
a.detachFrom = &subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // iteration stays valid, C never called
TEST_ASSERT_EQUAL(0, c.calls);
a.detachWho = nullptr;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str());
}
// Tightest safe case: removing the node the iterator will step to next. std::list relinks A's
// next pointer when B's node is erased, so ++iterator lands on C.
void test_detach_of_immediately_next_observer_during_notify()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
a.detachWho = &b;
a.detachFrom = &subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str());
TEST_ASSERT_EQUAL(0, b.calls);
a.detachWho = nullptr;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str());
}
// Self-detach is only safe when the observer also aborts the chain: returning nonzero exits
// before the iterator is advanced past the node unobserve() just erased. PhoneAPI is the one
// observer in the tree that does this (onNotify -> checkConnectionTimeout -> close() ->
// unobserve, returning -1), and its -1 is load-bearing, not incidental. A self-detaching
// observer that returned 0 would walk a freed node - not covered here, because asserting that
// would be asserting UB; notifyObservers() has to be hardened before it can be tested.
void test_self_detach_with_abort_during_notify()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
b.detachWho = &b;
b.detachFrom = &subject;
b.returnCode = -1;
TEST_ASSERT_EQUAL(-1, subject.notifyObservers(1));
TEST_ASSERT_EQUAL_STRING("AB", callOrder.c_str()); // C never runs: the chain aborted
TEST_ASSERT_EQUAL(0, c.calls);
b.detachWho = nullptr;
b.returnCode = 0;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("AC", callOrder.c_str());
}
void test_attach_during_notify_is_safe_and_delivers_next_time()
{
Observable<int> subject;
RecordingObserver a('A'), b('B'), c('C'), d('D');
a.observe(&subject);
b.observe(&subject);
c.observe(&subject);
a.attachWho = &d; // A appends D mid-notify (push_back never invalidates list iterators)
a.attachTo = &subject;
TEST_ASSERT_EQUAL(0, subject.notifyObservers(1));
// The pre-existing observers all ran, in order. Whether the same pass also reaches the
// freshly appended D is deliberately not asserted - a hardened notifyObservers that
// snapshots the list would legitimately change that, and it should not go red for it.
TEST_ASSERT_EQUAL_STRING("ABC", callOrder.substr(0, 3).c_str());
a.attachWho = nullptr;
callOrder.clear();
TEST_ASSERT_EQUAL(0, subject.notifyObservers(2));
TEST_ASSERT_EQUAL_STRING("ABCD", callOrder.c_str()); // D is a full participant from now on
}
// --- Unity lifecycle ---
void setUp(void)
{
callOrder.clear();
}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== Basic delivery ===\n");
RUN_TEST(test_notify_with_no_observers_returns_zero);
RUN_TEST(test_notify_order_and_arg);
printf("\n=== Abort contract ===\n");
RUN_TEST(test_nonzero_return_aborts_chain_and_propagates);
printf("\n=== CallbackObserver ===\n");
RUN_TEST(test_callback_observer_dispatches_member_function);
RUN_TEST(test_callback_observer_return_code_aborts_chain);
printf("\n=== Lifecycle ===\n");
RUN_TEST(test_destroyed_observer_is_not_notified);
RUN_TEST(test_observer_watching_two_observables_detaches_from_both);
RUN_TEST(test_unobserve_after_the_observable_is_destroyed_is_noop);
RUN_TEST(test_destroyed_observable_leaves_no_dangling_pointer_in_the_observer);
printf("\n=== Duplicate observe / unobserve ===\n");
RUN_TEST(test_duplicate_observe_delivers_twice_and_unobserve_removes_all);
RUN_TEST(test_unobserve_of_never_observed_observable_is_noop);
printf("\n=== Mutation during notify (safe cases) ===\n");
RUN_TEST(test_detach_of_earlier_observer_during_notify);
RUN_TEST(test_detach_of_later_observer_during_notify);
RUN_TEST(test_detach_of_immediately_next_observer_during_notify);
RUN_TEST(test_self_detach_with_abort_during_notify);
RUN_TEST(test_attach_during_notify_is_safe_and_delivers_next_time);
exit(UNITY_END());
}
void loop() {}