STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
sequencer_main.cpp
Go to the documentation of this file.
1/**
2 * sequencer_service — state machine, actuator commanding, abort, FIRE lifecycle.
3 *
4 * Listens on TCP (default port 9998) for newline-terminated text commands:
5 *
6 * TRANSITION:<state_name>[:<ms>] — request state transition; the optional duration
7 * overrides the hold of a state configured to accept
8 * one (never the burn state — that is refused)
9 * ACTUATOR:<role_name>:<0|1> — manual actuator command (debug mode only)
10 * DEBUG_MODE:<0|1> — toggle debug mode
11 * EXTEND_FIRE — extend FIRE window
12 * SCRIPTS — read-only: what this process made of its dynamic states
13 * at startup (loaded, or refused and why). A query, not a
14 * push; the answer is fixed for the life of the run.
15 *
16 * There is deliberately no reload command. Config reaches this process once, at startup: the
17 * backend deploys the active profile to config/ at session start and then the pipeline units are
18 * started, so a running sequencer's config is frozen for the life of the run.
19 *
20 * Each command gets an immediate reply of "OK\n" or "ERR:<reason>\n".
21 * Multiple simultaneous TCP clients are supported (a bounded, joined thread per connection).
22 * Commands are not executed on those threads: each is posted to SequencerService's command queue
23 * and run by its single worker, which is what serializes access to the state machine.
24 *
25 * Usage: ./sequencer_service [--config PATH] [--port PORT]
26 */
27
28#include <arpa/inet.h>
29#include <netinet/in.h>
30#include <signal.h>
31#include <sys/socket.h>
32#include <unistd.h>
33
34#include <atomic>
35#include <cstring>
36#include <iostream>
37#include <memory>
38#include <string>
39#include <thread>
40#include <vector>
41
42#include "control/HoldParse.hpp"
44
45namespace {
46std::atomic<bool> g_running{true};
47
48// Concurrent TCP clients accepted. The backend is the real client and holds one connection at a
49// time, so this is a guard against runaway thread creation, not a capacity target — set well
50// above any legitimate load. Previously unbounded.
51constexpr size_t kMaxClients = 64;
52// Longest command accepted before the connection is dropped, matching the old per-client cap.
53constexpr size_t kMaxLineBytes = 512;
54
55void signalHandler(int /*sig*/) {
56 std::cout << "\n[Sequencer] Shutting down..." << std::endl;
57 g_running = false;
58}
59
60std::string trim(const std::string& s) {
61 size_t a = s.find_first_not_of(" \t\r\n");
62 size_t b = s.find_last_not_of(" \t\r\n");
63 return (a == std::string::npos) ? "" : s.substr(a, b - a + 1);
64}
65
66/**
67 * Execute one newline-terminated command and write its reply.
68 *
69 * Does no state-machine work itself: every command below is posted to SequencerService's command
70 * queue and executed by its single worker thread, which is what serializes them. This function
71 * parses, dispatches, and replies.
72 */
73void handleCommandLine(int client_fd, const std::string& raw, sequencer::SequencerService& svc) {
74 auto sendReply = [&](const std::string& msg) {
75 send(client_fd, msg.c_str(), msg.size(), MSG_NOSIGNAL);
76 };
77
78 const std::string cmd = trim(raw);
79 if (cmd.empty())
80 return;
81
82 // ── TRANSITION:<state_name> ──────────────────────────────────────────
83 if (cmd.compare(0, 11, "TRANSITION:") == 0) {
84 const std::string rest = trim(cmd.substr(11));
85 std::string state_name = rest;
86 uint32_t hold_ms = 0;
87
88 // Optional trailing :<ms>. Split on the LAST colon like the ACTUATOR branch below, because
89 // state names contain spaces and may not be assumed colon-free either — and only accept the
90 // tail as a duration if it parses cleanly as one. Anything else stays part of the name, so
91 // a plain "TRANSITION:Flow Test" is byte-for-byte what it always was and no existing client
92 // changes. A state literally named "Foo:123" would misparse into an unknown state that is
93 // refused, rather than into an unintended actuation — the direction to fail in.
94 const size_t last_colon = rest.rfind(':');
95 if (last_colon != std::string::npos && last_colon > 0) {
96 uint32_t parsed = 0;
97 if (sequencer::parseHoldMs(trim(rest.substr(last_colon + 1)), parsed)) {
98 hold_ms = parsed;
99 state_name = trim(rest.substr(0, last_colon));
100 } else if (trim(rest.substr(last_colon + 1)).find_first_not_of("0123456789") ==
101 std::string::npos) {
102 // All digits but rejected: zero, or past the wire ceiling. Say so rather than
103 // silently treating "Flow Test:0" as a state name and reporting it unknown.
104 sendReply("ERR:bad hold duration\n");
105 return;
106 }
107 }
108
109 std::string why;
110 if (state_name.empty()) {
111 sendReply("ERR:empty state name\n");
112 } else if (svc.transitionTo(state_name, hold_ms, &why)) {
113 // Echo the window actually used so the client displays the sequencer's number rather
114 // than assuming its own was honoured.
115 sendReply(hold_ms ? "OK:" + std::to_string(hold_ms) + "\n" : std::string("OK\n"));
116 } else {
117 sendReply("ERR:" + (why.empty() ? std::string("transition rejected") : why) + "\n");
118 }
119
120 // ── ACTUATOR:<role>:<0|1> ────────────────────────────────────────────
121 } else if (cmd.compare(0, 9, "ACTUATOR:") == 0) {
122 const std::string rest = trim(cmd.substr(9));
123 const size_t last_colon = rest.rfind(':');
124 if (last_colon == std::string::npos || last_colon == 0) {
125 sendReply("ERR:bad ACTUATOR format\n");
126 return;
127 }
128 const std::string role_name = trim(rest.substr(0, last_colon));
129 const std::string val_str = trim(rest.substr(last_colon + 1));
130 int pos = -1;
131 if (val_str == "1" || val_str == "open")
132 pos = 1;
133 else if (val_str == "0" || val_str == "closed")
134 pos = 0;
135 if (pos < 0 || role_name.empty()) {
136 sendReply("ERR:bad ACTUATOR value\n");
137 } else {
138 std::string why;
139 if (svc.manualActuator(role_name, pos, &why))
140 sendReply("OK\n");
141 else
142 sendReply("ERR:" + (why.empty() ? std::string("actuator command failed") : why) +
143 "\n");
144 }
145
146 // ── DEBUG_MODE:<0|1> ─────────────────────────────────────────────────
147 } else if (cmd.compare(0, 11, "DEBUG_MODE:") == 0) {
148 const std::string val = trim(cmd.substr(11));
149 if (val == "1" || val == "true") {
150 svc.setDebugMode(true);
151 sendReply("OK\n");
152 } else if (val == "0" || val == "false") {
153 svc.setDebugMode(false);
154 sendReply("OK\n");
155 } else {
156 sendReply("ERR:bad DEBUG_MODE value\n");
157 }
158
159 // ── EXTEND_FIRE ──────────────────────────────────────────────────────
160 } else if (cmd == "EXTEND_FIRE") {
161 if (svc.extendFire())
162 sendReply("OK\n");
163 else
164 sendReply("ERR:not in FIRE state\n");
165
166 // ── SCRIPTS ──────────────────────────────────────────────────────────
167 // Read-only. Reports what this process made of its dynamic states at startup, so the GUI
168 // can render a refused state as visibly dead with the sequencer's own wording rather than
169 // as a normal-looking button that errors when pressed.
170 //
171 // This does NOT reintroduce config push. It is a query: the answer was decided once, at
172 // load, and cannot change for the life of the run.
173 } else if (cmd == "SCRIPTS") {
174 sendReply(svc.scriptStatusReport());
175
176 } else {
177 sendReply("ERR:unknown command\n");
178 }
179}
180
181/**
182 * One connection, on its own thread: read lines, dispatch, reply, until the peer closes.
183 *
184 * A thread per connection looks redundant now that SequencerService serializes everything on its
185 * worker — and an earlier revision of this change did fold it into the accept loop. A stress run
186 * showed why that was wrong: while the loop sits inside a command waiting for the worker, it is
187 * not calling accept(), so new connections queue in the backlog and time out at the *client*
188 * even though the service itself is healthy (600 concurrent transitions: 0 service-side timeouts,
189 * 5 client-side ones, all of them accept latency). Serializing execution is the goal; serializing
190 * *acceptance* was an accident.
191 *
192 * What the old version got wrong was not the threads, it was their lifetime: they were detached
193 * while holding a reference to a stack-allocated service, so they could outlive it at shutdown,
194 * and nothing bounded how many existed. Both are handled by the caller now — threads are counted
195 * and joined.
196 */
197void handleClient(int client_fd, sequencer::SequencerService& svc) {
198 // 5-second receive timeout, so a silent peer cannot pin this thread forever.
199 struct timeval tv{.tv_sec = 5, .tv_usec = 0};
200 setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
201
202 std::string buf;
203 while (g_running) {
204 char tmp[512];
205 ssize_t n = recv(client_fd, tmp, sizeof(tmp), 0);
206 if (n <= 0)
207 break; // peer closed, timed out, or shutdown() woke us
208 buf.append(tmp, static_cast<size_t>(n));
209
210 size_t nl;
211 while ((nl = buf.find('\n')) != std::string::npos) {
212 const std::string line = buf.substr(0, nl);
213 buf.erase(0, nl + 1);
214 handleCommandLine(client_fd, line, svc);
215 }
216 if (buf.size() > kMaxLineBytes) {
217 std::cerr << "[Sequencer] oversized command — dropping connection" << std::endl;
218 break;
219 }
220 }
221 close(client_fd);
222}
223} // namespace
224
225int main(int argc, char* argv[]) {
226 std::string config_path = "config/config.toml";
227 uint16_t listen_port = 9998;
228
229 for (int i = 1; i < argc; ++i) {
230 std::string arg = argv[i];
231 if (arg == "--config" && i + 1 < argc) {
232 config_path = argv[++i];
233 } else if (arg == "--port" && i + 1 < argc) {
234 listen_port = static_cast<uint16_t>(std::atoi(argv[++i]));
235 } else if (arg == "--help" || arg == "-h") {
236 std::cout << "Usage: " << argv[0] << " [--config PATH] [--port PORT]\n";
237 return 0;
238 }
239 }
240
241 signal(SIGINT, signalHandler);
242 signal(SIGTERM, signalHandler);
243 signal(SIGPIPE, SIG_IGN); // don't crash on broken TCP connections
244
246 if (!svc.init(config_path)) {
247 std::cerr << "[Sequencer] Initialization failed" << std::endl;
248 return 1;
249 }
250
251 // TCP listen socket
252 int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
253 if (listen_fd < 0) {
254 std::cerr << "[Sequencer] socket() failed" << std::endl;
255 return 1;
256 }
257 {
258 int opt = 1;
259 setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
260 }
261 {
262 struct sockaddr_in addr{};
263 addr.sin_family = AF_INET;
264 addr.sin_addr.s_addr = INADDR_ANY;
265 addr.sin_port = htons(listen_port);
266 if (bind(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0) {
267 std::cerr << "[Sequencer] bind() failed on port " << listen_port << std::endl;
268 close(listen_fd);
269 return 1;
270 }
271 }
272 if (listen(listen_fd, 10) < 0) {
273 std::cerr << "[Sequencer] listen() failed" << std::endl;
274 close(listen_fd);
275 return 1;
276 }
277
278 std::cout << "[Sequencer] Listening on port " << listen_port << std::endl;
279 std::cout << "[Sequencer] Commands: TRANSITION:<state>[:<ms>] | ACTUATOR:<name>:<0|1> "
280 << "| DEBUG_MODE:<0|1> | EXTEND_FIRE" << std::endl;
281
282 // Connections are tracked rather than detached, so every thread is joined before `svc` — a
283 // stack object these threads hold a reference to — goes out of scope at the end of main().
284 struct Conn {
285 std::thread th;
286 int fd{-1};
287 std::atomic<bool> done{false};
288 };
289 std::vector<std::unique_ptr<Conn>> conns;
290
291 auto reap_finished = [&conns]() {
292 for (auto it = conns.begin(); it != conns.end();) {
293 if ((*it)->done.load()) {
294 if ((*it)->th.joinable())
295 (*it)->th.join();
296 it = conns.erase(it);
297 } else {
298 ++it;
299 }
300 }
301 };
302
303 while (g_running) {
304 reap_finished();
305
306 // select() with 10 ms timeout — keeps g_running check while staying low-latency
307 fd_set rd;
308 FD_ZERO(&rd);
309 FD_SET(listen_fd, &rd);
310 struct timeval tv{.tv_sec = 0, .tv_usec = 10000};
311 if (select(listen_fd + 1, &rd, nullptr, nullptr, &tv) <= 0)
312 continue;
313
314 int client_fd = accept(listen_fd, nullptr, nullptr);
315 if (client_fd < 0)
316 continue;
317
318 // Reap again before testing the cap: a burst of short-lived connections arriving inside
319 // one select() iteration would otherwise be counted against the limit even though those
320 // threads have already finished.
321 reap_finished();
322 if (conns.size() >= kMaxClients) {
323 // Say why, rather than closing cold — a bare close() reaches the client as a TCP
324 // reset, which is indistinguishable from the service having crashed.
325 static const char kBusy[] = "ERR:too many connections\n";
326 send(client_fd, kBusy, sizeof(kBusy) - 1, MSG_NOSIGNAL);
327 std::cerr << "[Sequencer] client limit (" << kMaxClients
328 << ") reached — refusing connection" << std::endl;
329 close(client_fd);
330 continue;
331 }
332
333 auto conn = std::make_unique<Conn>();
334 conn->fd = client_fd;
335 Conn* raw = conn.get();
336 conn->th = std::thread([raw, &svc]() {
337 handleClient(raw->fd, svc);
338 raw->done = true;
339 });
340 conns.push_back(std::move(conn));
341 }
342
343 // Wake any thread parked in recv() so shutdown does not wait out its 5 s timeout, then join
344 // them all. Joining before returning is what keeps `svc` alive until nothing references it.
345 for (const auto& c : conns)
346 shutdown(c->fd, SHUT_RDWR);
347 for (const auto& c : conns)
348 if (c->th.joinable())
349 c->th.join();
350
351 close(listen_fd);
352 std::cout << "[Sequencer] Stopped." << std::endl;
353 return 0;
354}
static void signalHandler(int)
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.
std::string scriptStatusReport() const
What this process made of its dynamic states at startup, as newline-terminated text.
bool manualActuator(const std::string &name, int pos, std::string *refusal_reason=nullptr)
Debug mode only: manual OPEN/CLOSE for one role.
bool extendFire()
Extend the FIRE window (only valid while in FIRE state).
static std::atomic< bool > g_running
static void send(void *ctx, const uint8_t *bytes, uint16_t len)
Definition gnss_task.c:27
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
std::string trim(const std::string &s)
void handleClient(int client_fd, sequencer::SequencerService &svc)
One connection, on its own thread: read lines, dispatch, reply, until the peer closes.
void handleCommandLine(int client_fd, const std::string &raw, sequencer::SequencerService &svc)
Execute one newline-terminated command and write its reply.
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
bool parseHoldMs(const std::string &text, uint32_t &out)
Parse a hold duration off the wire, strictly.
Definition HoldParse.hpp:31
int main()