Entropic 2.11.1
Local-first agentic inference engine
Loading...
Searching...
No Matches
transport_stdio.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
10
11#include <cerrno>
12#include <cstring>
13#include <chrono>
14
15#include <fcntl.h>
16#include <poll.h>
17#include <signal.h>
18#include <spawn.h>
19#include <sys/wait.h>
20#include <unistd.h>
21
22namespace {
28struct IgnoreSigpipe {
34 IgnoreSigpipe() { signal(SIGPIPE, SIG_IGN); }
35};
36static IgnoreSigpipe ignore_sigpipe;
37} // namespace
38
39extern char** environ;
40
41static auto logger = entropic::log::get("mcp.transport.stdio");
42
43namespace entropic {
44
63static std::string sanitize_display_name(const std::string& raw) {
64 std::string out;
65 out.reserve(raw.size());
66 for (char c : raw) {
67 if (out.empty() && c == '/') { continue; }
68 if (c == '[' || c == ']') { continue; }
69 if (static_cast<unsigned char>(c) < 0x20 || c == 0x7f) { continue; }
70 out.push_back(c);
71 }
72 return out.empty() ? std::string{"server"} : out;
73}
74
85 std::string command,
86 std::vector<std::string> args,
87 std::map<std::string, std::string> env,
88 uint32_t default_timeout_ms)
89 : display_name_(sanitize_display_name(command)),
90 command_(std::move(command)),
91 args_(std::move(args)),
92 env_(std::move(env)),
93 default_timeout_ms_(default_timeout_ms) {}
94
111 std::string display_name,
112 std::string command,
113 std::vector<std::string> args,
114 std::map<std::string, std::string> env,
115 uint32_t default_timeout_ms)
116 : display_name_(sanitize_display_name(
117 display_name.empty() ? command : std::move(display_name))),
118 command_(std::move(command)),
119 args_(std::move(args)),
120 env_(std::move(env)),
121 default_timeout_ms_(default_timeout_ms) {}
122
131
139bool StdioTransport::create_all_pipes(int (&fds)[6]) {
140 bool ok = create_pipe(fds[0], fds[1]) &&
141 create_pipe(fds[2], fds[3]) &&
142 create_pipe(fds[4], fds[5]);
143 if (!ok) {
144 logger->error("Failed to create pipes: {}", strerror(errno));
145 for (auto& fd : fds) { close_fd(fd); }
146 }
147 return ok;
148}
149
157 if (connected_) {
158 return true;
159 }
160
161 if (!open_child_process()) {
162 return false;
163 }
164
165 connected_ = true;
166 stderr_thread_ = std::thread(
167 &StdioTransport::stderr_reader_loop, this);
168
169 logger->info("Spawned child process PID {} for '{}' (cmd: {})",
170 child_pid_, display_name_, command_);
171 return true;
172}
173
180bool StdioTransport::open_child_process() {
181 int fds[6] = {-1, -1, -1, -1, -1, -1};
182 if (!create_all_pipes(fds)) {
183 return false;
184 }
185
186 auto env_strs = build_env();
187 bool ok = spawn_child(fds[0], fds[3], fds[5], env_strs);
188
189 // Close child-side pipe ends in parent
190 close_fd(fds[0]);
191 close_fd(fds[3]);
192 close_fd(fds[5]);
193
194 if (!ok) {
195 close_fd(fds[1]);
196 close_fd(fds[2]);
197 close_fd(fds[4]);
198 return false;
199 }
200
201 stdin_fd_ = fds[1];
202 stdout_fd_ = fds[2];
203 stderr_fd_ = fds[4];
204 return true;
205}
206
213 connected_ = false;
214
215 terminate_child();
216 close_fd(stdin_fd_);
217 close_fd(stdout_fd_);
218 close_fd(stderr_fd_);
219
220 if (stderr_thread_.joinable()) {
221 stderr_thread_.join();
222 }
223
224 logger->info("Closed stdio transport for '{}'", display_name_);
225}
226
240 const std::string& request_json,
241 uint32_t timeout_ms) {
242
243 if (!connected_ || cancel_flag_.load(std::memory_order_acquire)) {
244 return "";
245 }
246
247 uint32_t actual_timeout = timeout_ms > 0
248 ? timeout_ms : default_timeout_ms_;
249
250 std::lock_guard<std::mutex> lock(io_mutex_);
251
252 std::string msg = request_json + "\n";
253 ssize_t written = ::write(stdin_fd_, msg.data(), msg.size());
254 if (written < 0 || static_cast<size_t>(written) != msg.size()) {
255 logger->error("Write to child stdin failed: {}",
256 strerror(errno));
257 connected_ = false;
258 return "";
259 }
260
261 return read_line(stdout_fd_, actual_timeout);
262}
263
271 if (!connected_) {
272 return false;
273 }
274 if (child_pid_ > 0 && kill(child_pid_, 0) == 0) {
275 return true;
276 }
277 return false;
278}
279
295 cancel_flag_.store(true, std::memory_order_release);
296}
297
306static std::vector<char*> build_argv(const std::string& command,
307 const std::vector<std::string>& args) {
308 std::vector<char*> argv;
309 argv.push_back(const_cast<char*>(command.c_str()));
310 for (const auto& arg : args) {
311 argv.push_back(const_cast<char*>(arg.c_str()));
312 }
313 argv.push_back(nullptr);
314 return argv;
315}
316
324static std::vector<char*> build_envp(
325 const std::vector<std::string>& env_strs) {
326 std::vector<char*> envp;
327 for (const auto& e : env_strs) {
328 envp.push_back(const_cast<char*>(e.c_str()));
329 }
330 envp.push_back(nullptr);
331 return envp;
332}
333
344bool StdioTransport::spawn_child(
345 int stdin_r, int stdout_w, int stderr_w,
346 const std::vector<std::string>& env_strs) {
347
348 posix_spawn_file_actions_t actions;
349 posix_spawn_file_actions_init(&actions);
350 posix_spawn_file_actions_adddup2(&actions, stdin_r, STDIN_FILENO);
351 posix_spawn_file_actions_adddup2(&actions, stdout_w, STDOUT_FILENO);
352 posix_spawn_file_actions_adddup2(&actions, stderr_w, STDERR_FILENO);
353
354 auto argv = build_argv(command_, args_);
355 auto envp = build_envp(env_strs);
356
357 int err = posix_spawnp(
358 &child_pid_, command_.c_str(), &actions,
359 nullptr, argv.data(), envp.data());
360
361 posix_spawn_file_actions_destroy(&actions);
362
363 if (err != 0) {
364 logger->error("posix_spawnp failed for '{}' (cmd: {}): {}",
365 display_name_, command_, strerror(err));
366 child_pid_ = -1;
367 return false;
368 }
369 return true;
370}
371
384std::vector<std::string> StdioTransport::build_env() const {
385 std::map<std::string, std::string> merged;
386
387 // Copy parent environment
388 for (char** ep = environ; ep && *ep; ++ep) {
389 std::string entry(*ep);
390 auto eq = entry.find('=');
391 if (eq != std::string::npos) {
392 merged[entry.substr(0, eq)] = entry.substr(eq + 1);
393 }
394 }
395
396 // Apply overrides
397 for (const auto& [key, val] : env_) {
398 merged[key] = val;
399 }
400
401 std::vector<std::string> result;
402 result.reserve(merged.size());
403 for (const auto& [key, val] : merged) {
404 result.push_back(key + "=" + val);
405 }
406 return result;
407}
408
417bool StdioTransport::create_pipe(int& read_fd, int& write_fd) {
418 int fds[2];
419 if (::pipe(fds) != 0) {
420 return false;
421 }
422 ::fcntl(fds[0], F_SETFD, FD_CLOEXEC);
423 ::fcntl(fds[1], F_SETFD, FD_CLOEXEC);
424 read_fd = fds[0];
425 write_fd = fds[1];
426 return true;
427}
428
437int StdioTransport::poll_until_ready(
438 int fd,
439 std::chrono::steady_clock::time_point deadline) {
440
441 auto remaining = std::chrono::duration_cast<
442 std::chrono::milliseconds>(
443 deadline - std::chrono::steady_clock::now());
444
445 if (remaining.count() <= 0) {
446 return 0;
447 }
448
449 // P1-10 (2.0.6-rc16): cap the single poll slice at 100ms so the
450 // caller's cancel_flag_ is re-checked at least ten times/second.
451 constexpr int kSliceMs = 100;
452 int slice = static_cast<int>(remaining.count());
453 if (slice > kSliceMs) { slice = kSliceMs; }
454
455 struct pollfd pfd{fd, POLLIN, 0};
456 int rc = ::poll(&pfd, 1, slice);
457 if (rc == 0) {
458 // Slice expired; report 0 only if the full deadline passed.
459 if (std::chrono::steady_clock::now() < deadline) {
460 return -2; // caller retries after checking cancel_flag
461 }
462 }
463 return rc;
464}
465
479std::string StdioTransport::read_line(int fd, uint32_t timeout_ms) {
480 std::string line;
481 auto deadline = std::chrono::steady_clock::now() +
482 std::chrono::milliseconds(timeout_ms);
483
484 while (true) {
485 // P1-10: short-circuit if the engine interrupted this request.
486 if (cancel_flag_.load(std::memory_order_acquire)) {
487 logger->info("Transport read cancelled by interrupt");
488 break;
489 }
490 int ready = poll_until_ready(fd, deadline);
491 if (ready == -2) { continue; } // slice expired, re-check cancel
492 if (ready <= 0) {
493 if (ready == 0) { logger->warn("Read timeout after {}ms", timeout_ms); }
494 break;
495 }
496 char ch = 0;
497 if (::read(fd, &ch, 1) <= 0) { break; }
498 if (ch == '\n') { return line; }
499 line += ch;
500 }
501 return "";
502}
503
509void StdioTransport::stderr_reader_loop() {
510 constexpr int poll_timeout_ms = 500;
511 while (connected_) {
512 struct pollfd pfd{stderr_fd_, POLLIN, 0};
513 int ret = ::poll(&pfd, 1, poll_timeout_ms);
514 if (ret <= 0) {
515 continue;
516 }
517
518 char buf[1024];
519 ssize_t n = ::read(stderr_fd_, buf, sizeof(buf) - 1);
520 if (n <= 0) {
521 break;
522 }
523 buf[n] = '\0';
524 logger->warn("[{}] {}", display_name_, buf);
525 }
526}
527
533void StdioTransport::terminate_child() {
534 if (child_pid_ <= 0) {
535 return;
536 }
537
538 ::kill(child_pid_, SIGTERM);
539
540 // Wait up to 3 seconds for graceful exit
541 constexpr int max_wait_ms = 3000;
542 constexpr int poll_interval_ms = 50;
543 int waited = 0;
544 while (waited < max_wait_ms) {
545 int status = 0;
546 pid_t result = ::waitpid(child_pid_, &status, WNOHANG);
547 if (result == child_pid_) {
548 child_pid_ = -1;
549 return;
550 }
551 std::this_thread::sleep_for(
552 std::chrono::milliseconds(poll_interval_ms));
553 waited += poll_interval_ms;
554 }
555
556 // Force kill
557 ::kill(child_pid_, SIGKILL);
558 ::waitpid(child_pid_, nullptr, 0);
559 logger->warn("Force-killed child PID {}", child_pid_);
560 child_pid_ = -1;
561}
562
569void StdioTransport::close_fd(int& fd) {
570 if (fd >= 0) {
571 ::close(fd);
572 fd = -1;
573 }
574}
575
576} // namespace entropic
bool open() override
Spawn child process and open pipes.
bool is_connected() const override
Check if child process is alive.
void interrupt() override
Abort any blocking read and mark transport uncancellable.
std::string send_request(const std::string &request_json, uint32_t timeout_ms=0) override
Send JSON-RPC request via stdin, read response from stdout.
void close() override
Send SIGTERM, reap child, close pipes.
StdioTransport(std::string command, std::vector< std::string > args, std::map< std::string, std::string > env={}, uint32_t default_timeout_ms=30000)
Construct with command, arguments, and environment.
~StdioTransport() override
Destructor — ensures child is cleaned up.
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
Activate model on GPU (WARM → ACTIVE).
static std::vector< char * > build_envp(const std::vector< std::string > &env_strs)
Build a NULL-terminated envp from env strings.
static std::string sanitize_display_name(const std::string &raw)
Sanitize a display_name for use as a log-line bracket label.
static std::vector< char * > build_argv(const std::string &command, const std::vector< std::string > &args)
Build a NULL-terminated argv from command + args.
@ ok
Tool dispatched, returned non-empty content.
Stdio transport for external MCP servers.