Entropic 2.14.3
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
11
12#include <cerrno>
13#include <cstring>
14#include <chrono>
15
16#include <fcntl.h>
17#include <poll.h>
18#include <signal.h>
19#include <spawn.h>
20#include <sys/wait.h>
21#include <unistd.h>
22
23namespace {
29struct IgnoreSigpipe {
35 IgnoreSigpipe() { signal(SIGPIPE, SIG_IGN); }
36};
37static IgnoreSigpipe ignore_sigpipe;
38} // namespace
39
40extern char** environ;
41
42static auto logger = entropic::log::get("mcp.transport.stdio");
43
44namespace entropic {
45
64static std::string sanitize_display_name(const std::string& raw) {
65 std::string out;
66 out.reserve(raw.size());
67 for (char c : raw) {
68 if (out.empty() && c == '/') { continue; }
69 if (c == '[' || c == ']') { continue; }
70 if (static_cast<unsigned char>(c) < 0x20 || c == 0x7f) { continue; }
71 out.push_back(c);
72 }
73 return out.empty() ? std::string{"server"} : out;
74}
75
86 std::string command,
87 std::vector<std::string> args,
88 std::map<std::string, std::string> env,
90 : display_name_(sanitize_display_name(command)),
91 command_(std::move(command)),
92 args_(std::move(args)),
93 env_(std::move(env)),
94 default_timeout_ms_(default_timeout_ms) {}
95
112 std::string display_name,
113 std::string command,
114 std::vector<std::string> args,
115 std::map<std::string, std::string> env,
117 : display_name_(sanitize_display_name(
118 display_name.empty() ? command : std::move(display_name))),
119 command_(std::move(command)),
120 args_(std::move(args)),
121 env_(std::move(env)),
122 default_timeout_ms_(default_timeout_ms) {}
123
132
140bool StdioTransport::create_all_pipes(int (&fds)[6]) {
141 bool ok = create_pipe(fds[0], fds[1]) &&
142 create_pipe(fds[2], fds[3]) &&
143 create_pipe(fds[4], fds[5]);
144 if (!ok) {
145 logger->error("Failed to create pipes: {}", strerror(errno));
146 for (auto& fd : fds) { close_fd(fd); }
147 }
148 return ok;
149}
150
158 // gh#150: clear FIRST, and on both paths. open() means "I want this
159 // usable"; returning a transport that silently drops every call is
160 // never the useful reading. The early return below is why placing
161 // this after it would have left a live-but-latched transport stuck.
162 cancel_flag_.store(false, std::memory_order_release);
163
164 if (connected_) {
165 return true;
166 }
167
168 if (!open_child_process()) {
169 return false;
170 }
171
172 connected_ = true;
173 stderr_thread_ = std::thread(
174 &StdioTransport::stderr_reader_loop, this);
175
176 logger->info("Spawned child process PID {} for '{}' (cmd: {})",
177 child_pid_, display_name_, command_);
178 return true;
179}
180
187bool StdioTransport::open_child_process() {
188 int fds[6] = {-1, -1, -1, -1, -1, -1};
189 if (!create_all_pipes(fds)) {
190 return false;
191 }
192
193 auto env_strs = build_env();
194 bool ok = spawn_child(fds[0], fds[3], fds[5], env_strs);
195
196 // Close child-side pipe ends in parent
197 close_fd(fds[0]);
198 close_fd(fds[3]);
199 close_fd(fds[5]);
200
201 if (!ok) {
202 close_fd(fds[1]);
203 close_fd(fds[2]);
204 close_fd(fds[4]);
205 return false;
206 }
207
208 stdin_fd_ = fds[1];
209 stdout_fd_ = fds[2];
210 stderr_fd_ = fds[4];
211 return true;
212}
213
220 connected_ = false;
221
222 terminate_child();
223
224 // gh#158 (v2.13.0): JOIN BEFORE CLOSING, not after. `close_fd` writes
225 // `stderr_fd_ = -1` while `stderr_reader_loop` is still polling and
226 // reading that same member — a data race on an int and a read from an
227 // fd number that may already have been reused by another thread's
228 // open(). ThreadSanitizer reports it at close_fd's assignment. The loop
229 // exits on `connected_` (false above) or on the child's EOF, so the
230 // join costs at most one 500ms poll slice, which `terminate_child`'s
231 // 3s reap has usually already absorbed.
232 if (stderr_thread_.joinable()) {
233 stderr_thread_.join();
234 }
235
236 close_fd(stdin_fd_);
237 close_fd(stdout_fd_);
238 close_fd(stderr_fd_);
239
240 logger->info("Closed stdio transport for '{}'", display_name_);
241}
242
259 const std::string& request_json,
261
262 if (!connected_ || request_cancelled()) {
263 return "";
264 }
265
267 ? timeout_ms : default_timeout_ms_;
268
269 std::lock_guard<std::mutex> lock(io_mutex_);
270
271 if (desynced_) {
272 int dropped = drain_orphaned_responses();
273 desynced_ = false;
274 logger->warn("Discarded {} orphaned response line(s) from '{}' "
275 "before sending the next request", dropped,
276 display_name_);
277 }
278
279 std::string msg = request_json + "\n";
280 ssize_t written = ::write(stdin_fd_, msg.data(), msg.size());
281 if (written < 0 || static_cast<size_t>(written) != msg.size()) {
282 logger->error("Write to child stdin failed: {}",
283 strerror(errno));
284 connected_ = false;
285 return "";
286 }
287
288 // gh#158: the request is on the wire. Anything other than a complete
289 // line back means we walked away from a reply the server will still
290 // send, so mark the pipe desynced and let the next caller drain it.
291 auto response = read_line(stdout_fd_, actual_timeout);
292 desynced_ = response.empty();
293 return response;
294}
295
303int StdioTransport::drain_orphaned_responses() {
304 constexpr int kMaxLines = 64;
305 constexpr size_t kMaxBytes = 1u << 20; // 1 MiB
306 int lines = 0;
307 size_t bytes = 0;
308 while (lines < kMaxLines && bytes < kMaxBytes) {
309 struct pollfd pfd{stdout_fd_, POLLIN, 0};
310 if (::poll(&pfd, 1, 0) <= 0) { break; }
311 char ch = 0;
312 if (::read(stdout_fd_, &ch, 1) <= 0) { break; }
313 ++bytes;
314 if (ch == '\n') { ++lines; }
315 }
316 return lines;
317}
318
326 if (!connected_) {
327 return false;
328 }
329 if (child_pid_ > 0 && kill(child_pid_, 0) == 0) {
330 return true;
331 }
332 return false;
333}
334
358 cancel_flag_.store(true, std::memory_order_release);
359}
360
371 cancel_flag_.store(false, std::memory_order_release);
372}
373
381 return cancel_flag_.load(std::memory_order_acquire);
382}
383
409 return cancel_flag_.load(std::memory_order_acquire)
411}
412
421static std::vector<char*> build_argv(const std::string& command,
422 const std::vector<std::string>& args) {
423 std::vector<char*> argv;
424 argv.push_back(const_cast<char*>(command.c_str()));
425 for (const auto& arg : args) {
426 argv.push_back(const_cast<char*>(arg.c_str()));
427 }
428 argv.push_back(nullptr);
429 return argv;
430}
431
439static std::vector<char*> build_envp(
440 const std::vector<std::string>& env_strs) {
441 std::vector<char*> envp;
442 for (const auto& e : env_strs) {
443 envp.push_back(const_cast<char*>(e.c_str()));
444 }
445 envp.push_back(nullptr);
446 return envp;
447}
448
465bool StdioTransport::spawn_child(
466 int stdin_r, int stdout_w, int stderr_w,
467 const std::vector<std::string>& env_strs) {
468
474 if (!working_dir_.empty()) {
475 posix_spawn_file_actions_addchdir_np(&actions, working_dir_.c_str());
476 }
477
478 auto argv = build_argv(command_, args_);
479 auto envp = build_envp(env_strs);
480
481 int err = posix_spawnp(
482 &child_pid_, command_.c_str(), &actions,
483 nullptr, argv.data(), envp.data());
484
486
487 if (err != 0) {
488 logger->error("posix_spawnp failed for '{}' (cmd: {}, cwd: '{}'): {}",
489 display_name_, command_, working_dir_, strerror(err));
490 child_pid_ = -1;
491 return false;
492 }
493 logger->info("Spawned '{}' (pid {}) in '{}'", display_name_, child_pid_,
494 working_dir_.empty() ? "<host cwd>" : working_dir_);
495 return true;
496}
497
510std::vector<std::string> StdioTransport::build_env() const {
511 std::map<std::string, std::string> merged;
512
513 // Copy parent environment
514 for (char** ep = environ; ep && *ep; ++ep) {
515 std::string entry(*ep);
516 auto eq = entry.find('=');
517 if (eq != std::string::npos) {
518 merged[entry.substr(0, eq)] = entry.substr(eq + 1);
519 }
520 }
521
522 // Apply overrides
523 for (const auto& [key, val] : env_) {
524 merged[key] = val;
525 }
526
527 std::vector<std::string> result;
528 result.reserve(merged.size());
529 for (const auto& [key, val] : merged) {
530 result.push_back(key + "=" + val);
531 }
532 return result;
533}
534
543bool StdioTransport::create_pipe(int& read_fd, int& write_fd) {
544 int fds[2];
545 if (::pipe(fds) != 0) {
546 return false;
547 }
550 read_fd = fds[0];
551 write_fd = fds[1];
552 return true;
553}
554
563int StdioTransport::poll_until_ready(
564 int fd,
565 std::chrono::steady_clock::time_point deadline) {
566
567 auto remaining = std::chrono::duration_cast<
568 std::chrono::milliseconds>(
569 deadline - std::chrono::steady_clock::now());
570
571 if (remaining.count() <= 0) {
572 return 0;
573 }
574
575 // P1-10 (2.0.6-rc16): cap the single poll slice at 100ms so the
576 // caller's cancel_flag_ is re-checked at least ten times/second.
577 constexpr int kSliceMs = 100;
578 int slice = static_cast<int>(remaining.count());
579 if (slice > kSliceMs) { slice = kSliceMs; }
580
581 struct pollfd pfd{fd, POLLIN, 0};
582 int rc = ::poll(&pfd, 1, slice);
583 if (rc == 0) {
584 // Slice expired; report 0 only if the full deadline passed.
585 if (std::chrono::steady_clock::now() < deadline) {
586 return -2; // caller retries after checking cancel_flag
587 }
588 }
589 return rc;
590}
591
605std::string StdioTransport::read_line(int fd, uint32_t timeout_ms) {
606 std::string line;
607 auto deadline = std::chrono::steady_clock::now() +
608 std::chrono::milliseconds(timeout_ms);
609
610 while (true) {
611 // P1-10: short-circuit if the engine interrupted this request.
612 if (request_cancelled()) {
613 logger->info("Transport read cancelled by interrupt");
614 break;
615 }
616 int ready = poll_until_ready(fd, deadline);
617 if (ready == -2) { continue; } // slice expired, re-check cancel
618 if (ready <= 0) {
619 if (ready == 0) { logger->warn("Read timeout after {}ms", timeout_ms); }
620 break;
621 }
622 char ch = 0;
623 if (::read(fd, &ch, 1) <= 0) { break; }
624 if (ch == '\n') { return line; }
625 line += ch;
626 }
627 return "";
628}
629
635void StdioTransport::stderr_reader_loop() {
636 constexpr int poll_timeout_ms = 500;
637 while (connected_) {
638 struct pollfd pfd{stderr_fd_, POLLIN, 0};
639 int ret = ::poll(&pfd, 1, poll_timeout_ms);
640 if (ret <= 0) {
641 continue;
642 }
643
644 char buf[1024];
645 ssize_t n = ::read(stderr_fd_, buf, sizeof(buf) - 1);
646 if (n <= 0) {
647 break;
648 }
649 buf[n] = '\0';
650 logger->warn("[{}] {}", display_name_, buf);
651 }
652}
653
659void StdioTransport::terminate_child() {
660 if (child_pid_ <= 0) {
661 return;
662 }
663
664 ::kill(child_pid_, SIGTERM);
665
666 // Wait up to 3 seconds for graceful exit
667 constexpr int max_wait_ms = 3000;
668 constexpr int poll_interval_ms = 50;
669 int waited = 0;
670 while (waited < max_wait_ms) {
671 int status = 0;
672 pid_t result = ::waitpid(child_pid_, &status, WNOHANG);
673 if (result == child_pid_) {
674 child_pid_ = -1;
675 return;
676 }
677 std::this_thread::sleep_for(
678 std::chrono::milliseconds(poll_interval_ms));
680 }
681
682 // Force kill
683 ::kill(child_pid_, SIGKILL);
684 ::waitpid(child_pid_, nullptr, 0);
685 logger->warn("Force-killed child PID {}", child_pid_);
686 child_pid_ = -1;
687}
688
695void StdioTransport::close_fd(int& fd) {
696 if (fd >= 0) {
697 ::close(fd);
698 fd = -1;
699 }
700}
701
702} // namespace entropic
bool is_interrupted() const override
Whether an interrupt is currently short-circuiting calls.
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 the child, join the stderr pump, 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.
bool request_cancelled() const
Whether THIS request must abort (gh#158, v2.13.0).
~StdioTransport() override
Destructor — ensures child is cleaned up.
void clear_interrupt() override
Release the cancel flag so this transport works again.
json lines
Lines taken so far.
std::string err
size_exceeded, when set
long bytes
Content bytes taken.
int read_fd
Our end.
std::size_t remaining
Unexamined count.
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.
@ optional
optional: every escape asks the host's path approver
ENTROPIC_EXPORT bool current_run_cancelled()
Whether THIS thread's run has been cancelled.
Definition run_scope.cpp:45
@ read
read_file, list_directory
@ write
write_file, edit_file
gh#158: the cancel token of the run executing on THIS thread.
Stdio transport for external MCP servers.