onetaskgraph_core/subprocess/connection.rs
1//! One spawned plugin, and the one exchange at a time the engine has with it.
2//!
3//! The transport is deliberately hand-rolled, for the reason `engine/join.rs` gives for
4//! its own combinator: this crate is written against `std::future` alone and runs on
5//! whatever runtime its caller brings, so it may not reach for a runtime's process or
6//! channel types. What it must not do instead is block: a blocking read inside an `async
7//! fn` would stall every *other* source's future on the same task, and asking every
8//! source at once is the property the engine is built on. So the blocking half runs on an
9//! ordinary thread and the async half waits on [`Answer`], a one-shot that parks the
10//! caller's waker until that thread has a line.
11//!
12//! Requests on one connection are serialized, which the protocol permits and §1.1 names
13//! as the simpler correct choice. The concurrency that matters here is *across* sources,
14//! and that is the caller's: the engine drives one future per source.
15
16use std::io::{BufRead, BufReader, Read, Write};
17use std::process::{Child, ChildStderr, Command, Stdio};
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::mpsc::{Sender, channel};
20use std::sync::{Arc, Mutex};
21use std::task::{Context, Poll, Waker};
22use std::time::Duration;
23
24use onetaskgraph_plugin_api::SourceError;
25use serde_json::Value;
26
27use super::wire::{Request, Response};
28
29/// How much of an offending line §6.3 quotes back. Long enough to recognise the message,
30/// short enough that a plugin echoing a whole page of tasks cannot fill a terminal.
31const QUOTED: usize = 200;
32
33/// The most a peer may put on one line before this side stops reading it.
34///
35/// A line is read into memory before anything can be said about it, so a peer that never
36/// writes a newline is a peer that decides how much memory this process uses. Sixteen
37/// mebibytes is far above any real page — a source declaring a page size of ten thousand
38/// and a kibibyte of prose per task is a tenth of it — and far below anything that
39/// threatens a host, which is the whole of what a bound like this is for.
40pub const MAX_LINE: u64 = 16 * 1024 * 1024;
41
42/// How much of a plugin's standard error is kept for diagnostics. Bounded because a
43/// plugin that logs in a loop must not be able to grow this engine's memory without end —
44/// the invariant this product is built on is that the engine holds work data transiently,
45/// and a diagnostics buffer with no ceiling is a way to hold it for ever by accident.
46const KEPT_DIAGNOSTICS: usize = 4096;
47
48/// What reading one line from a peer produced.
49pub(crate) enum Line {
50 /// A line, with its terminator still on it.
51 Read(String),
52 /// The peer closed the stream with nothing more to say.
53 Ended,
54 /// The peer wrote [`MAX_LINE`] bytes without ending the line.
55 TooLong,
56 /// The stream itself failed.
57 Failed(std::io::Error),
58}
59
60/// Read one line, refusing a peer that never ends one.
61///
62/// The bound is applied *before* the allocation rather than after it, which is the whole
63/// point: checking the length of a line already in memory is a check made too late.
64pub(crate) fn read_line(reader: &mut (impl BufRead + ?Sized)) -> Line {
65 let mut line = String::new();
66 match reader.take(MAX_LINE).read_line(&mut line) {
67 Err(error) => Line::Failed(error),
68 Ok(0) => Line::Ended,
69 Ok(_) if !line.ends_with('\n') => Line::TooLong,
70 Ok(_) => Line::Read(line),
71 }
72}
73
74/// A live plugin process, with a thread doing its blocking input and output.
75pub(crate) struct Connection {
76 /// Where a request line goes. `None` once the worker has given up, so that a caller
77 /// after a fatal error gets that error rather than a wait nothing will end.
78 jobs: Mutex<Option<Sender<Job>>>,
79 /// Whatever the plugin has written to standard error, bounded and shared.
80 diagnostics: Arc<Mutex<String>>,
81 /// The next request id. Opaque to the plugin, which echoes it byte for byte (§1.1).
82 next_id: AtomicU64,
83 /// Held so the child is reaped, and killed, when this connection is dropped.
84 ///
85 /// Absent when the peer is not a process this engine owns — [`Peer::over`] connects to
86 /// one that is already running, and killing something it did not start is not this
87 /// type's to do.
88 child: Arc<Mutex<Option<Child>>>,
89 deadline: Duration,
90}
91
92/// One request line and the slot its answer belongs in.
93struct Job {
94 /// The serialized request, without its terminating line feed.
95 line: String,
96 /// Where the worker puts the answer.
97 slot: Arc<Slot>,
98}
99
100impl Connection {
101 /// Adopt a plugin whose handshake has already been answered.
102 ///
103 /// Taking the streams *after* the handshake is what lets the handshake itself be an
104 /// ordinary blocking exchange: it happens while the source is being built, where the
105 /// contract's `build` is synchronous anyway, so no runtime is involved and no other
106 /// source's future exists yet to be stalled.
107 pub(crate) fn adopt(peer: Peer) -> Self {
108 let Peer {
109 child,
110 mut writer,
111 mut reader,
112 stderr,
113 request_deadline,
114 handshake_deadline: _,
115 } = peer;
116 let diagnostics = Arc::new(Mutex::new(String::new()));
117 if let Some(stderr) = stderr {
118 drain(stderr, Arc::clone(&diagnostics));
119 }
120 let (sender, receiver) = channel::<Job>();
121 std::thread::spawn(move || {
122 for job in &receiver {
123 let answer = exchange(&mut writer, &mut reader, &job.line);
124 let fatal = answer.is_err();
125 job.slot.fill(answer);
126 if fatal {
127 break;
128 }
129 }
130 // Dropping the receiver is what turns a later `send` into an error instead of
131 // a wait nothing would end. Anything already queued is failed by name first.
132 for job in receiver.try_iter() {
133 job.slot.fill(Err(SourceError::Unavailable {
134 message: "the plugin connection closed before this request was sent".to_owned(),
135 }));
136 }
137 });
138 Self {
139 jobs: Mutex::new(Some(sender)),
140 diagnostics,
141 next_id: AtomicU64::new(1),
142 child,
143 deadline: request_deadline,
144 }
145 }
146
147 /// Send one method call and wait for the line that answers it.
148 ///
149 /// # Errors
150 ///
151 /// Returns the plugin's own [`SourceError`] when it answered with one, and
152 /// [`SourceError::Unavailable`] or [`SourceError::Malformed`] when the connection
153 /// failed or the answer was not one this protocol allows.
154 pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value, SourceError> {
155 let id = self.next_id.fetch_add(1, Ordering::Relaxed).to_string();
156 let request = Request {
157 id: id.clone(),
158 method: method.to_owned(),
159 params,
160 };
161 // A request is built from contract types that all serialize, so this cannot fail
162 // for a reason a user could act on.
163 let line = serde_json::to_string(&request).expect("a request is plain data");
164 let slot = Arc::new(Slot::empty());
165 self.dispatch(Job {
166 line,
167 slot: Arc::clone(&slot),
168 })?;
169 let timed = Arc::clone(&slot);
170 let child = Arc::clone(&self.child);
171 let deadline = self.deadline;
172 let timed_method = method.to_owned();
173 std::thread::spawn(move || {
174 std::thread::sleep(deadline);
175 let expired = timed.fill_if_empty(Err(SourceError::Unavailable {
176 message: format!(
177 "the plugin did not answer {timed_method:?} within {} milliseconds",
178 deadline.as_millis()
179 ),
180 }));
181 if expired
182 && let Ok(mut child) = child.lock()
183 && let Some(child) = child.as_mut()
184 {
185 let _ = child.kill();
186 }
187 });
188 let answer = Answer { slot }.await?;
189 self.interpret(&id, &answer)
190 }
191
192 /// Hand one job to the worker, or say plainly that there is no longer a worker.
193 fn dispatch(&self, job: Job) -> Result<(), SourceError> {
194 let mut jobs = self
195 .jobs
196 .lock()
197 .unwrap_or_else(|poisoned| poisoned.into_inner());
198 let Some(sender) = jobs.as_ref() else {
199 return Err(self.closed());
200 };
201 if sender.send(job).is_err() {
202 *jobs = None;
203 return Err(self.closed());
204 }
205 Ok(())
206 }
207
208 /// The one line a caller reads when the plugin is gone, with its own last words.
209 fn closed(&self) -> SourceError {
210 SourceError::Unavailable {
211 message: format!("the plugin stopped answering{}", self.said()),
212 }
213 }
214
215 /// Whatever the plugin wrote to standard error, as a clause to append to a message.
216 fn said(&self) -> String {
217 let diagnostics = self
218 .diagnostics
219 .lock()
220 .unwrap_or_else(|poisoned| poisoned.into_inner());
221 let said = diagnostics.trim();
222 if said.is_empty() {
223 String::new()
224 } else {
225 format!("; it wrote: {said}")
226 }
227 }
228
229 /// Turn one answer line into the result or failure it carries.
230 fn interpret(&self, id: &str, line: &str) -> Result<Value, SourceError> {
231 let response: Response = serde_json::from_str(line).map_err(|error| {
232 self.violation(
233 format!("the plugin answered with a line that is not a response envelope: {error}"),
234 line,
235 )
236 })?;
237 if response.id != id {
238 return Err(self.violation(
239 format!(
240 "the plugin answered request {id:?} with an envelope addressed to {:?}",
241 response.id
242 ),
243 line,
244 ));
245 }
246 match response.outcome() {
247 Some(outcome) => outcome,
248 None => Err(self.violation(
249 "the plugin answered with an envelope carrying both a result and an error, \
250 or neither"
251 .to_owned(),
252 line,
253 )),
254 }
255 }
256
257 /// A §6.3 protocol violation, quoting the offending line at a readable length.
258 fn violation(&self, problem: String, line: &str) -> SourceError {
259 SourceError::Malformed {
260 message: format!("{problem}: {}{}", quoted(line), self.said()),
261 }
262 }
263}
264
265impl Drop for Connection {
266 /// Close standard input, then make sure the child is gone.
267 ///
268 /// §1.2 step 4 is the polite half: dropping the sender drops the worker's `ChildStdin`
269 /// and a well-behaved plugin sees end-of-file and exits `0`. The kill is for the other
270 /// kind — a plugin that ignores end-of-file would otherwise outlive the run that
271 /// spawned it, and a stranded child holding a user's credentials is the one leak this
272 /// process must not walk away from.
273 fn drop(&mut self) {
274 if let Ok(mut jobs) = self.jobs.lock() {
275 *jobs = None;
276 }
277 if let Ok(mut child) = self.child.lock()
278 && let Some(child) = child.as_mut()
279 {
280 let _ = child.kill();
281 let _ = child.wait();
282 }
283 }
284}
285
286/// A plugin at the other end of a pair of streams, before a worker owns them.
287///
288/// The two constructors are the whole of what distinguishes a plugin this engine started
289/// from one it merely talks to. Everything after the handshake — framing, ids, violations,
290/// diagnostics — is the same either way, which is what lets the protocol's two halves be
291/// driven against each other over an ordinary pipe rather than only through a process
292/// this test suite would then be unable to misbehave on purpose.
293pub(crate) struct Peer {
294 /// The process, when this engine started one.
295 pub(crate) child: Arc<Mutex<Option<Child>>>,
296 /// Where requests go.
297 pub(crate) writer: Box<dyn Write + Send>,
298 /// Where responses come from.
299 pub(crate) reader: Box<dyn BufRead + Send>,
300 /// Diagnostics only; never parsed (§1).
301 pub(crate) stderr: Option<ChildStderr>,
302 pub(crate) request_deadline: Duration,
303 /// Present only when this engine owns a child it can interrupt during initialization.
304 pub(crate) handshake_deadline: Option<Duration>,
305}
306
307/// The state the operating system itself needs a spawned plugin to keep, and nothing else.
308///
309/// `env_clear` below is about *credentials*: §3.1 gives a plugin the variables its own
310/// configuration names and nothing more, so a host's unrelated tokens do not cross the
311/// pipe. On Windows that clearing takes something with it that is not the user's at all.
312/// Winsock's provider catalog records its DLLs as `%SystemRoot%\system32\...`, and it is
313/// the *child's* environment that expands them, so a child spawned without that variable
314/// cannot open a socket at all: every networked plugin reports its backend as unreachable,
315/// on that platform alone. A GitHub Projects board a process away answered on Linux and
316/// macOS and was unreachable on Windows for exactly this reason.
317///
318/// `SystemRoot` is the operating system's own and holds nothing of a user's work and no
319/// credential, so §3.1's boundary is where it was: a plugin still reads its configuration
320/// and its secrets from the `initialize` request rather than from any of this. Anything
321/// else a platform turns out to need is named here beside it, one variable at a time, with
322/// the reason it is not the user's.
323#[cfg(windows)]
324fn keep_platform_state(command: &mut Command) {
325 if let Some(root) = std::env::var_os("SystemRoot") {
326 command.env("SystemRoot", root);
327 }
328}
329
330/// Nothing to keep: a Unix child needs no variable of its own in order to reach a socket.
331#[cfg(not(windows))]
332fn keep_platform_state(_command: &mut Command) {}
333
334impl Peer {
335 /// Spawn `program` and take its three streams.
336 ///
337 /// # Errors
338 ///
339 /// Returns [`SourceError::Unavailable`] when the command cannot be spawned, naming
340 /// the program, because that is nearly always a path that is wrong or not executable
341 /// and the message a caller sees is their only clue which.
342 pub(crate) fn spawn(
343 program: &str,
344 args: &[String],
345 deadline: Duration,
346 ) -> Result<Self, SourceError> {
347 let mut command = Command::new(program);
348 command
349 .args(args)
350 // Credentials cross this boundary only in the initialize request (§3.1).
351 // Inheriting the engine's environment would also hand the plugin every
352 // unrelated token held by its host process.
353 .env_clear();
354 keep_platform_state(&mut command);
355 let mut child = command
356 .stdin(Stdio::piped())
357 .stdout(Stdio::piped())
358 .stderr(Stdio::piped())
359 .spawn()
360 .map_err(|error| SourceError::Unavailable {
361 message: format!("could not run the plugin program {program:?}: {error}"),
362 })?;
363 // Every stream was asked for as a pipe immediately above, so none of them can be
364 // absent; `expect` here rather than a branch a reader has to weigh.
365 let writer = Box::new(child.stdin.take().expect("stdin was piped"));
366 let reader = Box::new(BufReader::new(
367 child.stdout.take().expect("stdout was piped"),
368 ));
369 let stderr = child.stderr.take().expect("stderr was piped");
370 Ok(Self {
371 child: Arc::new(Mutex::new(Some(child))),
372 writer,
373 reader,
374 stderr: Some(stderr),
375 request_deadline: deadline,
376 handshake_deadline: Some(deadline),
377 })
378 }
379
380 /// Talk to a plugin that is already running, over streams somebody else owns.
381 pub(crate) fn over(
382 writer: impl Write + Send + 'static,
383 reader: impl Read + Send + 'static,
384 deadline: Duration,
385 ) -> Self {
386 Self {
387 child: Arc::new(Mutex::new(None)),
388 writer: Box::new(writer),
389 reader: Box::new(BufReader::new(reader)),
390 stderr: None,
391 request_deadline: deadline,
392 handshake_deadline: None,
393 }
394 }
395
396 /// One blocking request and response, for the handshake.
397 ///
398 /// # Errors
399 ///
400 /// Returns [`SourceError::Unavailable`] when the plugin cannot be written to or has
401 /// nothing to say.
402 pub(crate) fn exchange(&mut self, line: &str) -> Result<String, SourceError> {
403 let Some(deadline) = self.handshake_deadline else {
404 return exchange(&mut self.writer, &mut self.reader, line);
405 };
406 let finished = Arc::new(AtomicBool::new(false));
407 let timed_out = Arc::new(AtomicBool::new(false));
408 let watched = Arc::clone(&self.child);
409 let done = Arc::clone(&finished);
410 let expired = Arc::clone(&timed_out);
411 std::thread::spawn(move || {
412 std::thread::sleep(deadline);
413 if !done.load(Ordering::Acquire) {
414 expired.store(true, Ordering::Release);
415 if let Ok(mut child) = watched.lock()
416 && let Some(child) = child.as_mut()
417 {
418 let _ = child.kill();
419 }
420 }
421 });
422 let answer = exchange(&mut self.writer, &mut self.reader, line);
423 finished.store(true, Ordering::Release);
424 if timed_out.load(Ordering::Acquire) {
425 Err(SourceError::Unavailable {
426 message: format!(
427 "the plugin did not answer the initialize request within {} milliseconds",
428 deadline.as_millis()
429 ),
430 })
431 } else {
432 answer
433 }
434 }
435
436 /// Whatever the plugin wrote to standard error, once it can no longer write more.
437 ///
438 /// Only ever reached on a handshake that failed, where this peer is being discarded
439 /// and the one remaining question is what it said. So the child is ended first, on
440 /// purpose: reading a live plugin's standard error means waiting for a line it may
441 /// never write, which would turn a diagnostic into a hang — and a plugin whose
442 /// handshake failed has no connection left to keep open. With the writing end gone the
443 /// read reaches end-of-file, so this returns rather than waits.
444 pub(crate) fn said(&mut self) -> String {
445 if let Ok(mut child) = self.child.lock()
446 && let Some(child) = child.as_mut()
447 {
448 let _ = child.kill();
449 let _ = child.wait();
450 }
451 let Some(stderr) = self.stderr.as_mut() else {
452 return String::new();
453 };
454 let mut said = String::new();
455 let mut reader = BufReader::new(stderr);
456 while said.len() < KEPT_DIAGNOSTICS {
457 let mut line = String::new();
458 // Bounded by what is still wanted rather than by the line: a plugin whose
459 // diagnostic is one enormous line must not decide how much of this process's
460 // memory it takes, and the outer condition cannot say that on its own — it is
461 // only consulted between lines.
462 let room = (KEPT_DIAGNOSTICS - said.len()) as u64;
463 match (&mut reader).take(room).read_line(&mut line) {
464 Ok(0) | Err(_) => break,
465 Ok(_) => said.push_str(&line),
466 }
467 }
468 said.trim().to_owned()
469 }
470}
471
472/// Write one line, flush it, and read the one that answers.
473fn exchange(
474 writer: &mut (impl Write + ?Sized),
475 reader: &mut (impl BufRead + ?Sized),
476 line: &str,
477) -> Result<String, SourceError> {
478 writeln!(writer, "{line}")
479 .and_then(|()| writer.flush())
480 .map_err(|error| SourceError::Unavailable {
481 message: format!("could not send a request to the plugin: {error}"),
482 })?;
483 match read_line(reader) {
484 Line::Read(answer) => Ok(answer),
485 Line::Ended => Err(SourceError::Unavailable {
486 message: "the plugin closed its output without answering".to_owned(),
487 }),
488 Line::TooLong => Err(SourceError::Malformed {
489 message: format!(
490 "the plugin wrote more than {MAX_LINE} bytes without ending the line; a \
491 response is one line and this engine will not hold an unbounded one"
492 ),
493 }),
494 Line::Failed(error) => Err(SourceError::Unavailable {
495 message: format!("could not read the plugin's answer: {error}"),
496 }),
497 }
498}
499
500/// Keep the plugin's standard error, bounded, on a thread of its own.
501///
502/// A thread rather than a read at failure time because a plugin that fills the pipe's
503/// buffer and blocks writing to it would never answer the request the engine is waiting
504/// on — a deadlock whose symptom is a hang rather than a diagnostic.
505fn drain(stderr: ChildStderr, into: Arc<Mutex<String>>) {
506 std::thread::spawn(move || {
507 let mut reader = BufReader::new(stderr);
508 loop {
509 let mut line = String::new();
510 // One line at a time, each bounded on its own, because the cap has to hold
511 // against a plugin that logs one line and never ends it as well as against one
512 // that logs for ever. Reading past the cap and dropping the excess keeps the
513 // pipe drained — a plugin blocked writing to a full stderr never answers the
514 // request the engine is waiting on.
515 match (&mut reader).take(MAX_LINE).read_line(&mut line) {
516 Ok(0) | Err(_) => return,
517 Ok(_) => {}
518 }
519 let mut kept = into.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
520 let room = KEPT_DIAGNOSTICS.saturating_sub(kept.len());
521 if room > 0 {
522 let end = line
523 .char_indices()
524 .nth(room)
525 .map_or(line.len(), |(at, _)| at);
526 kept.push_str(&line[..end]);
527 }
528 }
529 });
530}
531
532/// One line, cut to a length a person can read, saying so when it was cut.
533fn quoted(line: &str) -> String {
534 let line = line.trim();
535 match line.char_indices().nth(QUOTED) {
536 None => format!("{line:?}"),
537 Some((at, _)) => format!("{:?} (truncated)", &line[..at]),
538 }
539}
540
541/// Where a worker thread leaves an answer for the future that is waiting on it.
542struct Slot {
543 /// The answer and the waker, together, so filling one and waking the other cannot
544 /// interleave with a poll that reads them in the other order.
545 state: Mutex<SlotState>,
546}
547
548/// What a slot holds between the request going out and the caller reading it.
549#[derive(Default)]
550struct SlotState {
551 /// The answer line, or the failure that ended the connection.
552 answer: Option<Result<String, SourceError>>,
553 /// Set permanently by the first answer, even after the future consumes that answer.
554 completed: bool,
555 /// The waiting task, if it has polled at least once.
556 waker: Option<Waker>,
557}
558
559impl Slot {
560 /// A slot with nothing in it yet.
561 fn empty() -> Self {
562 Self {
563 state: Mutex::new(SlotState::default()),
564 }
565 }
566
567 /// Leave an answer and wake whoever is waiting for it.
568 fn fill(&self, answer: Result<String, SourceError>) {
569 let _ = self.fill_if_empty(answer);
570 }
571
572 /// Leave the first answer only; a deadline racing a real response must not replace it.
573 fn fill_if_empty(&self, answer: Result<String, SourceError>) -> bool {
574 let mut state = self
575 .state
576 .lock()
577 .unwrap_or_else(|poisoned| poisoned.into_inner());
578 if state.completed {
579 return false;
580 }
581 state.completed = true;
582 state.answer = Some(answer);
583 let waker = state.waker.take();
584 drop(state);
585 if let Some(waker) = waker {
586 waker.wake();
587 }
588 true
589 }
590}
591
592/// The future half of one exchange: pending until the worker fills the slot.
593struct Answer {
594 /// Shared with the worker thread that will fill it.
595 slot: Arc<Slot>,
596}
597
598impl Future for Answer {
599 type Output = Result<String, SourceError>;
600
601 fn poll(self: std::pin::Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
602 let mut state = self
603 .slot
604 .state
605 .lock()
606 .unwrap_or_else(|poisoned| poisoned.into_inner());
607 match state.answer.take() {
608 Some(answer) => Poll::Ready(answer),
609 None => {
610 // Replaced rather than kept: a future polled on a second task must be
611 // woken through that task's waker, not the one that first polled it.
612 state.waker = Some(context.waker().clone());
613 Poll::Pending
614 }
615 }
616 }
617}