Skip to main content

escouade_plugin_protocol/
turn.rs

1//! A plugin in Rust on both targets (docs/PLUGINS.md §8.3): in process natively, a WASM reactor on `wasm32-wasip1`, with
2//! the same source.
3//!
4//! A reactor keeps its state between two calls of its export `escouade-turn`, as an instance of `run.js` does. The host
5//! writes on its standard input, which never blocks, then calls a turn: the plugin reads what came and writes its lines,
6//! and the turn ends. Nothing waits inside a turn: an answer of the host comes as a line of a later turn.
7//!
8//! - [`run`] is what a state machine calls in place of [`crate::serve`]; `escouade-plugin-kit` has its own, for a plugin
9//!   whose calls wait.
10//! - [`crate::module!`] exports the turn. It is one line of the plugin, nothing at all natively.
11//! - [`instance_id`] and [`read_file`] are what `std::process::id` and `std::fs` cannot be in a module.
12//! - On `wasm32`, `lines`, `write`, `flush`, `set_turn` are the kit's way in, and `host_call` the synchronous
13//!   door to the host (`escouade.call`).
14
15use crate::Refusal;
16use serde_json::Value;
17use std::path::Path;
18
19/// The whole lines of what was read, the end of a line that is not there yet kept in `partial` for the next turn.
20#[cfg_attr(not(target_family = "wasm"), allow(dead_code))]
21fn whole_lines(partial: &mut Vec<u8>, read: &[u8]) -> Vec<String> {
22    let mut all = std::mem::take(partial);
23    all.extend_from_slice(read);
24    let end = all.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
25    *partial = all.split_off(end);
26    String::from_utf8_lossy(&all)
27        .lines()
28        .map(str::to_string)
29        .collect()
30}
31
32/// Exports the turn of a plugin, `run` being what it runs at its start: `escouade_plugin_protocol::module!(run);`. A
33/// module's first turn calls it; it keeps the plugin (`turn::run`, `escouade_plugin_kit::serve::run`), and every turn
34/// then runs on what came. Natively, it is nothing: `escouade-plugins` calls `run`.
35#[macro_export]
36macro_rules! module {
37    ($run:path) => {
38        #[cfg(target_family = "wasm")]
39        #[export_name = "escouade-turn"]
40        pub extern "C" fn __escouade_turn() {
41            $crate::turn::enter($run);
42        }
43    };
44}
45
46/// The loop of a state machine, whatever the target: natively, [`crate::serve`] on the standard input and output until
47/// they end, its exit code answered; in a module, `handler` kept for the turns to come.
48#[cfg(not(target_family = "wasm"))]
49pub fn run(
50    name: &str,
51    capabilities: Value,
52    handler: impl FnMut(&str, Value) -> Result<Value, Refusal> + 'static,
53) -> i32 {
54    let stdin = std::io::stdin();
55    match crate::serve(
56        stdin.lock(),
57        std::io::stdout().lock(),
58        capabilities,
59        handler,
60    ) {
61        Ok(()) => 0,
62        Err(e) => {
63            eprintln!("escouade-plugins {name}: {e}");
64            1
65        }
66    }
67}
68
69/// What tells the ids of this instance from those of the one before it, restarted: its process.
70#[cfg(not(target_family = "wasm"))]
71pub fn instance_id() -> u64 {
72    u64::from(std::process::id())
73}
74
75/// A file a state machine must have before it answers: from the disk natively; in a module, read by the host, under a
76/// folder the plugin declares (`fs/read`, docs/PLUGINS.md §8.4.6). None when it cannot be read, or is refused.
77#[cfg(not(target_family = "wasm"))]
78pub fn read_file(path: &Path) -> Option<String> {
79    std::fs::read_to_string(path).ok()
80}
81
82#[cfg(target_family = "wasm")]
83pub use module::*;
84
85#[cfg(target_family = "wasm")]
86mod module {
87    use super::*;
88    use crate::serve;
89    use serde_json::json;
90    use std::cell::{Cell, RefCell};
91    use std::io::{Read, Write};
92
93    type Handler = Box<dyn FnMut(&str, Value) -> Result<Value, Refusal>>;
94
95    struct Machine {
96        capabilities: Value,
97        handler: Handler,
98    }
99
100    thread_local! {
101        static STARTED: Cell<bool> = const { Cell::new(false) };
102        static TURN: Cell<Option<fn()>> = const { Cell::new(None) };
103        static PARTIAL: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
104        static OUT: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
105        static MACHINE: RefCell<Option<Machine>> = const { RefCell::new(None) };
106    }
107
108    /// The whole lines the host wrote since the last turn. An empty read is "nothing more for now", never an end.
109    pub fn lines() -> Vec<String> {
110        let mut read = Vec::new();
111        let mut buf = [0u8; 16 * 1024];
112        let mut input = std::io::stdin().lock();
113        while let Ok(n) = input.read(&mut buf) {
114            if n == 0 {
115                break;
116            }
117            read.extend_from_slice(&buf[..n]);
118        }
119        PARTIAL.with(|p| whole_lines(&mut p.borrow_mut(), &read))
120    }
121
122    /// A line for the host, at the end of the turn.
123    pub fn write(line: &str) {
124        OUT.with(|o| {
125            let mut o = o.borrow_mut();
126            o.extend_from_slice(line.as_bytes());
127            o.push(b'\n');
128        });
129    }
130
131    /// What the turn wrote goes out, in one write.
132    pub fn flush() {
133        OUT.with(|o| {
134            let mut o = o.borrow_mut();
135            if !o.is_empty() {
136                let mut out = std::io::stdout().lock();
137                let _ = out.write_all(&o);
138                let _ = out.flush();
139                o.clear();
140            }
141        });
142    }
143
144    /// What each turn runs: given once, by what keeps the plugin.
145    pub fn set_turn(turn: fn()) {
146        TURN.with(|t| t.set(Some(turn)));
147    }
148
149    /// One call of `escouade-turn`: `start` the first time, then the turn it set.
150    #[doc(hidden)]
151    pub fn enter(start: impl FnOnce() -> i32) {
152        if !STARTED.with(|s| s.replace(true)) {
153            start();
154        }
155        if let Some(turn) = TURN.with(Cell::get) {
156            turn();
157        }
158    }
159
160    /// See the native `run`: `handler` kept, a turn being [`crate::serve`] on what came.
161    pub fn run(
162        _name: &str,
163        capabilities: Value,
164        handler: impl FnMut(&str, Value) -> Result<Value, Refusal> + 'static,
165    ) -> i32 {
166        let handler = Box::new(handler);
167        MACHINE.with(|m| {
168            *m.borrow_mut() = Some(Machine {
169                capabilities,
170                handler,
171            })
172        });
173        set_turn(machine_turn);
174        0
175    }
176
177    fn machine_turn() {
178        let input = lines().join("\n");
179        MACHINE.with(|m| {
180            let mut m = m.borrow_mut();
181            let Some(m) = m.as_mut() else { return };
182            let mut out = Vec::new();
183            let handler = &mut m.handler;
184            let capabilities = m.capabilities.clone();
185            let _ = serve(input.as_bytes(), &mut out, capabilities, |m, p| {
186                handler(m, p)
187            });
188            OUT.with(|o| o.borrow_mut().extend_from_slice(&out));
189        });
190        flush();
191    }
192
193    #[link(wasm_import_module = "escouade")]
194    extern "C" {
195        /// A call served at once on the plugin's own thread: a JSON request, its JSON answer written at `out` (`cap`
196        /// bytes): how many bytes it took, or how many it needs as a negative number.
197        fn call(request: *const u8, len: usize, out: *mut u8, cap: usize) -> i64;
198    }
199
200    #[link(wasm_import_module = "wasi_snapshot_preview1")]
201    extern "C" {
202        fn random_get(buf: *mut u8, len: usize) -> i32;
203    }
204
205    /// A synchronous call of the host (`fs/read`): for what a state machine must have before it answers. Its time counts
206    /// in the turn's. A host that asks for more room makes the call again.
207    pub fn host_call(method: &str, params: Value) -> Result<Value, String> {
208        let request = json!({ "method": method, "params": params }).to_string();
209        let mut out = vec![0u8; 64 * 1024];
210        loop {
211            // SAFETY: both buffers live through the call, which keeps within their lengths.
212            let n = unsafe { call(request.as_ptr(), request.len(), out.as_mut_ptr(), out.len()) };
213            if n < 0 {
214                out.resize(n.unsigned_abs() as usize, 0);
215                continue;
216            }
217            let v: Value = serde_json::from_slice(&out[..n as usize]).map_err(|e| e.to_string())?;
218            return match v.get("error") {
219                Some(e) => Err(e.as_str().unwrap_or("refused").to_string()),
220                None => Ok(v["result"].clone()),
221            };
222        }
223    }
224
225    /// What tells the ids of this instance from those of the one before it, remade: a module has no process, so WASI's
226    /// random numbers (`std::process::id` traps here). Of the size of a process's id.
227    pub fn instance_id() -> u64 {
228        let mut bytes = [0u8; 4];
229        // SAFETY: the buffer lives through the call, which fills its length.
230        let failed = unsafe { random_get(bytes.as_mut_ptr(), bytes.len()) };
231        if failed != 0 {
232            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
233            return now.map_or(0, |d| u64::from(d.subsec_nanos()));
234        }
235        u64::from(u32::from_le_bytes(bytes))
236    }
237
238    /// See the native `read_file`.
239    pub fn read_file(path: &Path) -> Option<String> {
240        let v = host_call("fs/read", json!({ "path": path.to_string_lossy() })).ok()?;
241        v["text"].as_str().map(str::to_string)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn a_line_cut_in_two_comes_whole_at_the_next_turn() {
251        let mut partial = Vec::new();
252        assert_eq!(whole_lines(&mut partial, b"one\ntw"), vec!["one"]);
253        assert_eq!(partial, b"tw");
254        assert!(whole_lines(&mut partial, b"o").is_empty());
255        assert_eq!(whole_lines(&mut partial, b"\nthree\n"), ["two", "three"]);
256        assert!(partial.is_empty());
257    }
258
259    #[test]
260    fn an_instance_has_the_id_of_its_process_natively() {
261        assert_eq!(instance_id(), u64::from(std::process::id()));
262    }
263
264    #[test]
265    fn a_file_is_read_from_the_disk_natively() {
266        let this = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
267        assert!(read_file(&this).is_some_and(|t| t.contains("escouade-plugin-protocol")));
268        assert_eq!(read_file(&this.join("nothing")), None);
269    }
270}