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
307impl Peer {
308 /// Spawn `program` and take its three streams.
309 ///
310 /// # Errors
311 ///
312 /// Returns [`SourceError::Unavailable`] when the command cannot be spawned, naming
313 /// the program, because that is nearly always a path that is wrong or not executable
314 /// and the message a caller sees is their only clue which.
315 pub(crate) fn spawn(
316 program: &str,
317 args: &[String],
318 deadline: Duration,
319 ) -> Result<Self, SourceError> {
320 let mut child = Command::new(program)
321 .args(args)
322 // Credentials cross this boundary only in the initialize request (§3.1).
323 // Inheriting the engine's environment would also hand the plugin every
324 // unrelated token held by its host process.
325 .env_clear()
326 .stdin(Stdio::piped())
327 .stdout(Stdio::piped())
328 .stderr(Stdio::piped())
329 .spawn()
330 .map_err(|error| SourceError::Unavailable {
331 message: format!("could not run the plugin program {program:?}: {error}"),
332 })?;
333 // Every stream was asked for as a pipe immediately above, so none of them can be
334 // absent; `expect` here rather than a branch a reader has to weigh.
335 let writer = Box::new(child.stdin.take().expect("stdin was piped"));
336 let reader = Box::new(BufReader::new(
337 child.stdout.take().expect("stdout was piped"),
338 ));
339 let stderr = child.stderr.take().expect("stderr was piped");
340 Ok(Self {
341 child: Arc::new(Mutex::new(Some(child))),
342 writer,
343 reader,
344 stderr: Some(stderr),
345 request_deadline: deadline,
346 handshake_deadline: Some(deadline),
347 })
348 }
349
350 /// Talk to a plugin that is already running, over streams somebody else owns.
351 pub(crate) fn over(
352 writer: impl Write + Send + 'static,
353 reader: impl Read + Send + 'static,
354 deadline: Duration,
355 ) -> Self {
356 Self {
357 child: Arc::new(Mutex::new(None)),
358 writer: Box::new(writer),
359 reader: Box::new(BufReader::new(reader)),
360 stderr: None,
361 request_deadline: deadline,
362 handshake_deadline: None,
363 }
364 }
365
366 /// One blocking request and response, for the handshake.
367 ///
368 /// # Errors
369 ///
370 /// Returns [`SourceError::Unavailable`] when the plugin cannot be written to or has
371 /// nothing to say.
372 pub(crate) fn exchange(&mut self, line: &str) -> Result<String, SourceError> {
373 let Some(deadline) = self.handshake_deadline else {
374 return exchange(&mut self.writer, &mut self.reader, line);
375 };
376 let finished = Arc::new(AtomicBool::new(false));
377 let timed_out = Arc::new(AtomicBool::new(false));
378 let watched = Arc::clone(&self.child);
379 let done = Arc::clone(&finished);
380 let expired = Arc::clone(&timed_out);
381 std::thread::spawn(move || {
382 std::thread::sleep(deadline);
383 if !done.load(Ordering::Acquire) {
384 expired.store(true, Ordering::Release);
385 if let Ok(mut child) = watched.lock()
386 && let Some(child) = child.as_mut()
387 {
388 let _ = child.kill();
389 }
390 }
391 });
392 let answer = exchange(&mut self.writer, &mut self.reader, line);
393 finished.store(true, Ordering::Release);
394 if timed_out.load(Ordering::Acquire) {
395 Err(SourceError::Unavailable {
396 message: format!(
397 "the plugin did not answer the initialize request within {} milliseconds",
398 deadline.as_millis()
399 ),
400 })
401 } else {
402 answer
403 }
404 }
405
406 /// Whatever the plugin wrote to standard error, once it can no longer write more.
407 ///
408 /// Only ever reached on a handshake that failed, where this peer is being discarded
409 /// and the one remaining question is what it said. So the child is ended first, on
410 /// purpose: reading a live plugin's standard error means waiting for a line it may
411 /// never write, which would turn a diagnostic into a hang — and a plugin whose
412 /// handshake failed has no connection left to keep open. With the writing end gone the
413 /// read reaches end-of-file, so this returns rather than waits.
414 pub(crate) fn said(&mut self) -> String {
415 if let Ok(mut child) = self.child.lock()
416 && let Some(child) = child.as_mut()
417 {
418 let _ = child.kill();
419 let _ = child.wait();
420 }
421 let Some(stderr) = self.stderr.as_mut() else {
422 return String::new();
423 };
424 let mut said = String::new();
425 let mut reader = BufReader::new(stderr);
426 while said.len() < KEPT_DIAGNOSTICS {
427 let mut line = String::new();
428 // Bounded by what is still wanted rather than by the line: a plugin whose
429 // diagnostic is one enormous line must not decide how much of this process's
430 // memory it takes, and the outer condition cannot say that on its own — it is
431 // only consulted between lines.
432 let room = (KEPT_DIAGNOSTICS - said.len()) as u64;
433 match (&mut reader).take(room).read_line(&mut line) {
434 Ok(0) | Err(_) => break,
435 Ok(_) => said.push_str(&line),
436 }
437 }
438 said.trim().to_owned()
439 }
440}
441
442/// Write one line, flush it, and read the one that answers.
443fn exchange(
444 writer: &mut (impl Write + ?Sized),
445 reader: &mut (impl BufRead + ?Sized),
446 line: &str,
447) -> Result<String, SourceError> {
448 writeln!(writer, "{line}")
449 .and_then(|()| writer.flush())
450 .map_err(|error| SourceError::Unavailable {
451 message: format!("could not send a request to the plugin: {error}"),
452 })?;
453 match read_line(reader) {
454 Line::Read(answer) => Ok(answer),
455 Line::Ended => Err(SourceError::Unavailable {
456 message: "the plugin closed its output without answering".to_owned(),
457 }),
458 Line::TooLong => Err(SourceError::Malformed {
459 message: format!(
460 "the plugin wrote more than {MAX_LINE} bytes without ending the line; a \
461 response is one line and this engine will not hold an unbounded one"
462 ),
463 }),
464 Line::Failed(error) => Err(SourceError::Unavailable {
465 message: format!("could not read the plugin's answer: {error}"),
466 }),
467 }
468}
469
470/// Keep the plugin's standard error, bounded, on a thread of its own.
471///
472/// A thread rather than a read at failure time because a plugin that fills the pipe's
473/// buffer and blocks writing to it would never answer the request the engine is waiting
474/// on — a deadlock whose symptom is a hang rather than a diagnostic.
475fn drain(stderr: ChildStderr, into: Arc<Mutex<String>>) {
476 std::thread::spawn(move || {
477 let mut reader = BufReader::new(stderr);
478 loop {
479 let mut line = String::new();
480 // One line at a time, each bounded on its own, because the cap has to hold
481 // against a plugin that logs one line and never ends it as well as against one
482 // that logs for ever. Reading past the cap and dropping the excess keeps the
483 // pipe drained — a plugin blocked writing to a full stderr never answers the
484 // request the engine is waiting on.
485 match (&mut reader).take(MAX_LINE).read_line(&mut line) {
486 Ok(0) | Err(_) => return,
487 Ok(_) => {}
488 }
489 let mut kept = into.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
490 let room = KEPT_DIAGNOSTICS.saturating_sub(kept.len());
491 if room > 0 {
492 let end = line
493 .char_indices()
494 .nth(room)
495 .map_or(line.len(), |(at, _)| at);
496 kept.push_str(&line[..end]);
497 }
498 }
499 });
500}
501
502/// One line, cut to a length a person can read, saying so when it was cut.
503fn quoted(line: &str) -> String {
504 let line = line.trim();
505 match line.char_indices().nth(QUOTED) {
506 None => format!("{line:?}"),
507 Some((at, _)) => format!("{:?} (truncated)", &line[..at]),
508 }
509}
510
511/// Where a worker thread leaves an answer for the future that is waiting on it.
512struct Slot {
513 /// The answer and the waker, together, so filling one and waking the other cannot
514 /// interleave with a poll that reads them in the other order.
515 state: Mutex<SlotState>,
516}
517
518/// What a slot holds between the request going out and the caller reading it.
519#[derive(Default)]
520struct SlotState {
521 /// The answer line, or the failure that ended the connection.
522 answer: Option<Result<String, SourceError>>,
523 /// Set permanently by the first answer, even after the future consumes that answer.
524 completed: bool,
525 /// The waiting task, if it has polled at least once.
526 waker: Option<Waker>,
527}
528
529impl Slot {
530 /// A slot with nothing in it yet.
531 fn empty() -> Self {
532 Self {
533 state: Mutex::new(SlotState::default()),
534 }
535 }
536
537 /// Leave an answer and wake whoever is waiting for it.
538 fn fill(&self, answer: Result<String, SourceError>) {
539 let _ = self.fill_if_empty(answer);
540 }
541
542 /// Leave the first answer only; a deadline racing a real response must not replace it.
543 fn fill_if_empty(&self, answer: Result<String, SourceError>) -> bool {
544 let mut state = self
545 .state
546 .lock()
547 .unwrap_or_else(|poisoned| poisoned.into_inner());
548 if state.completed {
549 return false;
550 }
551 state.completed = true;
552 state.answer = Some(answer);
553 let waker = state.waker.take();
554 drop(state);
555 if let Some(waker) = waker {
556 waker.wake();
557 }
558 true
559 }
560}
561
562/// The future half of one exchange: pending until the worker fills the slot.
563struct Answer {
564 /// Shared with the worker thread that will fill it.
565 slot: Arc<Slot>,
566}
567
568impl Future for Answer {
569 type Output = Result<String, SourceError>;
570
571 fn poll(self: std::pin::Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
572 let mut state = self
573 .slot
574 .state
575 .lock()
576 .unwrap_or_else(|poisoned| poisoned.into_inner());
577 match state.answer.take() {
578 Some(answer) => Poll::Ready(answer),
579 None => {
580 // Replaced rather than kept: a future polled on a second task must be
581 // woken through that task's waker, not the one that first polled it.
582 state.waker = Some(context.waker().clone());
583 Poll::Pending
584 }
585 }
586 }
587}