Skip to content

Commit ad5e8a5

Browse files
dkulpclaude
andcommitted
fix(channeloutput): make the threaded-output worker handoff race-free
Three interlocking fixes to ThreadedChannelOutput: - Init no longer hangs when the worker thread cannot be created. The catch block used to fall into 'while (!m_threadIsRunning)' — a flag only the never-started thread could set — wedging startup or a config reload forever. StartOutputThread now reports failure, the wait is bounded by m_runThread, and Init() fails the output so the loader rejects it with its normal warning instead of hanging. - The frame handoff had a lost-wakeup: SendData raised m_dataWaiting and notified without holding m_sendLock, against a predicate-less wait, so a notify landing between the worker's check and its wait was dropped. With 12 of 14 subclasses using an untimed wait, the frame sat until the next one arrived. The flags are now owned by m_sendLock and both waits take a predicate. (UDMX/USBDMX previously masked this with their 250ms timeout and now send on the notify.) - A pending frame is flushed before the worker honors a stop. The old loop checked m_runThread before m_dataWaiting, so the final blanking frame — which StopOutputThread follows immediately — was dropped deterministically and the lights stayed lit. SendOutputBuffer also claims the frame before copying it out; clearing after the copy would wipe the flag of a frame that landed mid-send, which is fatal for a blanking frame with no successor. m_bufLock now guards exactly the buffer contents: the worker no longer takes it to read flags, and it is no longer held across join() — with the exit flush that hold would deadlock every double-buffered output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent efe2a4c commit ad5e8a5

2 files changed

Lines changed: 107 additions & 65 deletions

File tree

src/channeloutput/ThreadedChannelOutput.cpp

Lines changed: 99 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,19 @@ int ThreadedChannelOutput::Init(void) {
5858
m_inBuf = new unsigned char[m_channelCount];
5959
m_outBuf = new unsigned char[m_channelCount];
6060
}
61-
StartOutputThread();
61+
if (!StartOutputThread()) {
62+
// Nothing can consume the buffers without a worker and the caller
63+
// discards an output whose Init() failed, so release them here.
64+
// Close() may still run on the way out; nulling keeps its delete[]
65+
// a no-op rather than a double free.
66+
if (m_useDoubleBuffer) {
67+
delete[] m_inBuf;
68+
delete[] m_outBuf;
69+
m_inBuf = nullptr;
70+
m_outBuf = nullptr;
71+
}
72+
return 0;
73+
}
6274
DumpConfig();
6375

6476
return 1;
@@ -87,9 +99,19 @@ int ThreadedChannelOutput::SendData(unsigned char* channelData) {
8799
if (m_useDoubleBuffer) {
88100
std::lock_guard<std::mutex> lock(m_bufLock);
89101
memcpy(m_inBuf, channelData, m_channelCount);
90-
m_dataWaiting = 1;
91102
} else {
92103
m_outBuf = channelData;
104+
}
105+
106+
// The flag has to be raised under m_sendLock: a store outside it can land
107+
// between the worker's predicate check and its wait(), and the notify that
108+
// follows is then delivered to nobody. The two locks are taken in
109+
// sequence, never nested, so no ordering against the worker exists to
110+
// violate. Publishing m_outBuf/m_inBuf before the lock is enough for the
111+
// worker to see them - it reads them only after acquiring m_sendLock and
112+
// observing the flag.
113+
{
114+
std::lock_guard<std::mutex> lock(m_sendLock);
93115
m_dataWaiting = 1;
94116
}
95117

@@ -100,12 +122,20 @@ int ThreadedChannelOutput::SendData(unsigned char* channelData) {
100122
int ThreadedChannelOutput::SendOutputBuffer(void) {
101123
LogExcess(VB_CHANNELOUT, "ChannelOutput::SendOutputBuffer()\n");
102124

125+
// Claim the pending frame BEFORE copying it out. Clearing after the copy
126+
// opens a window where a SendData landing between the copy and the clear
127+
// has its flag wiped and its frame is never sent - fatal for a final
128+
// blanking frame, which has no successor. Claim-first means a frame
129+
// arriving mid-send leaves the flag raised and the newer data goes out on
130+
// the worker's next pass (at worst the newest frame is sent twice).
131+
{
132+
std::lock_guard<std::mutex> lock(m_sendLock);
133+
m_dataWaiting = 0;
134+
}
135+
103136
if (m_useDoubleBuffer) {
104137
std::lock_guard<std::mutex> lock(m_bufLock);
105138
memcpy(m_outBuf, m_inBuf, m_channelCount);
106-
m_dataWaiting = 0;
107-
} else {
108-
m_dataWaiting = 0;
109139
}
110140

111141
RawSendData(m_outBuf);
@@ -119,7 +149,7 @@ void ThreadedChannelOutput::DumpConfig(void) {
119149
LogDebug(VB_CHANNELOUT, " Data Waiting : %u\n", m_dataWaiting);
120150
}
121151

122-
int ThreadedChannelOutput::StartOutputThread(void) {
152+
bool ThreadedChannelOutput::StartOutputThread(void) {
123153
LogDebug(VB_CHANNELOUT, "ThreadedChannelOutput::StartOutputThread()\n");
124154

125155
m_runThread = 1;
@@ -162,12 +192,18 @@ int ThreadedChannelOutput::StartOutputThread(void) {
162192
break;
163193
}
164194
LogErr(VB_CHANNELOUT, "ERROR creating ChannelOutput thread: %s\n", msg);
195+
196+
// Only OutputThread() ever raises m_threadIsRunning, so falling into
197+
// the wait below would spin forever.
198+
return false;
165199
}
166200

167-
while (!m_threadIsRunning)
201+
// m_runThread can only be 0 here if construction failed, which returns
202+
// above; the check keeps the wait bounded if that ever stops holding.
203+
while (m_runThread && !m_threadIsRunning)
168204
usleep(10000);
169205

170-
return 0;
206+
return true;
171207
}
172208

173209
int ThreadedChannelOutput::StopOutputThread(void) {
@@ -176,34 +212,38 @@ int ThreadedChannelOutput::StopOutputThread(void) {
176212
if (!m_thread.joinable())
177213
return -1;
178214

179-
m_runThread = 0;
215+
// Half of the worker's wait predicate, so it must change under m_sendLock;
216+
// a store outside the lock can be missed by a worker that is between its
217+
// predicate check and its wait(), leaving it parked until the next frame
218+
// that will never come.
219+
{
220+
std::lock_guard<std::mutex> lock(m_sendLock);
221+
m_runThread = 0;
222+
}
180223

181224
m_sendCond.notify_one();
182225

226+
// Wait up to 110ms for a pending frame to be sent. The worker flushes
227+
// m_dataWaiting before it honors the stop, so this is what lets the final
228+
// blanking frame of a sequence reach the hardware.
183229
int loops = 0;
184-
// Wait up to 110ms for data to be sent
185-
while ((m_dataWaiting) &&
186-
(m_threadIsRunning) &&
187-
(loops++ < 11))
230+
while (loops++ < 11) {
231+
{
232+
std::lock_guard<std::mutex> lock(m_sendLock);
233+
if (!m_dataWaiting || !m_threadIsRunning)
234+
break;
235+
}
188236
usleep(10000);
237+
}
189238

190-
// NOTE: preserved as-is from the pthread version. m_bufLock is held
191-
// across the join() below. This only avoids deadlock because by this
192-
// point OutputThread() is expected to be parked in
193-
// m_sendCond.wait()/wait_for() on m_sendLock (not holding m_bufLock)
194-
// per the 110ms settle loop above; OutputThread() does not need
195-
// m_bufLock again on its way out once m_runThread is 0 (it just
196-
// `continue`s straight to the top-of-loop check and exits). Do not
197-
// "fix" this ordering without auditing that invariant.
198-
std::unique_lock<std::mutex> lock(m_bufLock);
199-
200-
if (!m_thread.joinable()) {
201-
lock.unlock();
239+
if (!m_thread.joinable())
202240
return -1;
203-
}
204241

242+
// m_bufLock must NOT be held across the join(): the worker takes it while
243+
// flushing that pending frame on its way out. It guards only the contents
244+
// of m_inBuf/m_outBuf, which join() does not touch, and Close() does not
245+
// free them until after this returns.
205246
m_thread.join();
206-
lock.unlock();
207247

208248
return 0;
209249
}
@@ -214,30 +254,28 @@ void ThreadedChannelOutput::OutputThread(void) {
214254

215255
long long wakeTime = GetTime();
216256

217-
// unique_lock, not locked yet (mirrors the pthread version which did
218-
// not hold m_sendLock outside the loop body either); locked/unlocked
219-
// explicitly at the same points the old pthread_mutex_lock/unlock
220-
// calls were, rather than relying on scope-based RAII, so the
221-
// lock/unlock coverage stays a 1:1 match with the original.
257+
// Deferred: m_sendLock is held only while the predicate state is read or
258+
// written, never across SendOutputBuffer()/WaitTimedOut(), which can block
259+
// on hardware for as long as they like.
222260
std::unique_lock<std::mutex> sendLock(m_sendLock, std::defer_lock);
223261

262+
sendLock.lock();
224263
m_threadIsRunning = 1;
264+
sendLock.unlock();
225265
LogDebug(VB_CHANNELOUT, "ThreadedChannelOutput thread started\n");
226266

227-
while (m_runThread) {
267+
// m_dataWaiting and m_runThread are both owned by m_sendLock, so this
268+
// closes the window SendData's store-then-notify used to leave open.
269+
auto ready = [this]() { return m_dataWaiting || !m_runThread; };
270+
271+
while (true) {
228272
// Wait for more data
229273
sendLock.lock();
230274
long long nowTime = GetTime();
231275
LogExcess(VB_CHANNELOUT, "ThreadedChannelOutput thread: sent: %lld, elapsed: %lld\n",
232276
nowTime, nowTime - wakeTime);
233277

234-
if (m_useDoubleBuffer)
235-
m_bufLock.lock();
236-
237278
if (m_dataWaiting || m_maxWait) {
238-
if (m_useDoubleBuffer)
239-
m_bufLock.unlock();
240-
241279
// Old code computed an absolute CLOCK_REALTIME deadline
242280
// (now + duration) via gettimeofday()/timespec math for
243281
// pthread_cond_timedwait(). That duration was:
@@ -247,44 +285,41 @@ void ThreadedChannelOutput::OutputThread(void) {
247285
// timespec/overflow-carry arithmetic is no longer needed.
248286
std::chrono::milliseconds waitDuration(m_maxWait ? m_maxWait : 200);
249287

250-
// No predicate is passed (matches pthread_cond_timedwait,
251-
// which also does not loop internally): a spurious or timed
252-
// wakeup here just falls through to the checks below, and
253-
// the outer while(m_runThread) loop is what re-evaluates
254-
// state on the next pass, exactly as before.
255-
m_sendCond.wait_for(sendLock, waitDuration);
288+
// The predicate overload computes the deadline once, so spurious
289+
// wakeups do not extend it and m_maxWait keeps its meaning: the
290+
// interval at which a subclass wants WaitTimedOut() called.
291+
m_sendCond.wait_for(sendLock, waitDuration, ready);
256292
} else {
257-
if (m_useDoubleBuffer)
258-
m_bufLock.unlock();
259-
260-
// No predicate here either, matching pthread_cond_wait().
261-
m_sendCond.wait(sendLock);
293+
m_sendCond.wait(sendLock, ready);
262294
}
263295

264-
sendLock.unlock();
296+
// Sampled together under the lock so the two decisions below cannot
297+
// see a half-updated state.
298+
bool haveData = m_dataWaiting;
299+
bool keepRunning = m_runThread;
265300

266-
if (!m_runThread)
267-
continue;
301+
sendLock.unlock();
268302

269303
wakeTime = GetTime();
270304
LogExcess(VB_CHANNELOUT, "ThreadedChannelOutput thread: woke: %lld\n", wakeTime);
271305

272-
// See if there is any data waiting to process or if we timed out
273-
if (m_useDoubleBuffer)
274-
m_bufLock.lock();
275-
276-
if (m_dataWaiting) {
277-
if (m_useDoubleBuffer)
278-
m_bufLock.unlock();
279-
306+
// A pending frame is flushed even once a stop has been requested: the
307+
// last frame of a sequence is the blanking frame and StopOutputThread
308+
// follows it immediately, so honoring the stop first leaves the lights
309+
// lit. Reaching here with neither data nor a stop means the wait
310+
// timed out, which is the only thing WaitTimedOut() is for.
311+
if (haveData) {
280312
SendOutputBuffer();
281-
} else {
282-
if (m_useDoubleBuffer)
283-
m_bufLock.unlock();
313+
} else if (keepRunning) {
284314
WaitTimedOut();
285315
}
316+
317+
if (!keepRunning)
318+
break;
286319
}
287320

288321
LogDebug(VB_CHANNELOUT, "ThreadedChannelOutput thread complete\n");
322+
sendLock.lock();
289323
m_threadIsRunning = 0;
324+
sendLock.unlock();
290325
}

src/channeloutput/ThreadedChannelOutput.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,21 @@ class ThreadedChannelOutput : public ChannelOutput {
4141
virtual void DumpConfig(void) override;
4242
virtual int RawSendData(unsigned char* channelData) = 0;
4343
virtual void WaitTimedOut() {}
44-
int StartOutputThread(void);
44+
// Returns false if the worker thread could not be created; Init() fails
45+
// the output in that case rather than waiting for a thread that will
46+
// never run.
47+
bool StartOutputThread(void);
4548
int StopOutputThread(void);
4649
int SendOutputBuffer(void);
4750

4851
unsigned int m_maxWait;
4952
unsigned int m_threadIsRunning;
53+
54+
// m_runThread and m_dataWaiting are the worker's wait predicate: every
55+
// write to either, and every read the worker acts on, is under m_sendLock.
5056
unsigned int m_runThread;
5157
volatile unsigned int m_dataWaiting;
58+
5259
unsigned int m_useDoubleBuffer;
5360

5461
// Converted from pthread_t/pthread_mutex_t/pthread_cond_t to the

0 commit comments

Comments
 (0)