STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_sequencer_concurrency.cpp
Go to the documentation of this file.
1/**
2 * SequencerService under concurrent command load.
3 *
4 * The bug this guards against: SequencerService had no serialization at all. Fields were atomic,
5 * but a transition is a multi-step sequence built out of them — stop the actuator republish loop,
6 * apply actuators, restart the loop, assign the state, arm the burn — and atomics on the steps do
7 * nothing for the sequence. sequencer_main spawns a thread per TCP client, and FireManager's timer
8 * thread calls back into transitionTo when a burn expires, so two threads could run that sequence
9 * at once.
10 *
11 * The concrete failure is in ActuatorCommander::stopContinuousLoop(): `if (loop_thread_.joinable())
12 * loop_thread_.join();`. Two threads passing the joinable() check together both call join() on the
13 * same std::thread — a double pthread_join, which is undefined behaviour — and then both
14 * move-assign a fresh thread onto the handle, which is a std::terminate if the other's assignment
15 * landed first.
16 *
17 * ── How this test actually catches it ──────────────────────────────────────────────────────────
18 * A plain -O2 build does NOT reliably crash: a pre-fix binary survived 1016 commands over 65 s,
19 * because glibc tolerates a double pthread_join often enough that the window is rarely fatal. That
20 * is worse than a crash, not better — in production it silently corrupts thread bookkeeping.
21 *
22 * Under ThreadSanitizer the same code fails immediately and unambiguously:
23 *
24 * ThreadSanitizer: CHECK failed: sanitizer_thread_registry.cpp:348 "((t)) != (0)"
25 * #3 pthread_join
26 * #4 std::thread::join()
27 * #5 ActuatorCommander::stopContinuousLoop() ActuatorCommander.cpp
28 * #6 SequencerService::transitionTo(State)
29 * #8 handleClient sequencer_main.cpp
30 *
31 * (ConsumeThreadUserId returning 0 = the thread being joined was already consumed by another
32 * join.) Pre-fix died 0.2 s in at ~50 commands; post-fix ran 746 commands over 25.9 s clean.
33 *
34 * So: run this test under TSan to make it a real detector. Without TSan it is still a useful
35 * smoke test — it asserts the service stays alive and functional under load, and it would catch
36 * a deadlock or a hang, which the command-queue design is the other main risk of.
37 */
38#include <arpa/inet.h>
39#include <netinet/in.h>
40#include <sys/socket.h>
41#include <unistd.h>
42
43#include <atomic>
44#include <chrono>
45#include <cstdio>
46#include <fstream>
47#include <iostream>
48#include <string>
49#include <thread>
50#include <vector>
51
54
58using Clock = std::chrono::steady_clock;
59
60static int g_failures = 0;
61
62static void check(bool ok, const std::string& what) {
63 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
64 if (!ok)
65 g_failures++;
66}
67
68/** Minimal stand-in for controller_service: accept, drain, close. Without it every fire
69 * transition pays notifyControllerFire's connect timeout and the test crawls. */
70static void fakeController(int listen_fd, std::atomic<bool>& run) {
71 while (run) {
72 struct timeval tv{.tv_sec = 0, .tv_usec = 100000};
73 setsockopt(listen_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
74 int c = accept(listen_fd, nullptr, nullptr);
75 if (c < 0)
76 continue;
77 char buf[128];
78 setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
79 (void)recv(c, buf, sizeof(buf), 0);
80 close(c);
81 }
82}
83
84static int bindEphemeral(uint16_t& out_port) {
85 int fd = socket(AF_INET, SOCK_STREAM, 0);
86 int opt = 1;
87 setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
88 struct sockaddr_in a{};
89 a.sin_family = AF_INET;
90 a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
91 a.sin_port = 0;
92 bind(fd, reinterpret_cast<struct sockaddr*>(&a), sizeof(a));
93 listen(fd, 8);
94 socklen_t len = sizeof(a);
95 getsockname(fd, reinterpret_cast<struct sockaddr*>(&a), &len);
96 out_port = ntohs(a.sin_port);
97 return fd;
98}
99
100int main() {
101 std::cout << "=== SequencerService concurrency ===" << std::endl;
102
103 uint16_t ctrl_port = 0;
104 int ctrl_fd = bindEphemeral(ctrl_port);
105 std::atomic<bool> ctrl_run{true};
106 std::thread ctrl_thread(fakeController, ctrl_fd, std::ref(ctrl_run));
107
108 // Short burn window so the fire timer expires repeatedly during the storm — the expiry
109 // callback is the second thread that enters the state machine, and the one a naive mutex
110 // would have deadlocked against.
111 const std::string cfg_path = "test_concurrency_cfg.toml";
112 {
113 std::ofstream f(cfg_path);
114 f << "[database]\nhost=\"127.0.0.1\"\nport=1\n\n"
115 << "[controller_service]\nhost=\"127.0.0.1\"\nport=" << ctrl_port << "\n\n"
116 << "[fire]\nstate=\"Fire\"\nexpiry_target=\"Armed\"\n"
117 << "duration_ms=250\nextended_ms=500\n";
118 }
119
121 if (!svc.init(cfg_path)) {
122 std::cerr << "FAILED: init (run from the build dir, with config/ reachable)" << std::endl;
123 ctrl_run = false;
124 ctrl_thread.join();
125 close(ctrl_fd);
126 std::remove(cfg_path.c_str());
127 return 1;
128 }
129 // Debug mode: this test is about concurrent execution, not about which transitions the CSV
130 // permits. Without it most of the storm is rejected before touching the actuator loop, which
131 // is exactly the code path under test.
132 svc.setDebugMode(true);
133
134 // A fixed number of transitions per thread rather than "hammer for N seconds". Coverage is
135 // then identical everywhere, instead of being a function of how fast the box is — which
136 // matters because this test is meant to run under TSan, where throughput drops by roughly a
137 // third, and on CI runners that are slower again. The pre-fix binary died at ~50 transitions
138 // under TSan, so this is comfortable margin.
139 constexpr int kThreads = 12;
140 constexpr int kIterationsPerThread = 20;
141 std::atomic<int> issued{0};
142 const char* targets[] = {"Idle", "Armed", "Fire", "Engine Abort"};
143
144 const auto t0 = Clock::now();
145 std::vector<std::thread> workers;
146 for (int i = 0; i < kThreads; ++i) {
147 workers.emplace_back([&, i]() {
148 int n = i;
149 for (int k = 0; k < kIterationsPerThread; ++k) {
150 svc.transitionTo(std::string(targets[n++ % 4]));
151 issued++;
152 }
153 });
154 }
155 for (auto& w : workers)
156 w.join();
157 const auto elapsed_ms =
158 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
159
160 // Reaching here at all is the headline assertion: pre-fix, this is where the process died
161 // under TSan. A hang instead of a crash is caught by the CTest timeout on this test.
162 check(true, "survived concurrent transitions (no double-join / terminate)");
163 check(issued.load() == kThreads * kIterationsPerThread,
164 "every transition completed (" + std::to_string(issued.load()) + "/" +
165 std::to_string(kThreads * kIterationsPerThread) + " in " +
166 std::to_string(elapsed_ms) + " ms)");
167
168 // Still functional afterwards — a queue that wedged or deadlocked would fail here rather than
169 // crashing, and that is the main risk the command-queue design introduces.
170 const bool accepted = svc.transitionTo(std::string("Idle"));
171 check(accepted, "service still accepts commands after the storm");
173 "reported state matches the last accepted transition");
174
175 ctrl_run = false;
176 ctrl_thread.join();
177 close(ctrl_fd);
178 std::remove(cfg_path.c_str());
179
180 std::cout << (g_failures == 0 ? "✅ PASS" : "❌ FAIL") << std::endl;
181 return g_failures == 0 ? 0 : 1;
182}
static State fromName(const std::string &name)
Translate a CSV name (e.g.
Top-level sequencer service.
bool setDebugMode(bool enabled)
Enable or disable debug mode.
bool init(const std::string &config_path="config/config.toml")
Load config, CSVs, connect to Elodin.
bool transitionTo(const std::string &state_name)
Request a state transition by name (e.g.
Parses state_transitions.csv and validates state transitions.
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
std::chrono::steady_clock Clock
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
Definition run.py:1
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
static int g_failures
static void check(bool ok, const std::string &what)
static void fakeController(int listen_fd, std::atomic< bool > &run)
Minimal stand-in for controller_service: accept, drain, close.
static int bindEphemeral(uint16_t &out_port)