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
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}