Skip to main content

omgbase_sync/
external.rs

1//! The adapter protocol client (`spec/sync/README.md` §5): spawn an adapter
2//! command with `args + render_config_flags(config)` and the source's `env`,
3//! then speak newline-delimited JSON over its stdio — a handshake line, then
4//! id-matched requests and responses with ids from 1, plus the unsolicited
5//! `{"event":"batch"}` stream while a watch is live. stdout is the protocol;
6//! stderr is inherited for logs.
7
8use std::collections::BTreeMap;
9use std::io::{BufRead, BufReader, Read, Write};
10use std::process::{Child, Command, Stdio};
11use std::sync::Arc;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::mpsc::{Receiver, Sender, channel};
14use std::thread::JoinHandle;
15use std::time::{Duration, Instant};
16
17use serde_json::{Map, Value};
18
19use crate::PROTOCOL_VERSION;
20use crate::error::{Error, Result};
21use crate::registry::{AdapterRow, SourceRow, render_config_flags};
22use crate::source::{SourceCapabilities, SourceEntry, SourceItem, SyncSource};
23
24/// A connected adapter.
25pub struct ExternalSource {
26    command: String,
27    caps: SourceCapabilities,
28    stdin: Option<Box<dyn Write + Send>>,
29    responses: Receiver<String>,
30    batches: Option<Receiver<Vec<String>>>,
31    /// Set while a watch is live; a batch event arriving otherwise is dropped
32    /// (§9: never buffered as a response).
33    watching: Arc<AtomicBool>,
34    next_id: u64,
35    child: Option<Child>,
36    reader: Option<JoinHandle<()>>,
37    /// Every request line sent, without the newline (for tests and traces).
38    pub sent: Vec<String>,
39    /// Keep the trace (off by default: production sends are not retained).
40    pub trace: bool,
41}
42
43impl std::fmt::Debug for ExternalSource {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("ExternalSource")
46            .field("command", &self.command)
47            .field("caps", &self.caps)
48            .finish_non_exhaustive()
49    }
50}
51
52/// Demultiplex adapter stdout: `{"event":"batch"}` lines go to `batches`
53/// while `watching` (dropped otherwise), every other non-empty line to
54/// `responses`. Ends at EOF.
55fn route(
56    reader: Box<dyn Read + Send>,
57    responses: Sender<String>,
58    batches: Sender<Vec<String>>,
59    watching: Arc<AtomicBool>,
60) {
61    let buf = BufReader::new(reader);
62    for line in buf.lines() {
63        let Ok(line) = line else { break };
64        let trimmed = line.trim();
65        if trimmed.is_empty() {
66            continue;
67        }
68        if let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(trimmed) {
69            if obj.get("event").and_then(Value::as_str) == Some("batch") {
70                let paths: Vec<String> = obj
71                    .get("paths")
72                    .and_then(Value::as_array)
73                    .map(|a| {
74                        a.iter()
75                            .filter_map(Value::as_str)
76                            .map(str::to_owned)
77                            .collect()
78                    })
79                    .unwrap_or_default();
80                if watching.load(Ordering::SeqCst) {
81                    let _ = batches.send(paths);
82                }
83                continue;
84            }
85        }
86        // Non-JSON on stdout is a protocol violation; surfaced as a response
87        // so the awaiting call fails instead of hanging.
88        if responses.send(trimmed.to_owned()).is_err() {
89            break;
90        }
91    }
92}
93
94impl ExternalSource {
95    /// Spawn `command args…` with `env` merged over the parent's and connect.
96    pub fn spawn(command: &str, args: &[String], env: &BTreeMap<String, String>) -> Result<Self> {
97        let mut cmd = Command::new(command);
98        cmd.args(args)
99            .stdin(Stdio::piped())
100            .stdout(Stdio::piped())
101            .stderr(Stdio::inherit());
102        for (k, v) in env {
103            cmd.env(k, v);
104        }
105        let mut child = cmd.spawn().map_err(|e| Error::AdapterSpawn {
106            command: command.to_owned(),
107            message: e.to_string(),
108        })?;
109        let stdin = child.stdin.take().ok_or_else(|| Error::AdapterSpawn {
110            command: command.to_owned(),
111            message: "no stdin pipe".to_owned(),
112        })?;
113        let stdout = child.stdout.take().ok_or_else(|| Error::AdapterSpawn {
114            command: command.to_owned(),
115            message: "no stdout pipe".to_owned(),
116        })?;
117        Self::connect_inner(command, Box::new(stdout), Box::new(stdin), Some(child))
118    }
119
120    /// Spawn a registry source: the adapter's `command`, its fixed `args`,
121    /// then `render_config_flags(source.config)`; the source's `env`.
122    pub fn spawn_source(source: &SourceRow, adapter: &AdapterRow) -> Result<Self> {
123        let mut args = adapter.args.clone();
124        args.extend(render_config_flags(&source.config));
125        Self::spawn(&adapter.command, &args, &source.env)
126    }
127
128    /// The argv an adapter is spawned with (§5), for callers that log it.
129    #[must_use]
130    pub fn argv(source: &SourceRow, adapter: &AdapterRow) -> Vec<String> {
131        let mut argv = vec![adapter.command.clone()];
132        argv.extend(adapter.args.iter().cloned());
133        argv.extend(render_config_flags(&source.config));
134        argv
135    }
136
137    /// Connect over an arbitrary pair of streams (the adapter's stdout to
138    /// read, its stdin to write) — a test or a runner playing a scripted
139    /// adapter over pipes. `label` names the adapter in errors.
140    pub fn connect(
141        label: &str,
142        from_adapter: impl Read + Send + 'static,
143        to_adapter: impl Write + Send + 'static,
144    ) -> Result<Self> {
145        Self::connect_inner(label, Box::new(from_adapter), Box::new(to_adapter), None)
146    }
147
148    fn connect_inner(
149        command: &str,
150        from_adapter: Box<dyn Read + Send>,
151        to_adapter: Box<dyn Write + Send>,
152        child: Option<Child>,
153    ) -> Result<Self> {
154        let (resp_tx, resp_rx) = channel();
155        let (batch_tx, batch_rx) = channel();
156        let watching = Arc::new(AtomicBool::new(false));
157        let flag = Arc::clone(&watching);
158        let reader = std::thread::spawn(move || route(from_adapter, resp_tx, batch_tx, flag));
159        let mut source = Self {
160            command: command.to_owned(),
161            caps: SourceCapabilities::default(),
162            stdin: Some(to_adapter),
163            responses: resp_rx,
164            batches: Some(batch_rx),
165            watching,
166            next_id: 1,
167            child,
168            reader: Some(reader),
169            sent: Vec::new(),
170            trace: false,
171        };
172        // Handshake: the first line declares protocol + capabilities.
173        let line = match source.responses.recv() {
174            Ok(l) => l,
175            Err(_) => {
176                let err = Error::AdapterExited {
177                    command: command.to_owned(),
178                };
179                let _ = source.close();
180                return Err(err);
181            }
182        };
183        let parsed: Option<Value> = serde_json::from_str(&line).ok();
184        let protocol_ok = parsed
185            .as_ref()
186            .and_then(|v| v.get("protocol"))
187            .and_then(Value::as_u64)
188            == Some(PROTOCOL_VERSION);
189        let Some(hs) = parsed.filter(|_| protocol_ok) else {
190            let err = Error::AdapterHandshake {
191                command: command.to_owned(),
192                line,
193            };
194            let _ = source.close();
195            return Err(err);
196        };
197        source.caps = SourceCapabilities::from_json(hs.get("capabilities"));
198        Ok(source)
199    }
200
201    /// The label/command this source was spawned as.
202    #[must_use]
203    pub fn command(&self) -> &str {
204        &self.command
205    }
206
207    /// One request/response round trip: `{"id":n,"method":m,"params":{…}}`,
208    /// then responses until the one with our id (others are dropped, as the
209    /// reference does); `{"error": …}` fails.
210    pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
211        let id = self.next_id;
212        self.next_id += 1;
213        let line = serde_json::json!({ "id": id, "method": method, "params": params }).to_string();
214        if self.trace {
215            self.sent.push(line.clone());
216        }
217        let stdin = self.stdin.as_mut().ok_or_else(|| Error::AdapterExited {
218            command: self.command.clone(),
219        })?;
220        stdin
221            .write_all(format!("{line}\n").as_bytes())
222            .and_then(|()| stdin.flush())
223            .map_err(|_| Error::AdapterExited {
224                command: self.command.clone(),
225            })?;
226        loop {
227            let raw = self.responses.recv().map_err(|_| Error::AdapterExited {
228                command: self.command.clone(),
229            })?;
230            let msg: Value = serde_json::from_str(&raw)?;
231            if msg.get("id").and_then(Value::as_u64) != Some(id) {
232                continue;
233            }
234            if let Some(err) = msg.get("error").filter(|e| !e.is_null()) {
235                let message = match err {
236                    Value::String(s) if s.is_empty() => continue,
237                    Value::String(s) => s.clone(),
238                    Value::Bool(false) => continue,
239                    other => other.to_string(),
240                };
241                return Err(Error::AdapterError {
242                    method: method.to_owned(),
243                    message,
244                });
245            }
246            return Ok(msg.get("result").cloned().unwrap_or(Value::Null));
247        }
248    }
249
250    /// Whether the child (if any) is still running.
251    pub fn alive(&mut self) -> bool {
252        match &mut self.child {
253            Some(c) => matches!(c.try_wait(), Ok(None)),
254            None => self.reader.as_ref().is_some_and(|r| !r.is_finished()),
255        }
256    }
257}
258
259impl SyncSource for ExternalSource {
260    fn capabilities(&self) -> SourceCapabilities {
261        self.caps
262    }
263
264    fn enumerate(&mut self) -> Result<Vec<SourceEntry>> {
265        let r = self.call("enumerate", Value::Object(Map::new()))?;
266        Ok(r.get("entries")
267            .and_then(Value::as_array)
268            .map(|a| a.iter().filter_map(SourceEntry::from_json).collect())
269            .unwrap_or_default())
270    }
271
272    fn fetch(&mut self, path: &str) -> Result<Option<SourceItem>> {
273        let r = self.call("fetch", serde_json::json!({ "path": path }))?;
274        Ok(SourceItem::from_json(r.get("item")))
275    }
276
277    fn write(&mut self, path: &str, content: &str) -> Result<()> {
278        if !self.caps.write_through {
279            return Err(Error::Unsupported("write".to_owned()));
280        }
281        self.call(
282            "write",
283            serde_json::json!({ "path": path, "content": content }),
284        )?;
285        Ok(())
286    }
287
288    fn remove(&mut self, path: &str) -> Result<()> {
289        if !self.caps.write_through {
290            return Err(Error::Unsupported("remove".to_owned()));
291        }
292        self.call("remove", serde_json::json!({ "path": path }))?;
293        Ok(())
294    }
295
296    fn watch(&mut self) -> Result<Receiver<Vec<String>>> {
297        if !self.caps.watch {
298            return Err(Error::Unsupported("watch".to_owned()));
299        }
300        let rx = self
301            .batches
302            .take()
303            .ok_or_else(|| Error::Other("the watch stream was already taken".to_owned()))?;
304        // The listener is live before the request goes out (as the reference).
305        self.watching.store(true, Ordering::SeqCst);
306        if let Err(e) = self.call("watch", Value::Object(Map::new())) {
307            self.watching.store(false, Ordering::SeqCst);
308            self.batches = Some(rx);
309            return Err(e);
310        }
311        Ok(rx)
312    }
313
314    /// Stop listening, then `unwatch`; a failure (the process may be
315    /// exiting) is ignored.
316    fn unwatch(&mut self) -> Result<()> {
317        self.watching.store(false, Ordering::SeqCst);
318        let _ = self.call("unwatch", Value::Object(Map::new()));
319        Ok(())
320    }
321
322    /// Close stdin (EOF), give the adapter a moment to exit, then SIGTERM,
323    /// then SIGKILL; reap it.
324    fn close(&mut self) -> Result<()> {
325        self.stdin = None;
326        if let Some(mut child) = self.child.take() {
327            let deadline = Instant::now() + Duration::from_millis(500);
328            let mut exited = false;
329            while Instant::now() < deadline {
330                if matches!(child.try_wait(), Ok(Some(_))) {
331                    exited = true;
332                    break;
333                }
334                std::thread::sleep(Duration::from_millis(10));
335            }
336            if !exited {
337                crate::lock::send_sigterm(i64::from(child.id()));
338                let deadline = Instant::now() + Duration::from_millis(500);
339                while Instant::now() < deadline {
340                    if matches!(child.try_wait(), Ok(Some(_))) {
341                        exited = true;
342                        break;
343                    }
344                    std::thread::sleep(Duration::from_millis(10));
345                }
346            }
347            if !exited {
348                let _ = child.kill();
349                let _ = child.wait();
350            }
351        }
352        if let Some(reader) = self.reader.take() {
353            let _ = reader.join();
354        }
355        Ok(())
356    }
357}
358
359impl Drop for ExternalSource {
360    fn drop(&mut self) {
361        if self.child.is_some() || self.stdin.is_some() {
362            let _ = self.close();
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::source::SourceIdentity;
371    use std::io::Cursor;
372    use std::sync::{Arc, Mutex};
373
374    /// A writer that collects everything written.
375    #[derive(Clone, Default)]
376    struct Sink(Arc<Mutex<Vec<u8>>>);
377
378    impl Write for Sink {
379        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
380            self.0.lock().unwrap().extend_from_slice(buf);
381            Ok(buf.len())
382        }
383        fn flush(&mut self) -> std::io::Result<()> {
384            Ok(())
385        }
386    }
387
388    fn lines(sink: &Sink) -> Vec<String> {
389        String::from_utf8(sink.0.lock().unwrap().clone())
390            .unwrap()
391            .lines()
392            .map(str::to_owned)
393            .collect()
394    }
395
396    fn scripted(adapter_lines: &str) -> (ExternalSource, Sink) {
397        let sink = Sink::default();
398        let src =
399            ExternalSource::connect("fake", Cursor::new(adapter_lines.to_owned()), sink.clone())
400                .unwrap();
401        (src, sink)
402    }
403
404    #[test]
405    fn handshake_and_calls() {
406        use crate::pipe::{Dir, ScriptedAdapter};
407        let t = |d: Dir, l: &str| (d, l.to_owned());
408        let transcript = vec![
409            t(
410                Dir::In,
411                r#"{"protocol":1,"capabilities":{"identity":"inferred","writeThrough":true,"watch":true}}"#,
412            ),
413            t(Dir::In, ""),
414            t(Dir::In, r#"{"event":"batch","paths":["early.md"]}"#),
415            t(Dir::Out, r#"{"id":1,"method":"enumerate","params":{}}"#),
416            t(
417                Dir::In,
418                r#"{"id":1,"result":{"entries":[{"path":"a.md","revision":"1:2"}]}}"#,
419            ),
420            t(
421                Dir::Out,
422                r#"{"id":2,"method":"fetch","params":{"path":"a.md"}}"#,
423            ),
424            t(Dir::In, r#"{"id":99,"result":{}}"#),
425            t(
426                Dir::In,
427                r##"{"id":2,"result":{"item":{"path":"a.md","revision":"1:2","content":"# A\n"}}}"##,
428            ),
429            t(
430                Dir::Out,
431                r#"{"id":3,"method":"fetch","params":{"path":"gone.md"}}"#,
432            ),
433            t(Dir::In, r#"{"id":3,"result":{"item":null}}"#),
434            t(
435                Dir::Out,
436                r#"{"id":4,"method":"write","params":{"path":"b.md","content":"x\n"}}"#,
437            ),
438            t(Dir::In, r#"{"id":4,"result":{"ok":true}}"#),
439            t(
440                Dir::Out,
441                r#"{"id":5,"method":"remove","params":{"path":"b.md"}}"#,
442            ),
443            t(Dir::In, r#"{"id":5,"error":"nope"}"#),
444            t(Dir::Out, r#"{"id":6,"method":"watch","params":{}}"#),
445            t(Dir::In, r#"{"id":6,"result":{"ok":true}}"#),
446            t(Dir::In, r#"{"event":"batch","paths":["a.md","b.md"]}"#),
447            t(Dir::Out, r#"{"id":7,"method":"unwatch","params":{}}"#),
448            t(Dir::In, r#"{"id":7,"result":{"ok":true}}"#),
449        ];
450        let expected_out: Vec<String> = transcript
451            .iter()
452            .filter(|(d, _)| *d == Dir::Out)
453            .map(|(_, l)| l.clone())
454            .collect();
455        let (adapter, from_adapter, to_adapter) = ScriptedAdapter::spawn(transcript);
456        let mut src = ExternalSource::connect("fake", from_adapter, to_adapter).unwrap();
457        src.trace = true;
458        assert_eq!(
459            src.capabilities(),
460            SourceCapabilities {
461                identity: SourceIdentity::Inferred,
462                write_through: true,
463                watch: true
464            }
465        );
466        let entries = src.enumerate().unwrap();
467        assert_eq!(entries.len(), 1);
468        assert_eq!(entries[0].revision, "1:2");
469        let item = src.fetch("a.md").unwrap().unwrap();
470        assert_eq!(
471            item.content,
472            "# A
473"
474        );
475        assert!(
476            src.fetch("gone.md").unwrap().is_none(),
477            "a stray response (id 99) was skipped"
478        );
479        src.write(
480            "b.md", "x
481",
482        )
483        .unwrap();
484        let err = src.remove("b.md").unwrap_err();
485        assert!(
486            matches!(err, Error::AdapterError { ref method, ref message } if method == "remove" && message == "nope"),
487            "{err}"
488        );
489        let rx = src.watch().unwrap();
490        assert_eq!(
491            rx.recv().unwrap(),
492            ["a.md", "b.md"],
493            "the pre-watch event was dropped"
494        );
495        src.unwatch().unwrap();
496        assert!(rx.try_recv().is_err());
497        assert_eq!(src.sent, expected_out);
498        assert!(src.alive());
499        src.close().unwrap();
500        assert!(!src.alive());
501        assert_eq!(adapter.received(), expected_out);
502    }
503
504    #[test]
505    fn eof_after_the_script_reports_the_adapter_gone() {
506        let (mut src, sink) = scripted("{\"protocol\":1,\"capabilities\":{}}\n");
507        assert!(matches!(src.fetch("x"), Err(Error::AdapterExited { .. })));
508        assert_eq!(
509            lines(&sink),
510            [r#"{"id":1,"method":"fetch","params":{"path":"x"}}"#]
511        );
512        src.close().unwrap();
513    }
514
515    #[test]
516    fn bad_handshakes_fail() {
517        for bad in [
518            "",
519            "not json\n",
520            "{\"protocol\":2,\"capabilities\":{}}\n",
521            "{\"capabilities\":{}}\n",
522        ] {
523            let sink = Sink::default();
524            let err =
525                ExternalSource::connect("fake", Cursor::new(bad.to_owned()), sink).expect_err(bad);
526            if bad.is_empty() {
527                assert!(
528                    matches!(err, Error::AdapterExited { .. }),
529                    "{bad:?} → {err}"
530                );
531            } else {
532                assert!(
533                    matches!(err, Error::AdapterHandshake { .. }),
534                    "{bad:?} → {err}"
535                );
536            }
537        }
538    }
539
540    #[test]
541    fn read_only_sources_refuse_writes_and_watch() {
542        let (mut src, _sink) = scripted("{\"protocol\":1,\"capabilities\":{}}\n");
543        assert!(matches!(src.write("a", "b"), Err(Error::Unsupported(_))));
544        assert!(matches!(src.remove("a"), Err(Error::Unsupported(_))));
545        assert!(matches!(src.watch(), Err(Error::Unsupported(_))));
546    }
547
548    #[test]
549    fn spawns_a_real_process_and_closes_it() {
550        // `cat` echoes stdin; a handshake we write ourselves comes back.
551        let err = ExternalSource::spawn("definitely-not-a-command-xyz", &[], &BTreeMap::new())
552            .err()
553            .unwrap();
554        assert!(matches!(err, Error::AdapterSpawn { .. }), "{err}");
555        let script = "printf '%s\\n' '{\"protocol\":1,\"capabilities\":{\"watch\":true}}'; while IFS= read -r line; do case \"$line\" in *enumerate*) echo '{\"id\":1,\"result\":{\"entries\":[]}}';; *) echo \"{\\\"id\\\":${line#*\\\"id\\\":}\" | sed 's/,.*//;s/$/,\"result\":{}}/';; esac; done";
556        let env: BTreeMap<String, String> = [("OMGBASE_TEST_ENV".to_owned(), "1".to_owned())]
557            .into_iter()
558            .collect();
559        let mut src =
560            ExternalSource::spawn("sh", &["-c".to_owned(), script.to_owned()], &env).unwrap();
561        assert!(src.capabilities().watch);
562        assert!(src.enumerate().unwrap().is_empty());
563        assert!(src.alive());
564        src.close().unwrap();
565        assert!(!src.alive());
566    }
567
568    #[test]
569    fn argv_is_command_args_then_flags() {
570        let adapter = AdapterRow {
571            name: "fs".into(),
572            command: "omgbase-fs-adapter".into(),
573            args: vec!["--v".into()],
574        };
575        let source = SourceRow {
576            source_id: "src_0".into(),
577            name: "x-fs".into(),
578            adapter: "fs".into(),
579            config: serde_json::json!({"root": "/r", "debounce": 750})
580                .as_object()
581                .cloned()
582                .unwrap(),
583            env: BTreeMap::new(),
584        };
585        assert_eq!(
586            ExternalSource::argv(&source, &adapter),
587            [
588                "omgbase-fs-adapter",
589                "--v",
590                "--root",
591                "/r",
592                "--debounce",
593                "750"
594            ]
595        );
596    }
597}