Skip to main content

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}