Skip to main content

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}