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/// 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}