LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
sym_engines.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_SYM_SYM_ENGINES_H
6#define LINE_API_SYM_SYM_ENGINES_H
7
8/**
9 * @file
10 * @ingroup api_sym
11 * Resolves the symbolic backend to use, and owns the container that serves it.
12 *
13 * Port of jline.api.sym.SymEngines. Resolution order, the same in MATLAB
14 * (SAGE.m), the JAR and Python (line_solver.api.sym):
15 * 1. an explicit URL, from solver options or the `requested` argument;
16 * 2. the LINE_SAGE_URL environment variable;
17 * 3. a line-sage-rest service already listening on a conventional port;
18 * 4. a container started here from a locally present image;
19 * 5. nothing, in which case the caller keeps whatever native algebra it has,
20 * or reports that no backend is configured.
21 *
22 * STEP 3 VERIFIES IDENTITY THROUGH /api/v1/info rather than trusting the port:
23 * every imperialqore line-*-rest service listens on 8080 by convention, so a
24 * health probe alone would happily accept the LQNS service and then fail on the
25 * first symbolic request with an unrecognizable error.
26 *
27 * The container started in step 4 is reused for the life of the process and
28 * stopped by an atexit handler. It is bound to an ephemeral host port, so
29 * several processes, or a process alongside a hand-started service, do not
30 * collide. An atexit handler does NOT run on a signal or on _exit, so a
31 * container may outlive a killed process; `docker ps` shows it under the name
32 * `line-sage-rest-<port>` and it was started with --rm, so stopping it removes it.
33 *
34 * A PULL HAPPENS ONLY ON EXPLICIT OPT-IN, i.e. the "sage" keyword or a named
35 * image. Bare "auto"/"true"/"" keep the native backend unless the image is
36 * already local, so leaving the symbolic option on auto never triggers a
37 * multi-gigabyte download, and the pull itself is refused when the Docker
38 * storage location is short of space (see line/io/docker_image.h).
39 *
40 * DIVERGENCE FROM THE JAR: an https:// URL is refused by name rather than used,
41 * because this port's HTTP client has no TLS (see line/util/http.h). Refusing
42 * loudly is the point -- reporting "no backend" for a service that is up and
43 * merely unreachable over plaintext would send the caller looking in the wrong
44 * place.
45 */
46
47#include <cctype>
48#include <cstddef>
49#include <cstdlib>
50#include <cstring>
51#include <ctime>
52#include <iostream>
53#include <memory>
54#include <mutex>
55#include <string>
56#include <vector>
57
58#include <netinet/in.h>
59#include <sys/socket.h>
60#include <unistd.h>
61
65#include "line/util/error.h"
67
68namespace line {
69namespace sym {
70
71/** Image serving the symbolic REST API. */
72inline const char* const SYM_DOCKER_IMAGE = "imperialqore/line-sage-rest:latest";
73/** Environment variable naming a service to use. */
74inline const char* const SYM_URL_ENV = "LINE_SAGE_URL";
75/** Seconds to wait for a container to report healthy. */
76inline constexpr int SYM_STARTUP_TIMEOUT_SECONDS = 120;
77
78/** Fallback tags, tried in order after SYM_DOCKER_IMAGE. */
79inline std::vector<std::string> sym_docker_image_candidates() {
80 return std::vector<std::string>{"imperialqore/line-sage-rest:latest",
81 "imperialqore/line-sage-rest"};
82}
83
84/** Ports probed for an already running service, in order. */
85inline std::vector<int> sym_probe_ports() { return std::vector<int>{8085, 8080}; }
86
87namespace detail {
88
89/** Process-wide record of the container this process started, if any. */
90struct SymState {
91 std::mutex mutex;
92 std::shared_ptr<SageRestEngine> started;
93 std::string container;
94 bool atexitRegistered = false;
95};
96
97inline SymState& sym_state() {
98 static SymState state;
99 return state;
100}
101
102inline std::string lower(const std::string& s) {
103 std::string out(s);
104 for (std::size_t i = 0; i < out.size(); ++i)
105 out[i] = static_cast<char>(std::tolower(out[i]));
106 return out;
107}
108
109/** An unused local TCP port, obtained the way the JAR does: bind port 0. */
110inline int free_port() {
111 const int fd = ::socket(AF_INET, SOCK_STREAM, 0);
112 if (fd < 0) throw SymEngineError("SymEngines: cannot open a socket to pick a free port");
113 struct sockaddr_in addr;
114 std::memset(&addr, 0, sizeof(addr));
115 addr.sin_family = AF_INET;
116 addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
117 addr.sin_port = 0;
118 if (::bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) != 0) {
119 ::close(fd);
120 throw SymEngineError("SymEngines: cannot bind a free port");
121 }
122 socklen_t len = sizeof(addr);
123 if (::getsockname(fd, reinterpret_cast<struct sockaddr*>(&addr), &len) != 0) {
124 ::close(fd);
125 throw SymEngineError("SymEngines: cannot read the bound port");
126 }
127 const int port = static_cast<int>(ntohs(addr.sin_port));
128 ::close(fd);
129 return port;
130}
131
132/** Checks that a service is line-sage-rest and not another line-*-rest one. */
133inline bool is_sage_service(const SageRestEngine& engine) {
134 try {
135 return engine.info().contains("sage_version");
136 } catch (const Error&) {
137 return false;
138 }
139}
140
141/**
142 * True if this service serves every route the client calls, saying what is
143 * missing when it does not.
144 *
145 * A service that is line-sage-rest, healthy and arithmetically sound can still
146 * be OLDER THAN THE CLIENT, and accepting it defers the failure to the first
147 * request needing a route it does not have. Refusing here sends the caller down
148 * the same path as "no service at all" -- the native algebra, or a skip -- and
149 * says why. See SageRestEngine::missingRoutes for the run that motivated it.
150 */
151inline bool serves_required_routes(const SageRestEngine& engine) {
152 std::vector<std::string> missing;
153 try {
154 missing = engine.missingRoutes();
155 } catch (const Error&) {
156 return false;
157 }
158 if (missing.empty()) return true;
159 std::cerr << "[LINE] Ignoring the line-sage-rest service at " << engine.getBaseUrl()
160 << ": it is older than this client and does not serve ";
161 for (std::size_t i = 0; i < missing.size(); ++i)
162 std::cerr << (i ? ", " : "") << missing[i];
163 std::cerr << ". Refresh it with 'docker pull " << SYM_DOCKER_IMAGE << "'." << std::endl;
164 return false;
165}
166
167inline void sleep_millis(long millis) {
168 struct timespec ts;
169 ts.tv_sec = millis / 1000;
170 ts.tv_nsec = (millis % 1000) * 1000000L;
171 ::nanosleep(&ts, nullptr);
172}
173
174} // namespace detail
175
176/** Stops the container started by this process, if any. */
177inline void sym_stop_container() {
178 detail::SymState& st = detail::sym_state();
179 std::lock_guard<std::mutex> guard(st.mutex);
180 if (st.container.empty()) return;
181 util::capture({"docker", "stop", "-t", "1", st.container}, 30);
182 st.container.clear();
183 st.started.reset();
184}
185
186/**
187 * @return the first locally present image tag, or the empty string if none is
188 */
189inline std::string sym_find_image() {
190 const std::vector<std::string> candidates = sym_docker_image_candidates();
191 for (std::size_t i = 0; i < candidates.size(); ++i)
192 if (io::docker_has_local_image(candidates[i])) return candidates[i];
193 return std::string();
194}
195
196namespace detail {
197
198/**
199 * Pulls `target` if the Docker storage location has room; returns the tag on
200 * success, else the empty string. On refusal the caller keeps its native
201 * algebra rather than failing.
202 */
203inline std::string pull_image(const std::string& target) {
204 if (!io::docker_has_storage_for(target)) {
205 std::cerr << "[LINE] Skipping docker pull of " << target
206 << ": insufficient free space at the Docker storage location; "
207 << "keeping the native symbolic backend." << std::endl;
208 return std::string();
209 }
210 std::cout << "[LINE] Pulling Docker image " << target << " (this may take a while)..."
211 << std::endl;
212 if (io::docker_pull(target) && io::docker_has_local_image(target)) return target;
213 return std::string();
214}
215
216/** Starts the service in a container and waits for it to report healthy. */
217inline std::shared_ptr<SageRestEngine> start_container(const std::string& image) {
218 const int port = free_port();
219 const std::string name = "line-sage-rest-" + std::to_string(port);
220 const util::ProcResult run =
221 util::capture({"docker", "run", "-d", "--rm", "--name", name, "-p",
222 std::to_string(port) + ":8080", image},
223 120);
224 if (run.exitCode != 0 || util::trim(run.out).empty())
225 throw SymEngineError("SymEngines: could not start " + image);
226
227 SymState& st = sym_state();
228 {
229 std::lock_guard<std::mutex> guard(st.mutex);
230 st.container = name;
231 if (!st.atexitRegistered) {
232 std::atexit(&sym_stop_container);
233 st.atexitRegistered = true;
234 }
235 }
236
237 std::shared_ptr<SageRestEngine> engine =
238 std::make_shared<SageRestEngine>("http://localhost:" + std::to_string(port));
239 for (int waited = 0; waited < SYM_STARTUP_TIMEOUT_SECONDS * 1000; waited += 500) {
240 if (engine->isAvailable()) {
241 if (serves_required_routes(*engine) && engine->isUsable()) {
242 std::lock_guard<std::mutex> guard(st.mutex);
243 st.started = engine;
244 return engine;
245 }
246 // Booted, but its arithmetic dies on this CPU. Keeping it running
247 // would only cost memory, and returning it would hand the caller a
248 // backend that kills every request.
250 throw SymEngineError("SymEngines: container " + name +
251 " answers but cannot evaluate on this CPU");
252 }
253 sleep_millis(500);
254 }
256 throw SymEngineError("SymEngines: container " + name + " did not become healthy within " +
257 std::to_string(SYM_STARTUP_TIMEOUT_SECONDS) + " s");
258}
259
260} // namespace detail
261
262/**
263 * Resolves an engine.
264 *
265 * @param requested "" or "auto" to search, a URL to use a specific service,
266 * "none" to disable the backend, or an image name to start
267 * @return an engine, or a null pointer if no backend could be resolved
268 */
269inline std::shared_ptr<SymEngine> sym_resolve(const std::string& requested = "auto") {
270 const std::string req = util::trim(requested);
271 const std::string reqLower = detail::lower(req);
272 if (reqLower == "none" || reqLower == "off") return std::shared_ptr<SymEngine>();
273
274 if (req.compare(0, 8, "https://") == 0)
275 throw UnsupportedError(
276 "SymEngines: this port's HTTP client has no TLS, so the symbolic service must be "
277 "reached over http://; terminate TLS in front of it or use a local container");
278 if (req.compare(0, 7, "http://") == 0) {
279 std::shared_ptr<SageRestEngine> engine = std::make_shared<SageRestEngine>(req);
280 return engine->isAvailable() && detail::serves_required_routes(*engine) && engine->isUsable()
281 ? engine
282 : std::shared_ptr<SymEngine>();
283 }
284
285 const char* env = std::getenv(SYM_URL_ENV);
286 if (env != nullptr && !util::trim(env).empty()) {
287 const std::string url = util::trim(env);
288 if (url.compare(0, 8, "https://") == 0) {
289 std::cerr << "[LINE] Ignoring " << SYM_URL_ENV
290 << ": this port's HTTP client has no TLS." << std::endl;
291 } else {
292 std::shared_ptr<SageRestEngine> engine = std::make_shared<SageRestEngine>(url);
293 if (engine->isAvailable() && detail::serves_required_routes(*engine) &&
294 engine->isUsable())
295 return engine;
296 }
297 }
298
299 {
300 detail::SymState& st = detail::sym_state();
301 std::shared_ptr<SageRestEngine> cached;
302 {
303 std::lock_guard<std::mutex> guard(st.mutex);
304 cached = st.started;
305 }
306 if (cached && cached->isAvailable() && cached->isUsable()) return cached;
307 }
308
309 const std::vector<int> ports = sym_probe_ports();
310 for (std::size_t i = 0; i < ports.size(); ++i) {
311 std::shared_ptr<SageRestEngine> engine =
312 std::make_shared<SageRestEngine>("http://localhost:" + std::to_string(ports[i]));
313 if (detail::is_sage_service(*engine) && detail::serves_required_routes(*engine) &&
314 engine->isUsable())
315 return engine;
316 }
317
318 const bool search =
319 req.empty() || reqLower == "auto" || reqLower == "true" || reqLower == "sage";
320 std::string image = search ? sym_find_image() : req;
321 if (image.empty() && reqLower == "sage")
322 image = detail::pull_image(SYM_DOCKER_IMAGE);
323 else if (!search && !image.empty() && !io::docker_has_local_image(image))
324 image = detail::pull_image(image);
325 if (image.empty()) return std::shared_ptr<SymEngine>();
326
327 try {
328 return detail::start_container(image);
329 } catch (const Error&) {
330 return std::shared_ptr<SymEngine>();
331 }
332}
333
334} // namespace sym
335} // namespace line
336
337#endif // LINE_API_SYM_SYM_ENGINES_H
Base error for the multiprecision C++ port.
Definition error.h:31
UnsupportedError(const std::string &what)
Definition error.h:51
SymEngineError(const std::string &what)
Definition sym_engine.h:43
Docker primitives for the backends that legitimately ship an image.
The exception types the port throws.
bool docker_has_storage_for(const std::string &image)
bool docker_has_local_image(const std::string &image)
bool docker_pull(const std::string &image)
Pulls an image, streaming Docker's progress to stdout and stderr.
const char *const SYM_URL_ENV
Environment variable naming a service to use.
Definition sym_engines.h:74
constexpr int SYM_STARTUP_TIMEOUT_SECONDS
Seconds to wait for a container to report healthy.
Definition sym_engines.h:76
std::string sym_find_image()
std::shared_ptr< SymEngine > sym_resolve(const std::string &requested="auto")
Resolves an engine.
std::vector< int > sym_probe_ports()
Ports probed for an already running service, in order.
Definition sym_engines.h:85
const char *const SYM_DOCKER_IMAGE
Image serving the symbolic REST API.
Definition sym_engines.h:72
std::vector< std::string > sym_docker_image_candidates()
Fallback tags, tried in order after SYM_DOCKER_IMAGE.
Definition sym_engines.h:79
void sym_stop_container()
Stops the container started by this process, if any.
ProcResult capture(const std::vector< std::string > &argv, int timeoutSeconds, bool mergeStderr=false)
Runs a command, capturing stdout and discarding stderr.
Definition subprocess.h:82
std::string trim(const std::string &s)
Trims ASCII whitespace from both ends, as Java's String.trim() does.
Definition subprocess.h:181
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
SymEngine backed by the line-sage-rest service.
Running an external command and capturing its output, with a deadline.
Computer algebra operations LINE needs, as seen by this port.