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