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