Skip to main content

rpi_cli/
node_transport.rs

1//! Multiplexed JSON-lines transport for long-lived Node extension runtimes.
2//!
3//! Requests may complete out of order. A dedicated reader dispatches each
4//! response by id, while Node-to-Rust runtime requests use the same stdin pipe
5//! for their replies. This keeps transport concerns out of the Pi adapter and
6//! gives persistent packages and future one-shot PTC runtimes one protocol.
7
8use std::collections::HashMap;
9use std::io::{BufRead, BufReader, Write};
10use std::path::PathBuf;
11use std::process::{Child, ChildStdin, ChildStdout};
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::{mpsc, Arc, Mutex};
14
15use serde_json::Value;
16
17pub type RuntimeHandler = Arc<dyn Fn(&str, Value) -> Result<Value, String> + Send + Sync>;
18
19type Response = Result<Value, String>;
20type Pending = Arc<Mutex<HashMap<u64, mpsc::Sender<Response>>>>;
21pub type ToolUpdateHandler = Arc<dyn Fn(Value) + Send + Sync>;
22
23const NODE_STOPPED: &str = "Node extension host stopped";
24
25struct Inner {
26    /// Shared with the reader thread so an EOF or malformed response can
27    /// terminate a broken host even while other transport clones remain alive.
28    child: Arc<Mutex<Child>>,
29    stdin: Arc<Mutex<ChildStdin>>,
30    pending: Pending,
31    tool_update_handlers: Arc<Mutex<HashMap<String, ToolUpdateHandler>>>,
32    runtime_handlers: Arc<Mutex<Vec<RuntimeHandler>>>,
33    next_id: AtomicU64,
34    /// Shared with the reader thread so a natural EOF or malformed response
35    /// makes every existing transport clone observe the terminal state.
36    shutdown: Arc<AtomicBool>,
37    reader: Mutex<Option<std::thread::JoinHandle<()>>>,
38    cleanup_path: Option<PathBuf>,
39}
40
41impl Inner {
42    fn is_shutdown(&self) -> bool {
43        self.shutdown.load(Ordering::Acquire)
44    }
45
46    fn mark_failed(&self, error: &str) {
47        self.shutdown.store(true, Ordering::Release);
48        fail_pending(&self.pending, error);
49        terminate_child(&self.child);
50    }
51
52    /// Stop the child and wake every request that is waiting on its response.
53    ///
54    /// The operation is deliberately idempotent. A session can be shut down
55    /// while detached command/tool work still owns a transport clone, and the
56    /// final `Drop` must be able to run the same cleanup without racing a
57    /// second kill/wait.
58    fn shutdown(&self) {
59        let first_shutdown = !self.shutdown.swap(true, Ordering::AcqRel);
60        // Wake callers before waiting for the process. `Child::kill` is
61        // synchronous on some platforms and must not keep a pending command
62        // blocked behind process cleanup.
63        if first_shutdown {
64            fail_pending(&self.pending, NODE_STOPPED);
65        }
66        terminate_child(&self.child);
67    }
68}
69
70impl Drop for Inner {
71    fn drop(&mut self) {
72        self.shutdown();
73        if let Ok(reader) = self.reader.get_mut() {
74            if let Some(reader) = reader.take() {
75                let _ = reader.join();
76            }
77        }
78        if let Some(path) = self.cleanup_path.as_ref() {
79            let _ = std::fs::remove_file(path);
80        }
81    }
82}
83
84#[derive(Clone)]
85pub struct NodeTransport {
86    inner: Arc<Inner>,
87}
88
89pub struct PendingRequest {
90    id: u64,
91    receiver: mpsc::Receiver<Response>,
92}
93
94/// A spawned Node host whose initialization response has not necessarily
95/// arrived yet.  Keeping the transport available separately from the init
96/// receiver lets callers publish it to their shutdown path before extension
97/// factories or lifecycle hooks can block.
98pub struct NodeTransportStartup {
99    transport: NodeTransport,
100    init_receiver: mpsc::Receiver<Response>,
101}
102
103impl NodeTransportStartup {
104    /// Return a clone of the live transport.  The clone is intentionally cheap
105    /// and can be stored in a concurrent shutdown slot while `wait` blocks.
106    pub fn transport(&self) -> NodeTransport {
107        self.transport.clone()
108    }
109
110    /// Wait for the host's `id:0` initialization envelope.  Any failed or
111    /// closed initialization path terminates the child before returning.
112    pub fn wait(self) -> Result<(NodeTransport, Value), String> {
113        let result = match self.init_receiver.recv() {
114            Ok(Ok(init)) => Ok(init),
115            Ok(Err(error)) => Err(error),
116            Err(_) => Err("Node extension host exited before initialization".into()),
117        };
118        match result {
119            Ok(init) => Ok((self.transport, init)),
120            Err(error) => {
121                self.transport.shutdown();
122                Err(error)
123            }
124        }
125    }
126}
127
128impl PendingRequest {
129    pub fn id(&self) -> u64 {
130        self.id
131    }
132
133    pub fn wait(self) -> Response {
134        self.receiver
135            .recv()
136            .unwrap_or_else(|_| Err("Node extension response channel closed".into()))
137    }
138}
139
140impl NodeTransport {
141    pub fn start(
142        child: Child,
143        stdin: ChildStdin,
144        stdout: ChildStdout,
145    ) -> Result<(Self, Value), String> {
146        Self::start_with_cleanup_and_handlers(child, stdin, stdout, None, Vec::new())
147    }
148
149    /// Start a transport and remove an optional host script when the child is
150    /// dropped. Windows cannot carry the embedded Node source in `node -e`
151    /// once the host grows beyond the CreateProcess command-line limit, so the
152    /// caller may launch a temporary `.mjs` file and hand its path to us.
153    pub fn start_with_cleanup(
154        child: Child,
155        stdin: ChildStdin,
156        stdout: ChildStdout,
157        cleanup_path: Option<PathBuf>,
158    ) -> Result<(Self, Value), String> {
159        Self::start_with_cleanup_and_handlers(child, stdin, stdout, cleanup_path, Vec::new())
160    }
161
162    /// Start a transport with runtime handlers that are available while the
163    /// Node host is loading extension factories and lifecycle hooks. The host
164    /// may issue `runtime_request` messages before it publishes its `id:0`
165    /// initialization response, so the reader must be running before waiting
166    /// for that response.
167    pub fn start_with_cleanup_and_handlers(
168        child: Child,
169        stdin: ChildStdin,
170        stdout: ChildStdout,
171        cleanup_path: Option<PathBuf>,
172        initial_handlers: Vec<RuntimeHandler>,
173    ) -> Result<(Self, Value), String> {
174        Self::start_pending_with_cleanup_and_handlers(
175            child,
176            stdin,
177            stdout,
178            cleanup_path,
179            initial_handlers,
180        )?
181        .wait()
182    }
183
184    /// Spawn the transport and start its reader, returning before the Node
185    /// host's initialization response arrives.  This is the primitive used by
186    /// the lazy runtime so shutdown can kill a host whose extension factory is
187    /// stuck before it emits `id:0`.
188    pub fn start_pending_with_cleanup_and_handlers(
189        child: Child,
190        stdin: ChildStdin,
191        stdout: ChildStdout,
192        cleanup_path: Option<PathBuf>,
193        initial_handlers: Vec<RuntimeHandler>,
194    ) -> Result<NodeTransportStartup, String> {
195        let stdout = BufReader::new(stdout);
196        let stdin = Arc::new(Mutex::new(stdin));
197        let pending = Arc::new(Mutex::new(HashMap::new()));
198        let tool_update_handlers = Arc::new(Mutex::new(HashMap::new()));
199        let runtime_handlers = Arc::new(Mutex::new(initial_handlers));
200        let shutdown = Arc::new(AtomicBool::new(false));
201        let (init_sender, init_receiver) = mpsc::channel();
202        let child = Arc::new(Mutex::new(child));
203        let inner = Arc::new(Inner {
204            child: child.clone(),
205            stdin: stdin.clone(),
206            pending: pending.clone(),
207            tool_update_handlers: tool_update_handlers.clone(),
208            runtime_handlers: runtime_handlers.clone(),
209            next_id: AtomicU64::new(1),
210            shutdown: shutdown.clone(),
211            reader: Mutex::new(None),
212            cleanup_path,
213        });
214        let reader = std::thread::Builder::new()
215            .name("rpi-node-transport".into())
216            .spawn(move || {
217                read_loop(
218                    stdout,
219                    stdin,
220                    child,
221                    pending,
222                    tool_update_handlers,
223                    runtime_handlers,
224                    Some(init_sender),
225                    shutdown,
226                )
227            })
228            .map_err(|error| {
229                terminate_child(&inner.child);
230                if let Some(path) = inner.cleanup_path.as_ref() {
231                    let _ = std::fs::remove_file(path);
232                }
233                format!("could not start Node response reader: {error}")
234            })?;
235        if let Err(error) = inner.reader.lock().map(|mut slot| *slot = Some(reader)) {
236            terminate_child(&inner.child);
237            if let Some(path) = inner.cleanup_path.as_ref() {
238                let _ = std::fs::remove_file(path);
239            }
240            return Err(format!("Node reader lock poisoned: {error}"));
241        }
242
243        // Initialization is delivered by the same reader that handles all
244        // subsequent responses. This prevents a startup runtime request from
245        // being mistaken for the first response or blocking the response
246        // reader until after the lifecycle hook completes.
247        Ok(NodeTransportStartup {
248            transport: Self { inner },
249            init_receiver,
250        })
251    }
252
253    /// Stop the Node host and resolve all currently pending requests with an
254    /// error. Safe to call repeatedly and while other transport clones exist.
255    pub fn shutdown(&self) {
256        self.inner.shutdown();
257    }
258
259    /// Whether the reader or an explicit shutdown has made this transport
260    /// unusable. Lazy owners use this to discard a stale slot and retry on the
261    /// next request.
262    pub fn is_shutdown(&self) -> bool {
263        self.inner.is_shutdown()
264    }
265
266    /// Whether two handles refer to the same child process.  Lazy startup can
267    /// briefly expose one handle through both its `starting` and `transport`
268    /// slots; callers updating shared runtime handlers must avoid appending the
269    /// same callback twice in that window.
270    pub fn same_instance(&self, other: &Self) -> bool {
271        Arc::ptr_eq(&self.inner, &other.inner)
272    }
273
274    pub fn begin_request(&self, method: &str, payload: Value) -> Result<PendingRequest, String> {
275        if self.inner.is_shutdown() {
276            return Err(NODE_STOPPED.into());
277        }
278        let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
279        let (sender, receiver) = mpsc::channel();
280        let mut pending = self
281            .inner
282            .pending
283            .lock()
284            .map_err(|_| "Node pending-request lock poisoned")?;
285        if self.inner.is_shutdown() {
286            return Err(NODE_STOPPED.into());
287        }
288        pending.insert(id, sender);
289        // Close the small race where shutdown sets the flag after the check
290        // above but before this request is inserted. Holding the pending lock
291        // makes shutdown either drain this entry or observe this branch.
292        if self.inner.is_shutdown() {
293            let sender = pending.remove(&id);
294            drop(pending);
295            if let Some(sender) = sender {
296                let _ = sender.send(Err(NODE_STOPPED.into()));
297            }
298            return Err(NODE_STOPPED.into());
299        }
300        drop(pending);
301
302        let mut request = serde_json::json!({"id": id, "method": method});
303        if let (Some(object), Some(values)) = (request.as_object_mut(), payload.as_object()) {
304            object.extend(values.clone());
305        }
306        if let Err(error) = write_value(&self.inner.stdin, &request) {
307            if let Ok(mut pending) = self.inner.pending.lock() {
308                pending.remove(&id);
309            }
310            self.inner.mark_failed(&error);
311            return Err(error);
312        }
313        Ok(PendingRequest { id, receiver })
314    }
315
316    pub fn request(&self, method: &str, payload: Value) -> Response {
317        self.begin_request(method, payload)?.wait()
318    }
319
320    pub fn send_event(&self, event: Value) -> Result<(), String> {
321        if self.inner.is_shutdown() {
322            return Err(NODE_STOPPED.into());
323        }
324        if let Err(error) = write_value(&self.inner.stdin, &event) {
325            self.inner.mark_failed(&error);
326            return Err(error);
327        }
328        Ok(())
329    }
330
331    /// Send a host event and wait for a normal id-correlated response. This
332    /// is used for terminal input listeners, whose `consume` result must be
333    /// known before the outer Rust TUI dispatches the same key.
334    pub fn request_event(&self, mut event: Value) -> Response {
335        if self.inner.is_shutdown() {
336            return Err(NODE_STOPPED.into());
337        }
338        let supported = event
339            .get("event")
340            .and_then(Value::as_str)
341            .is_some_and(|name| matches!(name, "custom_input" | "custom_resize"));
342        if !supported {
343            return Err("host event does not support request/response mode".into());
344        }
345        let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
346        let (sender, receiver) = mpsc::channel();
347        let mut pending = self
348            .inner
349            .pending
350            .lock()
351            .map_err(|_| "Node pending-request lock poisoned")?;
352        if self.inner.is_shutdown() {
353            return Err(NODE_STOPPED.into());
354        }
355        pending.insert(id, sender);
356        if self.inner.is_shutdown() {
357            let sender = pending.remove(&id);
358            drop(pending);
359            if let Some(sender) = sender {
360                let _ = sender.send(Err(NODE_STOPPED.into()));
361            }
362            return Err(NODE_STOPPED.into());
363        }
364        drop(pending);
365        let Some(object) = event.as_object_mut() else {
366            if let Ok(mut pending) = self.inner.pending.lock() {
367                pending.remove(&id);
368            }
369            return Err("Node host event must be a JSON object".into());
370        };
371        object.insert("id".into(), Value::from(id));
372        if let Err(error) = write_value(&self.inner.stdin, &event) {
373            if let Ok(mut pending) = self.inner.pending.lock() {
374                pending.remove(&id);
375            }
376            self.inner.mark_failed(&error);
377            return Err(error);
378        }
379        receiver
380            .recv()
381            .unwrap_or_else(|_| Err("Node extension response channel closed".into()))
382    }
383
384    pub fn cancel(&self, id: u64) -> Result<(), String> {
385        self.send_event(serde_json::json!({
386            "type": "host_event",
387            "event": "cancel_request",
388            "id": id,
389        }))
390    }
391
392    /// Register a callback for partial updates emitted by one JS tool call.
393    ///
394    /// The callback is intentionally keyed by the Pi tool-call id rather than
395    /// the transport request id: the former is what Node receives and what
396    /// remains stable when a request is cancelled or retried.
397    pub fn register_tool_update_handler(
398        &self,
399        tool_call_id: impl Into<String>,
400        handler: ToolUpdateHandler,
401    ) -> Result<(), String> {
402        self.inner
403            .tool_update_handlers
404            .lock()
405            .map_err(|_| "Node tool-update lock poisoned")?
406            .insert(tool_call_id.into(), handler);
407        Ok(())
408    }
409
410    /// Stop routing partial updates after a tool call settles. This also
411    /// suppresses updates sent by a JS tool after its execute promise resolves,
412    /// matching the agent loop's late-update gate.
413    pub fn unregister_tool_update_handler(&self, tool_call_id: &str) {
414        if let Ok(mut handlers) = self.inner.tool_update_handlers.lock() {
415            handlers.remove(tool_call_id);
416        }
417    }
418
419    pub fn replace_runtime_handlers(&self, handler: RuntimeHandler) -> Result<(), String> {
420        *self
421            .inner
422            .runtime_handlers
423            .lock()
424            .map_err(|_| "Node runtime handler lock poisoned")? = vec![handler];
425        Ok(())
426    }
427
428    pub fn add_runtime_handler(&self, handler: RuntimeHandler) -> Result<(), String> {
429        self.inner
430            .runtime_handlers
431            .lock()
432            .map_err(|_| "Node runtime handler lock poisoned")?
433            .push(handler);
434        Ok(())
435    }
436}
437
438fn read_loop(
439    mut stdout: BufReader<ChildStdout>,
440    stdin: Arc<Mutex<ChildStdin>>,
441    child: Arc<Mutex<Child>>,
442    pending: Pending,
443    tool_update_handlers: Arc<Mutex<HashMap<String, ToolUpdateHandler>>>,
444    handlers: Arc<Mutex<Vec<RuntimeHandler>>>,
445    init_sender: Option<mpsc::Sender<Response>>,
446    shutdown: Arc<AtomicBool>,
447) {
448    let mut init_sender = init_sender;
449    loop {
450        let message = match read_value(&mut stdout) {
451            Ok(message) => message,
452            Err(error) => {
453                // A reader failure is terminal even when the child process
454                // itself is still around (for example after malformed JSON).
455                // Publish that state before waking callers so lazy owners do
456                // not keep reusing a broken stdin/stdout pair.
457                shutdown.store(true, Ordering::Release);
458                fail_pending(&pending, &error);
459                terminate_child(&child);
460                if let Some(sender) = init_sender.take() {
461                    let _ = sender.send(Err(error));
462                }
463                return;
464            }
465        };
466        // The host reserves id 0 for its initialization envelope. Route it
467        // through a dedicated channel while leaving the reader alive for
468        // runtime requests and normal request/response traffic.
469        if init_sender.is_some() && message.get("id").and_then(Value::as_u64) == Some(0) {
470            if let Some(sender) = init_sender.take() {
471                // Preserve the full initialization envelope for callers. The
472                // regular request path unwraps `result`, but startup callers
473                // inspect the `ok` and `result` fields themselves.
474                let _ = sender.send(Ok(message));
475            }
476            continue;
477        }
478        if message.get("type").and_then(Value::as_str) == Some("runtime_request") {
479            let background = message
480                .get("action")
481                .and_then(Value::as_str)
482                .is_some_and(runtime_action_may_block);
483            if background {
484                let stdin = stdin.clone();
485                let handlers = handlers.clone();
486                let pending = pending.clone();
487                let child = child.clone();
488                let shutdown = shutdown.clone();
489                std::thread::spawn(move || {
490                    handle_runtime_request(message, &stdin, &handlers, &pending, &child, &shutdown)
491                });
492            } else {
493                handle_runtime_request(message, &stdin, &handlers, &pending, &child, &shutdown);
494            }
495            continue;
496        }
497        if message.get("type").and_then(Value::as_str) == Some("host_event")
498            && message.get("event").and_then(Value::as_str) == Some("tool_update")
499        {
500            let Some(tool_call_id) = message.get("toolCallId").and_then(Value::as_str) else {
501                continue;
502            };
503            let handler = tool_update_handlers
504                .lock()
505                .ok()
506                .and_then(|handlers| handlers.get(tool_call_id).cloned());
507            if let Some(handler) = handler {
508                handler(message.get("partialResult").cloned().unwrap_or_default());
509            }
510            continue;
511        }
512        let Some(id) = message.get("id").and_then(Value::as_u64) else {
513            continue;
514        };
515        let sender = pending
516            .lock()
517            .ok()
518            .and_then(|mut values| values.remove(&id));
519        let Some(sender) = sender else {
520            continue;
521        };
522        let response = response_from_message(&message);
523        let _ = sender.send(response);
524    }
525}
526
527/// Kill and reap the host after a terminal transport failure. This is kept
528/// separate from `Inner::shutdown` because the reader thread owns no `Inner`
529/// handle and must never try to join itself.
530fn terminate_child(child: &Arc<Mutex<Child>>) {
531    if let Ok(mut child) = child.lock() {
532        let _ = child.kill();
533        let _ = child.wait();
534    }
535}
536
537fn response_from_message(message: &Value) -> Response {
538    if message.get("ok").and_then(Value::as_bool) == Some(true) {
539        Ok(message.get("result").cloned().unwrap_or_default())
540    } else {
541        Err(message
542            .get("error")
543            .and_then(Value::as_str)
544            .unwrap_or("JS extension failed")
545            .to_string())
546    }
547}
548
549fn runtime_action_may_block(action: &str) -> bool {
550    action.starts_with("provider.")
551        || action.starts_with("tools.")
552        || action.starts_with("session.")
553        // A dialog waits for a key press in the TUI. Run it away from the
554        // response reader so a concurrent `ui.dialog.cancel` can still be
555        // dispatched when the command is aborted or times out.
556        || action == "ui.dialog"
557}
558
559fn handle_runtime_request(
560    message: Value,
561    stdin: &Arc<Mutex<ChildStdin>>,
562    handlers: &Arc<Mutex<Vec<RuntimeHandler>>>,
563    pending: &Pending,
564    child: &Arc<Mutex<Child>>,
565    shutdown: &Arc<AtomicBool>,
566) {
567    let Some(request_id) = message.get("requestId").and_then(Value::as_u64) else {
568        return;
569    };
570    let Some(action) = message.get("action").and_then(Value::as_str) else {
571        return;
572    };
573    let args = message.get("args").cloned().unwrap_or_default();
574    let handlers = handlers.lock().map(|values| values.clone());
575    let mut result = Err(format!("unsupported capability: {action}"));
576    match handlers {
577        Ok(handlers) => {
578            for handler in handlers {
579                match handler(action, args.clone()) {
580                    Ok(value) => {
581                        result = Ok(value);
582                        break;
583                    }
584                    Err(error) if error.starts_with("unsupported capability:") => {}
585                    Err(error) => {
586                        result = Err(error);
587                        break;
588                    }
589                }
590            }
591        }
592        Err(_) => result = Err("Node runtime handler lock poisoned".into()),
593    }
594    let response = match result {
595        Ok(result) => serde_json::json!({
596            "type": "runtime_response",
597            "requestId": request_id,
598            "ok": true,
599            "result": result,
600        }),
601        Err(error) => serde_json::json!({
602            "type": "runtime_response",
603            "requestId": request_id,
604            "ok": false,
605            "error": error,
606        }),
607    };
608    if let Err(error) = write_value(stdin, &response) {
609        // A runtime response is part of the host protocol. If Rust cannot
610        // deliver it, the Node side may be blocked forever waiting for this
611        // request. Tear down the broken transport and wake every Rust caller
612        // instead of silently leaving both sides hanging.
613        shutdown.store(true, Ordering::Release);
614        fail_pending(pending, &error);
615        terminate_child(child);
616    }
617}
618
619fn write_value(stdin: &Arc<Mutex<ChildStdin>>, value: &Value) -> Result<(), String> {
620    let mut stdin = stdin.lock().map_err(|_| "Node host stdin lock poisoned")?;
621    writeln!(stdin, "{value}")
622        .map_err(|error| format!("could not write to Node extension host: {error}"))?;
623    stdin
624        .flush()
625        .map_err(|error| format!("could not flush Node extension host: {error}"))
626}
627
628fn read_value(reader: &mut BufReader<ChildStdout>) -> Result<Value, String> {
629    let mut line = String::new();
630    reader
631        .read_line(&mut line)
632        .map_err(|error| format!("could not read Node extension host: {error}"))?;
633    if line.trim().is_empty() {
634        return Err("Node extension host exited without a response".into());
635    }
636    serde_json::from_str(&line).map_err(|error| format!("invalid Node extension response: {error}"))
637}
638
639fn fail_pending(pending: &Pending, error: &str) {
640    let values = pending
641        .lock()
642        .map(|mut values| values.drain().map(|(_, sender)| sender).collect::<Vec<_>>())
643        .unwrap_or_default();
644    for sender in values {
645        let _ = sender.send(Err(error.to_string()));
646    }
647}