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