Skip to main content

rootle_stdio/
lib.rs

1//! Stdio provider (plans/0005): an external child process speaking
2//! newline-delimited JSON-RPC 2.0 on stdin/stdout — the LSP model.
3//! Wrap any internal source-control system with a small script
4//! (see examples/providers/) and point `[provider] command` at it.
5//!
6//! Layout: this file is the surface — state + spawn constructors.
7//! `process.rs` spawns the child; `transport.rs` owns the reader
8//! thread + reply routing; `handshake.rs` runs the initialize round
9//! trip; `restart.rs` is the respawn-with-backoff recovery machine;
10//! `wire.rs` maps `trait Provider` methods onto round trips;
11//! `trace.rs` emits the diagnostic records (plans/0030).
12//! Transport (plans/0008 §1): a dedicated reader thread owns the
13//! child's stdout and routes replies by id into per-request slots.
14//! Requests wait with a deadline (`[provider] timeout_ms`) — one hung
15//! backend call fails instead of wedging every other call behind the
16//! io mutex, and a late reply is discarded by id-matching. Child EOF
17//! drops every pending slot, so a dead provider fails all in-flight
18//! requests immediately.
19//!
20//! Restart (plans/0008 §5): EOF marks the transport dead; the next
21//! request respawns the child with bounded backoff, re-runs the
22//! initialize handshake, and only then proceeds. A successful restart
23//! leaves a notice (`take_notice`) for the status line.
24
25use self::process::{Process, StderrMode, spawn_process};
26use self::reader::spawn_reader;
27use self::routing::Shared;
28use parking_lot::Mutex;
29use rootle_provider::{Capabilities, ProviderResult};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32use std::thread::JoinHandle;
33use std::time::Duration;
34
35mod handshake;
36mod process;
37mod reader;
38mod restart;
39mod routing;
40mod stderr;
41mod trace;
42mod transport;
43mod wire;
44
45#[cfg(test)]
46mod tests;
47
48/// Default per-request read deadline; `[provider] timeout_ms`.
49pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(30_000);
50
51pub struct StdioProvider {
52    name: String,
53    icon: Option<String>,
54    capabilities: Capabilities,
55    /// Child + its stdin, swapped on rebuild. Writes take this lock:
56    /// an advisory cancel (v1.1) must not queue behind another writer.
57    process: Mutex<Process>,
58    /// Id allocation + reply routing + lifecycle, shared with the
59    /// reader thread(s).
60    shared: Arc<Shared>,
61    /// Request id currently in flight (0 = none) — advisory-cancel
62    /// bookkeeping only; with pipelined requests it names one of them,
63    /// which is fine (cancel is best-effort by contract).
64    current_id: AtomicU64,
65    /// Per-request read deadline (plans/0008 §1).
66    timeout: Duration,
67    /// Respawn parameters, kept verbatim from construction.
68    command: Vec<String>,
69    env: Vec<(String, String)>,
70    stderr_mode: StderrMode,
71    /// Bounded stderr drainer for the same generation — present only
72    /// under Full-content tracing (Null mode); joined wherever the
73    /// stdout reader is, always after the child is terminated.
74    stderr_reader: Mutex<Option<stderr::StderrReader>>,
75    reader: Mutex<Option<JoinHandle<()>>>,
76    /// One-shot UI notice (a successful restart, a config warning),
77    /// drained via `take_notice` (plans/0008 §5).
78    notice: Mutex<Option<String>>,
79    /// 0022 M1: a rebuild-failure streak notices once per streak
80    /// (reset on recovery) — not once per attempt.
81    failure_noticed: Mutex<bool>,
82    /// Set by Drop: a rebuild sleeping in backoff checks this before
83    /// spawning a replacement, so dropping the provider mid-recovery
84    /// can't leak an orphan child after the app is gone. Drop can't
85    /// race an in-flight rebuild only because every worker clones the
86    /// `Arc<dyn Provider>` before moving into its thread (the strong
87    /// count can't hit zero mid-call) — keep that convention.
88    closed: AtomicBool,
89    /// Advisory cache budget (the user's [cache] max_mb in bytes) and
90    /// this provider's subtree path — passed at every initialize;
91    /// providers that cache SHOULD evict past it (protocol v1.2).
92    cache_bytes: u64,
93    cache_dir: Option<std::path::PathBuf>,
94    /// Cache usage the provider reported at initialize, if any —
95    /// surfaced in :settings.
96    cache_used: parking_lot::Mutex<Option<u64>>,
97}
98
99impl Drop for StdioProvider {
100    /// rootle owns the provider lifecycle: the child dies with the app
101    /// (kill first — stdin EOF alone is timing-dependent; the dead
102    /// stdout then lets the reader thread exit and be joined).
103    fn drop(&mut self) {
104        self.closed.store(true, Ordering::Release);
105        let process = self.process.get_mut();
106        process.terminate();
107        if let Some(handle) = self.reader.lock().take() {
108            let _ = handle.join();
109        }
110        // Stop the bounded drainer even if descendants inherited the pipe.
111        if let Some(handle) = self.stderr_reader.lock().take() {
112            let _ = handle.join();
113        }
114    }
115}
116
117#[derive(Default)]
118struct SpawnCache {
119    bytes: u64,
120    directory: Option<std::path::PathBuf>,
121}
122
123impl StdioProvider {
124    /// Spawn the provider and run the initialize handshake.
125    pub fn spawn(command: &[String], timeout: Duration) -> ProviderResult<Self> {
126        Self::spawn_inner(
127            command,
128            timeout,
129            &[],
130            StderrMode::Null,
131            SpawnCache::default(),
132        )
133    }
134
135    /// Spawn with the configured stderr policy (plans/0008 §4):
136    /// `Inherit` passes the child's stderr through for adapter
137    /// debugging; anything else discards it.
138    pub fn spawn_with_stderr(
139        command: &[String],
140        timeout: Duration,
141        inherit_stderr: bool,
142    ) -> ProviderResult<Self> {
143        Self::spawn_with_cache(command, timeout, inherit_stderr, 0, None)
144    }
145
146    /// Spawn with the user's cache budget and the provider's subtree
147    /// path — both travel in every initialize (protocol v1.2, advisory).
148    pub fn spawn_with_cache(
149        command: &[String],
150        timeout: Duration,
151        inherit_stderr: bool,
152        cache_bytes: u64,
153        cache_dir: Option<std::path::PathBuf>,
154    ) -> ProviderResult<Self> {
155        let mode = if inherit_stderr {
156            StderrMode::Inherit
157        } else {
158            StderrMode::Null
159        };
160        Self::spawn_inner(
161            command,
162            timeout,
163            &[],
164            mode,
165            SpawnCache {
166                bytes: cache_bytes,
167                directory: cache_dir,
168            },
169        )
170    }
171
172    /// Spawn with extra environment for the child process — the
173    /// test-binary re-exec seam (fakes pick their script by env).
174    pub fn spawn_with_env(
175        command: &[String],
176        timeout: Duration,
177        env: &[(&str, &str)],
178    ) -> ProviderResult<Self> {
179        Self::spawn_inner(
180            command,
181            timeout,
182            env,
183            StderrMode::Null,
184            SpawnCache::default(),
185        )
186    }
187
188    fn spawn_inner(
189        command: &[String],
190        timeout: Duration,
191        env: &[(&str, &str)],
192        stderr_mode: StderrMode,
193        cache: SpawnCache,
194    ) -> ProviderResult<Self> {
195        let spawned = spawn_process(command, env, stderr_mode);
196        let shared = Arc::new(Shared::default());
197        let session = shared.routing.lock().session;
198        let (mut process, stdout, child_stderr) = match spawned {
199            Ok(spawned) => {
200                self::trace::lifecycle("spawn", |fields| {
201                    fields.insert("session".into(), serde_json::json!(session.value()));
202                });
203                spawned
204            }
205            Err(error) => {
206                self::trace::lifecycle("spawn_failed", |fields| {
207                    fields.insert("session".into(), serde_json::json!(session.value()));
208                });
209                return Err(error);
210            }
211        };
212        let reader = spawn_reader(stdout, Arc::clone(&shared), session)?;
213        let stderr_handle = match child_stderr
214            .map(|stderr| self::stderr::spawn(stderr, session))
215            .transpose()
216        {
217            Ok(handle) => handle,
218            Err(error) => {
219                rootle_trace::mark_incomplete("provider stderr reader could not start");
220                process.terminate();
221                let _ = reader.join();
222                return Err(rootle_provider::ProviderError::other(format!(
223                    "start provider stderr reader: {error}"
224                )));
225            }
226        };
227        let provider = StdioProvider {
228            name: "stdio".into(),
229            icon: None,
230            capabilities: Capabilities {
231                commit: false,
232                orgs: true,
233                code_search: true,
234                file_search: true,
235                // v1.5 defaults false until the handshake says
236                // otherwise (default-branch-only providers).
237                refs: false,
238                log: false,
239                blame: false,
240            },
241            process: Mutex::new(process),
242            shared,
243            current_id: AtomicU64::new(0),
244            timeout,
245            command: command.to_vec(),
246            env: env
247                .iter()
248                .map(|(k, v)| (k.to_string(), v.to_string()))
249                .collect(),
250            stderr_mode,
251            stderr_reader: Mutex::new(stderr_handle),
252            reader: Mutex::new(Some(reader)),
253            notice: Mutex::new(None),
254            failure_noticed: Mutex::new(false),
255            closed: AtomicBool::new(false),
256            cache_bytes: cache.bytes,
257            cache_dir: cache.directory,
258            cache_used: parking_lot::Mutex::new(None),
259        };
260        // Same deadline as any request: a provider that hangs on
261        // startup fails into the github fallback instead of blocking
262        // launch (plans/0008 §1).
263        let reply = provider.handshake()?;
264        provider.initialize_from(reply)
265    }
266}