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
141inline void sleep_millis(long millis) {
142 struct timespec ts;
143 ts.tv_sec = millis / 1000;
144 ts.tv_nsec = (millis % 1000) * 1000000L;
145 ::nanosleep(&ts, nullptr);
146}
147
148} // namespace detail
149
150/** Stops the container started by this process, if any. */
151inline void sym_stop_container() {
152 detail::SymState& st = detail::sym_state();
153 std::lock_guard<std::mutex> guard(st.mutex);
154 if (st.container.empty()) return;
155 util::capture({"docker", "stop", "-t", "1", st.container}, 30);
156 st.container.clear();
157 st.started.reset();
158}
159
160/**
161 * @return the first locally present image tag, or the empty string if none is
162 */
163inline std::string sym_find_image() {
164 const std::vector<std::string> candidates = sym_docker_image_candidates();
165 for (std::size_t i = 0; i < candidates.size(); ++i)
166 if (io::docker_has_local_image(candidates[i])) return candidates[i];
167 return std::string();
168}
169
170namespace detail {
171
172/**
173 * Pulls `target` if the Docker storage location has room; returns the tag on
174 * success, else the empty string. On refusal the caller keeps its native
175 * algebra rather than failing.
176 */
177inline std::string pull_image(const std::string& target) {
178 if (!io::docker_has_storage_for(target)) {
179 std::cerr << "[LINE] Skipping docker pull of " << target
180 << ": insufficient free space at the Docker storage location; "
181 << "keeping the native symbolic backend." << std::endl;
182 return std::string();
183 }
184 std::cout << "[LINE] Pulling Docker image " << target << " (this may take a while)..."
185 << std::endl;
186 if (io::docker_pull(target) && io::docker_has_local_image(target)) return target;
187 return std::string();
188}
189
190/** Starts the service in a container and waits for it to report healthy. */
191inline std::shared_ptr<SageRestEngine> start_container(const std::string& image) {
192 const int port = free_port();
193 const std::string name = "line-sage-rest-" + std::to_string(port);
194 const util::ProcResult run =
195 util::capture({"docker", "run", "-d", "--rm", "--name", name, "-p",
196 std::to_string(port) + ":8080", image},
197 120);
198 if (run.exitCode != 0 || util::trim(run.out).empty())
199 throw SymEngineError("SymEngines: could not start " + image);
200
201 SymState& st = sym_state();
202 {
203 std::lock_guard<std::mutex> guard(st.mutex);
204 st.container = name;
205 if (!st.atexitRegistered) {
206 std::atexit(&sym_stop_container);
207 st.atexitRegistered = true;
208 }
209 }
210
211 std::shared_ptr<SageRestEngine> engine =
212 std::make_shared<SageRestEngine>("http://localhost:" + std::to_string(port));
213 for (int waited = 0; waited < SYM_STARTUP_TIMEOUT_SECONDS * 1000; waited += 500) {
214 if (engine->isAvailable()) {
215 if (engine->isUsable()) {
216 std::lock_guard<std::mutex> guard(st.mutex);
217 st.started = engine;
218 return engine;
219 }
220 // Booted, but its arithmetic dies on this CPU. Keeping it running
221 // would only cost memory, and returning it would hand the caller a
222 // backend that kills every request.
224 throw SymEngineError("SymEngines: container " + name +
225 " answers but cannot evaluate on this CPU");
226 }
227 sleep_millis(500);
228 }
230 throw SymEngineError("SymEngines: container " + name + " did not become healthy within " +
231 std::to_string(SYM_STARTUP_TIMEOUT_SECONDS) + " s");
232}
233
234} // namespace detail
235
236/**
237 * Resolves an engine.
238 *
239 * @param requested "" or "auto" to search, a URL to use a specific service,
240 * "none" to disable the backend, or an image name to start
241 * @return an engine, or a null pointer if no backend could be resolved
242 */
243inline std::shared_ptr<SymEngine> sym_resolve(const std::string& requested = "auto") {
244 const std::string req = util::trim(requested);
245 const std::string reqLower = detail::lower(req);
246 if (reqLower == "none" || reqLower == "off") return std::shared_ptr<SymEngine>();
247
248 if (req.compare(0, 8, "https://") == 0)
249 throw UnsupportedError(
250 "SymEngines: this port's HTTP client has no TLS, so the symbolic service must be "
251 "reached over http://; terminate TLS in front of it or use a local container");
252 if (req.compare(0, 7, "http://") == 0) {
253 std::shared_ptr<SageRestEngine> engine = std::make_shared<SageRestEngine>(req);
254 return engine->isAvailable() && engine->isUsable() ? engine : std::shared_ptr<SymEngine>();
255 }
256
257 const char* env = std::getenv(SYM_URL_ENV);
258 if (env != nullptr && !util::trim(env).empty()) {
259 const std::string url = util::trim(env);
260 if (url.compare(0, 8, "https://") == 0) {
261 std::cerr << "[LINE] Ignoring " << SYM_URL_ENV
262 << ": this port's HTTP client has no TLS." << std::endl;
263 } else {
264 std::shared_ptr<SageRestEngine> engine = std::make_shared<SageRestEngine>(url);
265 if (engine->isAvailable() && engine->isUsable()) return engine;
266 }
267 }
268
269 {
270 detail::SymState& st = detail::sym_state();
271 std::shared_ptr<SageRestEngine> cached;
272 {
273 std::lock_guard<std::mutex> guard(st.mutex);
274 cached = st.started;
275 }
276 if (cached && cached->isAvailable() && cached->isUsable()) return cached;
277 }
278
279 const std::vector<int> ports = sym_probe_ports();
280 for (std::size_t i = 0; i < ports.size(); ++i) {
281 std::shared_ptr<SageRestEngine> engine =
282 std::make_shared<SageRestEngine>("http://localhost:" + std::to_string(ports[i]));
283 if (detail::is_sage_service(*engine) && engine->isUsable()) return engine;
284 }
285
286 const bool search =
287 req.empty() || reqLower == "auto" || reqLower == "true" || reqLower == "sage";
288 std::string image = search ? sym_find_image() : req;
289 if (image.empty() && reqLower == "sage")
290 image = detail::pull_image(SYM_DOCKER_IMAGE);
291 else if (!search && !image.empty() && !io::docker_has_local_image(image))
292 image = detail::pull_image(image);
293 if (image.empty()) return std::shared_ptr<SymEngine>();
294
295 try {
296 return detail::start_container(image);
297 } catch (const Error&) {
298 return std::shared_ptr<SymEngine>();
299 }
300}
301
302} // namespace sym
303} // namespace line
304
305#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
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.