Skip to content

Commit 9623d3e

Browse files
dkulpclaude
andcommitted
fix(outputmonitor): don't let the smart-receiver callback outlive its owner
SetOutput() copied srCallback out from under gpioLock and invoked the copy after releasing it. The callback is installed by FalconV5Support and captures its raw 'this', so the copy carries no ownership: a string-config reload destroying that object between the copy and the call left the dispatch writing into freed memory. The destructor's unregister could not prevent it -- it can only clear the member, never a copy already on another thread's stack. The window is reachable because "Set Port Status" arrives on the API, MQTT, GPIO, and scheduler threads while the reload runs elsewhere. Invoke the callback through the member under a dedicated mutex that setSmartReceiverEventCallback() also takes. The unregister then blocks behind any in-flight call and no call can start after it returns, so no copy escapes. Kept separate from gpioLock so dispatch still happens with the port state unlocked and can't re-enter into a self-deadlock. The state the callback publishes had the same problem one level down: the port, receiver index, and command string were three unsynchronized fields written from the command thread and read by the packet generator on the output thread. As well as pairing a new port with a stale command, assigning and comparing the std::string concurrently is a torn read of a heap pointer. Replace them with a single lock-free atomic word, snapshotted once per pass and cleared with a compare_exchange so a request arriving mid-pass isn't dropped. static_asserts pin the two properties that relies on: no padding (the exchange compares the object representation) and lock-freedom, which is what keeps the 32-bit build off a libatomic lock. Builds clean for 32-bit and 64-bit BeagleBone platforms; the padding assert was confirmed to fail when deliberately broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e8bce1a commit 9623d3e

4 files changed

Lines changed: 77 additions & 22 deletions

File tree

src/OutputMonitor.cpp

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ void OutputMonitor::DisableOutputs() {
516516
}
517517

518518
void OutputMonitor::SetOutput(const std::string& port, bool on) {
519-
// Smart-receiver commands are collected under the lock and dispatched
519+
// Smart-receiver commands are collected under gpioLock and dispatched
520520
// after it is released: srCallback lands in FalconV5Support, and calling
521521
// out of the monitor with gpioLock held invites re-entry deadlocks.
522522
struct SRAction {
@@ -525,7 +525,6 @@ void OutputMonitor::SetOutput(const std::string& port, bool on) {
525525
const char* cmd;
526526
};
527527
std::vector<SRAction> srActions;
528-
std::function<void(int, int, const std::string&)> cb;
529528
{
530529
std::unique_lock<std::shared_mutex> lock(gpioLock);
531530
int pn = 0;
@@ -551,16 +550,22 @@ void OutputMonitor::SetOutput(const std::string& port, bool on) {
551550
}
552551
pn++;
553552
}
554-
cb = srCallback;
555553
}
556-
if (cb) {
557-
for (auto& a : srActions) {
558-
cb(a.port, a.index, a.cmd);
554+
if (!srActions.empty()) {
555+
// Call through the member under srCallbackLock rather than copying it
556+
// out first: a copy captures FalconV5Support's raw 'this' without
557+
// owning it, so a config reload destroying that object between the
558+
// copy and the call leaves us writing into freed memory.
559+
std::unique_lock<std::mutex> cbLock(srCallbackLock);
560+
if (srCallback) {
561+
for (auto& a : srActions) {
562+
srCallback(a.port, a.index, a.cmd);
563+
}
559564
}
560565
}
561566
}
562567
void OutputMonitor::setSmartReceiverEventCallback(std::function<void(int port, int index, const std::string& cmd)>&& f) {
563-
std::unique_lock<std::shared_mutex> lock(gpioLock);
568+
std::unique_lock<std::mutex> lock(srCallbackLock);
564569
srCallback = std::move(f);
565570
}
566571
void OutputMonitor::RemovePortConfiguration(int port, const Json::Value& config) {

src/OutputMonitor.h

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ class OutputMonitor {
8181
// than assume the pre-output state.
8282
std::atomic<bool> outputsEnabled{ false };
8383
// Guards portPins, the pull-high/low pin lists, fusePins, eFuseRetries,
84-
// pendingEFusePresets, and srCallback. Writers (config reload, the eFuse
85-
// GPIO callbacks, retry processing, enable/disable) take it exclusive;
86-
// status readers (HTTP/MQTT port status, the pixel-count tester) take it
84+
// and pendingEFusePresets. Writers (config reload, the eFuse GPIO
85+
// callbacks, retry processing, enable/disable) take it exclusive; status
86+
// readers (HTTP/MQTT port status, the pixel-count tester) take it
8787
// shared. Presets and the smart-receiver callback must NOT be invoked
8888
// while it is held: command presets run synchronously and can re-enter
8989
// EnableOutputs/DisableOutputs/SetOutput on the same thread.
@@ -94,6 +94,18 @@ class OutputMonitor {
9494
int eFuseRetryCount = 0;
9595
int eFuseRetryInterval = 100;
9696

97+
// The smart-receiver callback is owned by a FalconV5Support that is
98+
// destroyed on a config reload while commands ("Set Port Status") are
99+
// still arriving on the API/MQTT/GPIO threads. The callback captures that
100+
// object, so it must never be copied out and invoked after the lock is
101+
// dropped -- the copy carries no ownership and the object can be freed in
102+
// between. Invoking it under this mutex, which ~FalconV5Support() also
103+
// takes to clear the callback, means the unregister blocks behind any
104+
// in-flight call and no call can start after it returns.
105+
//
106+
// Deliberately not gpioLock: dispatch has to happen with the port state
107+
// unlocked so a callback that re-enters the monitor can't self-deadlock.
108+
std::mutex srCallbackLock;
97109
std::function<void(int port, int index, const std::string& cmd)> srCallback;
98110
// Port names whose EFUSE_TRIGGERED preset still needs to fire. Queued by
99111
// addEFuseWarning() (called with gpioLock held) and drained by

src/non-gpl/FalconV5Support/FalconV5Support.cpp

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,17 @@ class PRUControl {
9292

9393
FalconV5Support::FalconV5Support() {
9494
OutputMonitor::INSTANCE.setSmartReceiverEventCallback([this](int port, int index, const std::string& cmd) {
95-
togglePort = port;
96-
toggleIndex = index;
97-
command = cmd;
95+
PendingToggle pt;
96+
pt.port = (int16_t)port;
97+
pt.index = (uint8_t)index;
98+
if (cmd == "ToggleOutput") {
99+
pt.cmd = ToggleCommand::Toggle;
100+
} else if (cmd == "ResetOutput") {
101+
pt.cmd = ToggleCommand::Reset;
102+
} else {
103+
return;
104+
}
105+
pendingToggle.store(pt);
98106
});
99107
}
100108
FalconV5Support::~FalconV5Support() {
@@ -425,6 +433,9 @@ bool FalconV5Support::generateDynamicPacket(std::vector<std::array<uint8_t, 64>>
425433
}
426434
}
427435
curCount++;
436+
// Snapshot the pending toggle once for this pass so every chain sees the
437+
// same port/index/command triple.
438+
PendingToggle toggle = pendingToggle.load();
428439
for (auto& g : queryData[curMux]) {
429440
auto rc = g.second.front();
430441
if (!rc->hasMoreQueries()) {
@@ -434,13 +445,18 @@ bool FalconV5Support::generateDynamicPacket(std::vector<std::array<uint8_t, 64>>
434445
rc = g.second.front();
435446
}
436447
int rcP = rc->getPixelStrings().front()->m_portNumber;
437-
if (rcP <= togglePort && (rcP + 4) > togglePort) {
438-
if (command == "ToggleOutput") {
439-
rc->generateToggleEFusePacket(&packets[rc->getPixelStrings().front()->m_portNumber][0], toggleIndex, togglePort % 4);
440-
} else if (command == "ResetOutput") {
441-
rc->generateResetEFusePacket(&packets[rc->getPixelStrings().front()->m_portNumber][0], toggleIndex, togglePort % 4);
448+
if (toggle.cmd != ToggleCommand::None && rcP <= toggle.port && (rcP + 4) > toggle.port) {
449+
if (toggle.cmd == ToggleCommand::Toggle) {
450+
rc->generateToggleEFusePacket(&packets[rc->getPixelStrings().front()->m_portNumber][0], toggle.index, toggle.port % 4);
451+
} else {
452+
rc->generateResetEFusePacket(&packets[rc->getPixelStrings().front()->m_portNumber][0], toggle.index, toggle.port % 4);
442453
}
443-
togglePort = -1;
454+
// Clear only the toggle we just acted on: a compare_exchange keeps
455+
// a request that arrived since the snapshot from being dropped.
456+
// 'expected' is a copy because a failed exchange writes through it.
457+
PendingToggle expected = toggle;
458+
pendingToggle.compare_exchange_strong(expected, PendingToggle());
459+
toggle = PendingToggle();
444460
} else if (triggerPixelCount) {
445461
rc->generatePixelCountPacket(&packets[rc->getPixelStrings().front()->m_portNumber][0]);
446462
listen = false;

src/non-gpl/FalconV5Support/FalconV5Support.h

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
* personal use, but modified copies MAY NOT be redistributed in any form.
1313
*/
1414

15+
#include <atomic>
16+
#include <cstdint>
1517
#include <functional>
1618
#include "fpp-json-fwd.h"
1719
#include <list>
@@ -85,7 +87,27 @@ class FalconV5Support {
8587
int curCount = 0;
8688
bool triggerPixelCount = false;
8789

88-
int togglePort = -1;
89-
int toggleIndex = -1;
90-
std::string command;
90+
// A pending smart-receiver eFuse action. Published by the OutputMonitor
91+
// callback (API/MQTT/GPIO/scheduler threads) and consumed by
92+
// generateDynamicPacket() on the output thread, so it has to be a single
93+
// atomic word: as three separate fields the consumer could pair a new
94+
// port with the previous command, and the std::string it replaces was
95+
// being assigned and compared concurrently, which is a torn read of a
96+
// heap pointer. Kept to 4 bytes so the atomic is lock-free on 32-bit ARM
97+
// as well.
98+
enum class ToggleCommand : uint8_t {
99+
None = 0,
100+
Toggle,
101+
Reset
102+
};
103+
struct PendingToggle {
104+
int16_t port = -1;
105+
uint8_t index = 0;
106+
ToggleCommand cmd = ToggleCommand::None;
107+
};
108+
// compare_exchange compares the object representation, so padding here
109+
// would make the exchange in generateDynamicPacket() fail at random.
110+
static_assert(sizeof(PendingToggle) == 4, "PendingToggle must be a single unpadded word");
111+
static_assert(std::atomic<PendingToggle>::is_always_lock_free, "PendingToggle atomic must be lock-free");
112+
std::atomic<PendingToggle> pendingToggle{ PendingToggle() };
91113
};

0 commit comments

Comments
 (0)