Skip to content

Commit 5433bc0

Browse files
authored
Merge pull request #41 from Sendspin/fix/hook-serialisation
Run stream hooks one at a time, newest event winning the wait
2 parents 184bb6a + 616559f commit 5433bc0

6 files changed

Lines changed: 236 additions & 58 deletions

File tree

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,10 +1011,22 @@ the device refused is still audio arriving, so the amplifier is on for exactly a
10111011
long as `status` says `stream: receiving`. Nothing waits on it — a hook that blocks
10121012
cannot stall the audio path. It is reaped from the main loop; its output lands in
10131013
the log (stdout deliberately re-pointed at stderr, since `-o stdout` may be
1014-
carrying PCM); and a non-zero exit is a `W hook:` line, not a player failure. A
1015-
hook still running at shutdown is left to finish: an amplifier half-switched is
1014+
carrying PCM); and a non-zero exit is a `W hook:` line, not a player failure.
1015+
1016+
Hooks run one at a time, in event order, so a start hook that runs long cannot
1017+
finish after its own stream's stop hook and leave the amplifier on. While one
1018+
runs, the newest event waits — and only the newest: a stop superseded by a start
1019+
while an earlier hook is still running is skipped entirely (a `D hook:` line
1020+
records it), because the hardware should end in the final state, not replay a
1021+
stale one. A hook that *counts* events rather than setting state will see such
1022+
flapping coalesced away. However a hook misbehaves, at most one runs and one
1023+
waits — shutdown, below, is the one exception.
1024+
1025+
A hook still running at shutdown is left to finish: an amplifier half-switched is
10161026
worse than an orphan. A player stopped while a stream is playing runs its stop
1017-
hook on the way out, so `systemctl stop` leaves the amplifier off rather than on.
1027+
hook on the way out — beside a start hook that has still not finished, if it comes
1028+
to that (a `W hook:` line says so) — so `systemctl stop` leaves the amplifier off
1029+
rather than on.
10181030

10191031
The hook is handed nothing of the player's but that output stream: every other
10201032
descriptor is closed and SIGPIPE is back at its default, so `something | head -1`

docs/wiki/Controlling-the-Player.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,14 @@ tell from its own — the library reports that one is up, not where it came from
217217

218218
The hook never blocks playback: it is spawned and reaped from the main loop, its output
219219
goes to the log, and a non-zero exit is a `W hook:` warning rather than a player failure.
220-
Stopping the player while a stream is playing runs the stop hook before it exits, so
220+
Hooks run one at a time, in event order — a start hook that runs long cannot finish after
221+
its own stream's stop hook and leave the amplifier on. While one runs, only the newest
222+
event waits: a stop superseded by a start while an earlier hook is still running is
223+
skipped entirely, since the hardware should end in the final state rather than replay a
224+
stale one — a hook that *counts* events rather than setting state will see such flapping
225+
coalesced away, and each skipped event is a `D hook:` line in the log.
226+
Stopping the player while a stream is playing runs the stop hook before it
227+
exits — beside a start hook that has still not finished, if it comes to that — so
221228
`systemctl stop` switches the amplifier off rather than leaving it on. The hook is handed
222229
nothing of the player's but that output stream — every other descriptor is closed and
223230
SIGPIPE is back at its default — so `something | head -1` behaves as it would in any other

src/hooks.cpp

Lines changed: 79 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,85 @@ std::vector<std::string> hook_environment(const char* event, const HookContext&
9393
} // namespace
9494

9595
void HookRunner::run(const std::string& command, const char* event, const HookContext& context) {
96+
if (!this->running_.empty()) {
97+
// One at a time: spawned side by side, two events race in the scheduler, and a start
98+
// hook finishing after its own stream's stop hook leaves the amplifier on with the
99+
// player idle. The newest event wins the slot -- the hardware should end in the
100+
// final state, not replay a stale intermediate on the way there.
101+
if (this->pending_.has_value()) {
102+
cli_log(LogLevel::DEBUG, "The queued %s hook was superseded by the %s event",
103+
this->pending_->event.c_str(), event);
104+
}
105+
this->pending_ = PendingHook{command, event, context};
106+
return;
107+
}
108+
this->spawn(command, event, context);
109+
}
110+
111+
void HookRunner::poll() {
112+
for (size_t index = 0; index < this->running_.size();) {
113+
const RunningHook& hook = this->running_[index];
114+
int status = 0;
115+
const pid_t reaped = waitpid(hook.pid, &status, WNOHANG);
116+
if (reaped == 0) {
117+
++index;
118+
continue;
119+
}
120+
if (reaped < 0 && errno == EINTR) {
121+
// The signal landed on the call, not on the child: the hook is still running, and
122+
// the next poll() asks again. Every handler this daemon installs restarts its call
123+
// -- std::signal()'s BSD semantics for SIGINT and SIGTERM, SA_RESTART for SIGHUP --
124+
// so this is a guard rather than a path taken. Erasing here instead would leak a
125+
// zombie per stream, with nothing in the log to say why.
126+
++index;
127+
continue;
128+
}
129+
if (reaped < 0) {
130+
// ECHILD is the one that reaches this, and a child that cannot be waited on can
131+
// only be leaked, not re-polled -- so the entry goes. Said out loud because it
132+
// means something else reaped the hook, which is worth a breadcrumb; DEBUG because
133+
// the hook itself ran and there is nothing an operator can do about it.
134+
cli_log(LogLevel::DEBUG, "The %s hook [%d] could not be waited on (%s)",
135+
hook.event.c_str(), static_cast<int>(hook.pid), std::strerror(errno));
136+
}
137+
if (reaped == hook.pid && WIFEXITED(status) && WEXITSTATUS(status) != 0) {
138+
cli_log(LogLevel::WARN, "The %s hook [%d] exited %d", hook.event.c_str(),
139+
static_cast<int>(hook.pid), WEXITSTATUS(status));
140+
} else if (reaped == hook.pid && WIFSIGNALED(status)) {
141+
cli_log(LogLevel::WARN, "The %s hook [%d] was killed by signal %d",
142+
hook.event.c_str(), static_cast<int>(hook.pid), WTERMSIG(status));
143+
} else if (reaped == hook.pid) {
144+
cli_log(LogLevel::DEBUG, "The %s hook [%d] finished", hook.event.c_str(),
145+
static_cast<int>(hook.pid));
146+
}
147+
this->running_.erase(this->running_.begin() + static_cast<ptrdiff_t>(index));
148+
}
149+
150+
if (this->running_.empty() && this->pending_.has_value()) {
151+
// Its turn: everything ahead of it has been reaped. Taken out of the slot before the
152+
// spawn, which fills running_ again.
153+
const PendingHook next = *this->pending_;
154+
this->pending_.reset();
155+
this->spawn(next.command, next.event.c_str(), next.context);
156+
}
157+
}
158+
159+
void HookRunner::flush() {
160+
if (!this->pending_.has_value()) {
161+
return;
162+
}
163+
if (!this->running_.empty()) {
164+
// Ordering is knowingly given up, and said at WARN because it means a hook has hung:
165+
// the pending hook -- the stop the daemon promised on the way out -- must run at all.
166+
cli_log(LogLevel::WARN, "The %s hook has not finished -- running the %s hook beside it",
167+
this->running_.front().event.c_str(), this->pending_->event.c_str());
168+
}
169+
const PendingHook next = *this->pending_;
170+
this->pending_.reset();
171+
this->spawn(next.command, next.event.c_str(), next.context);
172+
}
173+
174+
void HookRunner::spawn(const std::string& command, const char* event, const HookContext& context) {
96175
// Built before the fork, because after it almost nothing is legal: this process has the
97176
// library's background threads, so the child may only call async-signal-safe functions
98177
// between fork() and execve() -- which allocation, and so std::string, is not.
@@ -157,44 +236,4 @@ void HookRunner::run(const std::string& command, const char* event, const HookCo
157236
this->running_.push_back({pid, event});
158237
}
159238

160-
void HookRunner::poll() {
161-
for (size_t index = 0; index < this->running_.size();) {
162-
const RunningHook& hook = this->running_[index];
163-
int status = 0;
164-
const pid_t reaped = waitpid(hook.pid, &status, WNOHANG);
165-
if (reaped == 0) {
166-
++index;
167-
continue;
168-
}
169-
if (reaped < 0 && errno == EINTR) {
170-
// The signal landed on the call, not on the child: the hook is still running, and
171-
// the next poll() asks again. Every handler this daemon installs restarts its call
172-
// -- std::signal()'s BSD semantics for SIGINT and SIGTERM, SA_RESTART for SIGHUP --
173-
// so this is a guard rather than a path taken. Erasing here instead would leak a
174-
// zombie per stream, with nothing in the log to say why.
175-
++index;
176-
continue;
177-
}
178-
if (reaped < 0) {
179-
// ECHILD is the one that reaches this, and a child that cannot be waited on can
180-
// only be leaked, not re-polled -- so the entry goes. Said out loud because it
181-
// means something else reaped the hook, which is worth a breadcrumb; DEBUG because
182-
// the hook itself ran and there is nothing an operator can do about it.
183-
cli_log(LogLevel::DEBUG, "The %s hook [%d] could not be waited on (%s)",
184-
hook.event.c_str(), static_cast<int>(hook.pid), std::strerror(errno));
185-
}
186-
if (reaped == hook.pid && WIFEXITED(status) && WEXITSTATUS(status) != 0) {
187-
cli_log(LogLevel::WARN, "The %s hook [%d] exited %d", hook.event.c_str(),
188-
static_cast<int>(hook.pid), WEXITSTATUS(status));
189-
} else if (reaped == hook.pid && WIFSIGNALED(status)) {
190-
cli_log(LogLevel::WARN, "The %s hook [%d] was killed by signal %d",
191-
hook.event.c_str(), static_cast<int>(hook.pid), WTERMSIG(status));
192-
} else if (reaped == hook.pid) {
193-
cli_log(LogLevel::DEBUG, "The %s hook [%d] finished", hook.event.c_str(),
194-
static_cast<int>(hook.pid));
195-
}
196-
this->running_.erase(this->running_.begin() + static_cast<ptrdiff_t>(index));
197-
}
198-
}
199-
200239
} // namespace sendspin_cli

src/hooks.h

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
#include <sys/types.h>
2121

22+
#include <optional>
2223
#include <string>
2324
#include <vector>
2425

@@ -48,8 +49,18 @@ struct HookContext {
4849
///
4950
/// A hook is `/bin/sh -c <command>` with `SENDSPIN_EVENT` and the context above added to
5051
/// this process's own environment -- a shell rather than an argv split, so `amixer set
51-
/// Master unmute && relay on` is one hook. The spawn returns as soon as the child is
52-
/// forked: a hook that blocks must not stall the audio path, so nothing here waits.
52+
/// Master unmute && relay on` is one hook. run() returns without waiting either way: a
53+
/// hook that blocks must not stall the audio path.
54+
///
55+
/// Hooks run one at a time, in event order. Two events for the same stream would otherwise
56+
/// race in the scheduler, and a start hook that runs long finishing *after* the stop hook
57+
/// of its own stream leaves the amplifier on with the player idle -- the exact state the
58+
/// hooks exist to prevent. While one runs, the newest event waits in a single pending
59+
/// slot; a newer event replaces whatever waits there, because the hardware should end in
60+
/// the *final* state, not replay a stale intermediate. A hook that counts events rather
61+
/// than setting state will see such flapping coalesced away -- each replacement is a
62+
/// `D hook:` line. The slot is also the bound: one event waiting at most, and one child at
63+
/// most until the shutdown flush(), no matter how a hook misbehaves.
5364
///
5465
/// The child's stdout and stderr both go where the player's stderr goes -- under -f, the
5566
/// logfile -- so whatever a hook prints lands beside the player's own lines. Its stdout is
@@ -61,24 +72,38 @@ struct HookContext {
6172
/// holding them keeps a restart from binding its port -- and SIGPIPE goes back to its default,
6273
/// which the player ignores and an exec would otherwise carry through.
6374
///
64-
/// THREAD SAFETY: run() and poll() must both be called on the main loop thread. That is
65-
/// where the stream callbacks that trigger hooks already fire, and it is what lets the
66-
/// bookkeeping below go unsynchronised.
75+
/// THREAD SAFETY: run(), poll() and flush() must all be called on the main loop thread.
76+
/// That is where the stream callbacks that trigger hooks already fire and where the
77+
/// shutdown path runs, and it is what lets the bookkeeping below go unsynchronised.
6778
class HookRunner {
6879
public:
69-
/// @brief Runs `command` with `SENDSPIN_EVENT=<event>` and `context` in its environment.
80+
/// @brief Runs `command` with `SENDSPIN_EVENT=<event>` and `context` in its environment,
81+
/// or holds it in the pending slot while an earlier hook is still running.
7082
///
7183
/// Failure to spawn is a WARN, not an error: the stream the event describes is fine,
7284
/// and the player must keep playing it.
7385
/// @param event What SENDSPIN_EVENT carries: "start" or "stop".
86+
/// @param context Copied when the event has to wait: it describes this event's stream,
87+
/// and the caller's object will already describe the next one by the time the slot is
88+
/// spawned.
7489
void run(const std::string& command, const char* event, const HookContext& context);
7590

76-
/// @brief Reaps any hooks that have finished, logging the ones that failed.
91+
/// @brief Reaps any hooks that have finished, logging the ones that failed, and spawns
92+
/// the pending event once the running hook is out.
7793
///
7894
/// Call from the main loop. A hook still running when the daemon exits is left to
7995
/// finish on its own -- an amplifier half-switched-off is worse than an orphan.
8096
void poll();
8197

98+
/// @brief Spawns the pending event now, beside the running hook if there still is one.
99+
///
100+
/// For the shutdown path, after the stream-end drain: the daemon promises that
101+
/// stopping it runs the stop hook, and a start hook that never finishes must not be
102+
/// allowed to turn that promise into an amplifier left on. Ordering is knowingly given
103+
/// up here -- there is no later event left to order against, and the alternative is
104+
/// the hook never running at all.
105+
void flush();
106+
82107
/// How many hooks have been spawned and not yet reaped. For tests, which need to know
83108
/// when poll() has seen a child out, and for nothing else.
84109
size_t running() const {
@@ -92,7 +117,18 @@ class HookRunner {
92117
std::string event;
93118
};
94119

120+
/// The event waiting for the running hook to finish, newest wins.
121+
struct PendingHook {
122+
std::string command;
123+
std::string event;
124+
HookContext context;
125+
};
126+
127+
/// Forks and execs one hook, unconditionally. run() decides whether now is the time.
128+
void spawn(const std::string& command, const char* event, const HookContext& context);
129+
95130
std::vector<RunningHook> running_;
131+
std::optional<PendingHook> pending_;
96132
};
97133

98134
} // namespace sendspin_cli

src/main.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,10 @@ int main(int argc, char* argv[]) {
10151015
hooks.poll();
10161016
std::this_thread::sleep_for(std::chrono::milliseconds(LOOP_INTERVAL_MS));
10171017
}
1018+
// The stop hook may be sitting in the pending slot behind a start hook that never
1019+
// finished, and no more polls are coming. Spawned now regardless: this is the last
1020+
// chance to keep the promise that stopping the player switches the amplifier off.
1021+
hooks.flush();
10181022
// The lambda holds references to locals declared after the listener, so it outlives them by
10191023
// exactly the width of this scope's teardown. Nothing calls it there -- stream events only
10201024
// arrive inside client.loop(), and the last one has run -- and dropping it here is what

tests/hooks_test.cpp

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
#include <gtest/gtest.h>
2525
#include <unistd.h>
2626

27-
#include <algorithm>
2827
#include <chrono>
2928
#include <csignal>
3029
#include <cstdio>
@@ -225,18 +224,99 @@ TEST(HookRunner, AFailingHookIsReapedRatherThanLeaked) {
225224
EXPECT_EQ(runner.running(), 0U);
226225
}
227226

228-
TEST(HookRunner, SeveralHooksInFlightAreEachReaped) {
227+
// ---------------------------------------------------------------------------
228+
// One at a time, newest event wins the wait
229+
// ---------------------------------------------------------------------------
230+
231+
/// A hook command that spins until `gate` exists, then runs `then`.
232+
///
233+
/// What every ordering test below hangs off: the gated hook is deterministically still
234+
/// running until the test opens the gate, with no scheduler timing assumed anywhere.
235+
std::string gated(const std::string& gate, const std::string& then) {
236+
return "while [ ! -e " + gate + " ]; do sleep 0.01; done; " + then;
237+
}
238+
239+
void open_gate(const std::string& path) {
240+
const std::ofstream gate(path);
241+
}
242+
243+
TEST(HookRunner, ASecondEventWaitsForTheRunningHook) {
244+
ScratchFile out;
245+
ScratchFile gate;
246+
HookRunner runner;
247+
// The start hook cannot write until the gate exists, so spawned side by side the stop
248+
// hook's 'b' would deterministically land first. The order below is the contract.
249+
runner.run(gated(gate.path(), "printf 'a' >> " + out.path()), "start", HookContext{});
250+
runner.run("printf 'b' >> " + out.path(), "stop", HookContext{});
251+
252+
EXPECT_EQ(runner.running(), 1U);
253+
open_gate(gate.path());
254+
ASSERT_TRUE(drain(runner));
255+
EXPECT_EQ(slurp(out.path()), "ab");
256+
}
257+
258+
TEST(HookRunner, TheNewestEventReplacesTheWaitingOne) {
229259
ScratchFile out;
260+
ScratchFile gate;
230261
HookRunner runner;
231-
runner.run("printf 'a' >> " + out.path(), "start", HookContext{});
262+
runner.run(gated(gate.path(), "printf 'a' >> " + out.path()), "start", HookContext{});
232263
runner.run("printf 'b' >> " + out.path(), "stop", HookContext{});
233264
runner.run("printf 'c' >> " + out.path(), "start", HookContext{});
234265

266+
open_gate(gate.path());
267+
ASSERT_TRUE(drain(runner));
268+
// The stop was superseded while it waited: the hardware ends in the final state, not
269+
// replaying the intermediate on the way there.
270+
EXPECT_EQ(slurp(out.path()), "ac");
271+
}
272+
273+
TEST(HookRunner, AWaitingEventKeepsItsOwnContext) {
274+
ScratchFile out;
275+
ScratchFile gate;
276+
HookRunner runner;
277+
runner.run(gated(gate.path(), "true"), "start", HookContext{});
278+
279+
HookContext context;
280+
context.server_id = "srv-b";
281+
runner.run("printf '%s' \"$SENDSPIN_SERVER_ID\" > " + out.path(), "stop", context);
282+
// What the caller does to its object between events must not reach into the slot: the
283+
// waiting event describes the stream it was fired for.
284+
context.server_id = "srv-c";
285+
286+
open_gate(gate.path());
235287
ASSERT_TRUE(drain(runner));
236-
// Order is the scheduler's, so only the multiset of writes is promised.
237-
std::string content = slurp(out.path());
238-
std::sort(content.begin(), content.end());
239-
EXPECT_EQ(content, "abc");
288+
EXPECT_EQ(slurp(out.path()), "srv-b");
289+
}
290+
291+
TEST(HookRunner, FlushRunsTheWaitingHookBesideAHungOne) {
292+
ScratchFile out;
293+
ScratchFile gate;
294+
HookRunner runner;
295+
runner.run(gated(gate.path(), "true"), "start", HookContext{});
296+
runner.run("printf 'b' >> " + out.path(), "stop", HookContext{});
297+
298+
// What the shutdown path does when the drain ends: two children out at once,
299+
// deliberately -- the promise that stopping the player runs the stop hook outranks
300+
// ordering when no more events can come.
301+
runner.flush();
302+
EXPECT_EQ(runner.running(), 2U);
303+
304+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
305+
while (slurp(out.path()) != "b" && std::chrono::steady_clock::now() < deadline) {
306+
runner.poll();
307+
std::this_thread::sleep_for(std::chrono::milliseconds(5));
308+
}
309+
EXPECT_EQ(slurp(out.path()), "b");
310+
311+
open_gate(gate.path());
312+
EXPECT_TRUE(drain(runner));
313+
}
314+
315+
TEST(HookRunner, FlushWithNothingWaitingDoesNothing) {
316+
HookRunner runner;
317+
runner.flush();
318+
319+
EXPECT_EQ(runner.running(), 0U);
240320
}
241321

242322
TEST(HookRunner, DoesNotHandTheHookThePlayersIgnoredSIGPIPE) {

0 commit comments

Comments
 (0)