lattice_lsp/transport.rs
1//! Child-process transport for an LSP server.
2//!
3//! Spawns the configured server binary, captures its stdio, and
4//! exposes split [`LspReader`] / [`LspWriter`] halves so the
5//! actor can run `read_loop` and `write_loop` on independent
6//! tokio tasks (the canonical full-duplex pattern).
7//!
8//! ## Cross-platform process discovery
9//!
10//! `tokio::process::Command::new(name)` consults the host's
11//! `PATH` (and on Windows applies the standard `.exe` suffix
12//! search). The transport does no manual PATH walking; if a
13//! server binary cannot be found, the error returned from
14//! `spawn` carries the OS-level reason verbatim.
15//!
16//! ## stderr capture
17//!
18//! LSP servers log to stderr. We pipe stderr into a background
19//! task that emits each line through `tracing::warn!` with a
20//! `server_id` field so users can debug a misbehaving server
21//! without seeing its noise on the terminal. The stderr task
22//! ends naturally when the server closes the pipe.
23//!
24//! ## Lifecycle
25//!
26//! `ChildTransport` owns the `Child` handle. Dropping the
27//! transport closes stdin (signalling shutdown to the server in
28//! the LSP protocol) but does NOT kill the process -- the actor
29//! is responsible for sending the `shutdown`/`exit` LSP sequence
30//! and awaiting graceful exit. Use [`ChildTransport::kill`] to
31//! force-terminate from a `Drop` implementation in the actor's
32//! supervision layer.
33
34use std::ffi::OsStr;
35use std::path::PathBuf;
36use std::process::Stdio;
37
38use thiserror::Error;
39use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
40
41use crate::codec::{LspReader, LspWriter};
42
43/// Spawn errors and lifecycle errors for the transport.
44#[derive(Debug, Error)]
45pub enum TransportError {
46 /// `spawn` failed -- usually because the binary isn't on
47 /// PATH or isn't executable.
48 #[error("failed to spawn LSP server {binary:?}: {source}")]
49 Spawn {
50 binary: PathBuf,
51 #[source]
52 source: std::io::Error,
53 },
54 /// Spawn succeeded but stdio pipes weren't captured. Should
55 /// be impossible given `Stdio::piped()` on all three handles,
56 /// but the error path keeps the API total.
57 #[error("LSP server stdio not captured")]
58 MissingStdio,
59 /// `kill` / `wait` failed at lifecycle teardown.
60 #[error("transport lifecycle: {0}")]
61 Lifecycle(#[source] std::io::Error),
62}
63
64/// One spawned LSP server process and its split codec halves.
65///
66/// Constructed by [`ChildTransport::spawn`]. After
67/// [`ChildTransport::split`] the reader, writer, and child handle
68/// can be moved into independent tasks. The actor pattern:
69///
70/// ```ignore
71/// let t = ChildTransport::spawn("rust-analyzer", &[], None).await?;
72/// let (reader, writer, child) = t.split();
73/// tokio::spawn(read_loop(reader));
74/// tokio::spawn(write_loop(writer, mailbox_rx));
75/// // child handle stays with the supervisor for kill / wait.
76/// ```
77#[derive(Debug)]
78pub struct ChildTransport {
79 child: Child,
80 stdin: ChildStdin,
81 stdout: ChildStdout,
82 /// Held until [`Self::split`]; consumed there to spawn the
83 /// stderr drain task.
84 stderr: Option<ChildStderr>,
85}
86
87impl ChildTransport {
88 /// Spawn the server binary with the given args. `cwd` is the
89 /// working directory passed to the child; LSP servers
90 /// typically resolve workspace-relative paths against it.
91 /// Pass the resolved workspace root when known.
92 pub async fn spawn<P, I, S>(
93 binary: P,
94 args: I,
95 cwd: Option<&std::path::Path>,
96 ) -> Result<Self, TransportError>
97 where
98 P: AsRef<OsStr>,
99 I: IntoIterator<Item = S>,
100 S: AsRef<OsStr>,
101 {
102 let binary_path = PathBuf::from(binary.as_ref());
103 let mut cmd = Command::new(&binary_path);
104 cmd.args(args)
105 .stdin(Stdio::piped())
106 .stdout(Stdio::piped())
107 .stderr(Stdio::piped())
108 // kill_on_drop matches our supervision contract: if
109 // the actor task panics before sending shutdown/exit,
110 // we don't leak a server process. The actor's normal
111 // path still runs the LSP shutdown handshake.
112 .kill_on_drop(true);
113 if let Some(dir) = cwd {
114 cmd.current_dir(dir);
115 }
116
117 let mut child = cmd.spawn().map_err(|source| TransportError::Spawn {
118 binary: binary_path.clone(),
119 source,
120 })?;
121
122 let stdin = child.stdin.take().ok_or(TransportError::MissingStdio)?;
123 let stdout = child.stdout.take().ok_or(TransportError::MissingStdio)?;
124 let stderr = child.stderr.take();
125 Ok(Self {
126 child,
127 stdin,
128 stdout,
129 stderr,
130 })
131 }
132
133 /// Process id of the spawned server. Useful for logs /
134 /// telemetry. `None` if the child has already been reaped.
135 pub fn pid(&self) -> Option<u32> {
136 self.child.id()
137 }
138
139 /// Consume the transport and return the reader, writer, and
140 /// retained child handle. The stderr pipe (if captured) is
141 /// returned alongside so the caller can spawn its own drain
142 /// task with whatever logging context is appropriate
143 /// (server_id, language, workspace root). This keeps the
144 /// transport free of `tracing` calls -- the actor decides.
145 pub fn split(
146 mut self,
147 ) -> (
148 LspReader<tokio::io::BufReader<ChildStdout>>,
149 LspWriter<ChildStdin>,
150 Option<ChildStderr>,
151 Child,
152 ) {
153 let reader = LspReader::new(self.stdout);
154 let writer = LspWriter::new(self.stdin);
155 let stderr = self.stderr.take();
156 (reader, writer, stderr, self.child)
157 }
158
159 /// Force-kill the server process. The actor's normal
160 /// shutdown path runs `shutdown` + `exit` LSP requests
161 /// instead; this is the supervision-layer fallback when the
162 /// server hangs.
163 pub async fn kill(mut self) -> Result<(), TransportError> {
164 self.child.start_kill().map_err(TransportError::Lifecycle)?;
165 self.child.wait().await.map_err(TransportError::Lifecycle)?;
166 Ok(())
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 // `Message`, `Notification`, and `serde_json::json!` are only
174 // used by the `#[cfg(unix)]`-gated round-trip test below
175 // (spawns `cat`); gate the imports the same way so Windows
176 // builds (which skip the test) don't flag them as unused.
177 #[cfg(unix)]
178 use crate::jsonrpc::{Message, Notification};
179 #[cfg(unix)]
180 use serde_json::json;
181
182 /// Spawning a non-existent binary surfaces `TransportError::Spawn`.
183 /// Cross-platform friendly: doesn't depend on shell semantics.
184 #[tokio::test]
185 async fn spawn_missing_binary_is_error() {
186 let err = ChildTransport::spawn(
187 "lattice-lsp-fixture-this-does-not-exist-x7q",
188 std::iter::empty::<&str>(),
189 None,
190 )
191 .await
192 .unwrap_err();
193 assert!(matches!(err, TransportError::Spawn { .. }));
194 }
195
196 /// Spawn `cat` (POSIX) as a stand-in echo server: the LSP
197 /// codec writes a frame to stdin, `cat` mirrors it to
198 /// stdout, the codec reads it back. Validates the
199 /// stdin → stdout round-trip with no LSP-specific server
200 /// behaviour.
201 #[cfg(unix)]
202 #[tokio::test]
203 async fn cat_echoes_one_message() {
204 let t = ChildTransport::spawn("cat", std::iter::empty::<&str>(), None)
205 .await
206 .unwrap();
207 let (mut reader, mut writer, _stderr, mut child) = t.split();
208
209 let n = Message::Notification(Notification::new(
210 "telemetry/event",
211 Some(json!({"k": "v"})),
212 ));
213 writer.write_message(&n).await.unwrap();
214 // Close stdin so cat sees EOF and exits; otherwise the
215 // child stays alive forever waiting for more input.
216 drop(writer);
217
218 let got = reader.read_message().await.unwrap().unwrap();
219 match got {
220 Message::Notification(n) => assert_eq!(n.method, "telemetry/event"),
221 _ => panic!("expected echoed notification"),
222 }
223 // After cat sees EOF, it exits; verify clean stream end.
224 assert!(reader.read_message().await.unwrap().is_none());
225 let status = child.wait().await.unwrap();
226 assert!(status.success());
227 }
228
229 /// PID is exposed while the child is alive.
230 #[cfg(unix)]
231 #[tokio::test]
232 async fn pid_is_exposed() {
233 let t = ChildTransport::spawn("cat", std::iter::empty::<&str>(), None)
234 .await
235 .unwrap();
236 assert!(t.pid().is_some());
237 t.kill().await.unwrap();
238 }
239}