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