Skip to main content

pi_codes/
client_async.rs

1//! Async client for `pi --mode rpc`: spawns the CLI, writes one command
2//! per LF-terminated line, and demultiplexes stdout into command
3//! responses and streamed events.
4//!
5//! Framing follows the protocol contract: records split on `\n` only
6//! (tokio's line reader complies; it never splits on U+2028/U+2029).
7
8use crate::cli::{Mode, PiCliBuilder};
9use crate::error::{Error, Result};
10use crate::io::PiEvent;
11use crate::rpc::{RpcCommand, RpcResponse};
12use std::collections::VecDeque;
13use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, ChildStdin, ChildStdout};
15
16/// A running `pi --mode rpc` process.
17pub struct PiRpcClient {
18    child: Child,
19    stdin: ChildStdin,
20    stdout: tokio::io::Lines<BufReader<ChildStdout>>,
21    /// Events that arrived while waiting for a command response.
22    pending_events: VecDeque<PiEvent>,
23    next_id: u64,
24}
25
26impl PiRpcClient {
27    /// Spawn `pi --mode rpc` from a builder. The builder's mode is forced
28    /// to [`Mode::Rpc`].
29    pub async fn spawn(builder: PiCliBuilder) -> Result<Self> {
30        let program = which::which("pi").map_err(|_| Error::BinaryNotFound)?;
31        let mut cmd = tokio::process::Command::new(program);
32        cmd.args(builder.mode(Mode::Rpc).assembled_args())
33            .stdin(std::process::Stdio::piped())
34            .stdout(std::process::Stdio::piped())
35            .stderr(std::process::Stdio::piped())
36            .kill_on_drop(true);
37        let mut child = cmd.spawn()?;
38        let stdin = child.stdin.take().expect("piped stdin");
39        let stdout = child.stdout.take().expect("piped stdout");
40        Ok(Self {
41            child,
42            stdin,
43            stdout: BufReader::new(stdout).lines(),
44            pending_events: VecDeque::new(),
45            next_id: 0,
46        })
47    }
48
49    /// Spawn with defaults: an ephemeral (`--no-session`) RPC server.
50    pub async fn start() -> Result<Self> {
51        Self::spawn(PiCliBuilder::new().no_session(true)).await
52    }
53
54    fn fresh_id(&mut self) -> String {
55        self.next_id += 1;
56        format!("pi-codes-{}", self.next_id)
57    }
58
59    /// Send one command and wait for its response. Events that arrive in
60    /// between are buffered for [`next_event`](Self::next_event).
61    ///
62    /// The command's `id` is used for correlation; commands built without
63    /// one get a client-generated id (the wire response echoes it).
64    pub async fn request(&mut self, mut command: RpcCommand) -> Result<RpcResponse> {
65        let id = match command_id(&command) {
66            Some(id) => id,
67            None => {
68                let id = self.fresh_id();
69                set_command_id(&mut command, &id);
70                id
71            }
72        };
73        let line = command.to_line()?;
74        self.stdin.write_all(line.as_bytes()).await?;
75        self.stdin.flush().await?;
76
77        loop {
78            let v = self.next_value().await?;
79            if v.get("type").and_then(serde_json::Value::as_str) == Some("response") {
80                let resp: RpcResponse =
81                    serde_json::from_value(v.clone()).map_err(|source| Error::Deserialization {
82                        source,
83                        raw_line: v.to_string(),
84                    })?;
85                // Correlate on id when the wire echoes one; a response
86                // without an id answers the oldest in-flight command,
87                // which for this sequential client is ours.
88                match &resp.id {
89                    Some(rid) if rid != &id => continue,
90                    _ => return Ok(resp),
91                }
92            }
93            self.pending_events
94                .push_back(PiEvent::from_value(v.clone()).map_err(|source| {
95                    Error::Deserialization {
96                        source,
97                        raw_line: v.to_string(),
98                    }
99                })?);
100        }
101    }
102
103    /// Convenience: send and fail on `success: false`.
104    pub async fn request_ok(&mut self, command: RpcCommand) -> Result<RpcResponse> {
105        let resp = self.request(command).await?;
106        if resp.success {
107            Ok(resp)
108        } else {
109            Err(Error::CommandFailed(
110                resp.error.unwrap_or_else(|| resp.command.clone()),
111            ))
112        }
113    }
114
115    /// Next streamed event: buffered ones first, then the wire. Returns
116    /// `Ok(None)` on clean EOF (process exit).
117    pub async fn next_event(&mut self) -> Result<Option<PiEvent>> {
118        if let Some(e) = self.pending_events.pop_front() {
119            return Ok(Some(e));
120        }
121        match self.try_next_value().await? {
122            None => Ok(None),
123            Some(v) => Ok(Some(PiEvent::from_value(v.clone()).map_err(|source| {
124                Error::Deserialization {
125                    source,
126                    raw_line: v.to_string(),
127                }
128            })?)),
129        }
130    }
131
132    async fn next_value(&mut self) -> Result<serde_json::Value> {
133        match self.try_next_value().await? {
134            Some(v) => Ok(v),
135            None => {
136                let mut err = String::new();
137                if let Some(stderr) = self.child.stderr.take() {
138                    use tokio::io::AsyncReadExt;
139                    let mut r = BufReader::new(stderr);
140                    let _ = r.read_to_string(&mut err).await;
141                }
142                Err(Error::ProcessExited(err.trim().to_string()))
143            }
144        }
145    }
146
147    async fn try_next_value(&mut self) -> Result<Option<serde_json::Value>> {
148        loop {
149            let Some(line) = self.stdout.next_line().await? else {
150                return Ok(None);
151            };
152            let line = line.trim_end_matches('\r');
153            if line.trim().is_empty() {
154                continue;
155            }
156            return serde_json::from_str(line)
157                .map(Some)
158                .map_err(|source| Error::Deserialization {
159                    source,
160                    raw_line: line.to_string(),
161                });
162        }
163    }
164
165    /// Kill the process and wait for exit.
166    pub async fn shutdown(mut self) -> Result<()> {
167        let _ = self.child.kill().await;
168        let _ = self.child.wait().await;
169        Ok(())
170    }
171}
172
173fn command_id(c: &RpcCommand) -> Option<String> {
174    match serde_json::to_value(c) {
175        Ok(v) => v
176            .get("id")
177            .and_then(serde_json::Value::as_str)
178            .map(str::to_string),
179        Err(_) => None,
180    }
181}
182
183fn set_command_id(c: &mut RpcCommand, id: &str) {
184    // Round-trip through JSON: every modeled command carries an optional
185    // `id`, and Raw commands are plain objects.
186    if let Ok(mut v) = serde_json::to_value(&*c) {
187        if let Some(obj) = v.as_object_mut() {
188            obj.insert("id".into(), serde_json::Value::String(id.to_string()));
189            if let Ok(with_id) = serde_json::from_value::<RpcCommand>(v) {
190                *c = with_id;
191            }
192        }
193    }
194}