gonzalo_parse/lib.rs
1//! Crash-isolated code parsing (ticket E).
2//!
3//! tree-sitter grammars are C and can `abort()`/segfault on malformed input,
4//! which Rust cannot catch. [`ParserPool`] runs [`build_rust`](gonzalo_graph::build_rust)
5//! in a pool of `gonzalo-parse-worker` subprocesses, so such a crash kills only
6//! a worker; the pool respawns it and the graph store/query layer (in the parent)
7//! is never taken down. This is the isolation required before parsing arbitrary
8//! target-repo input at scale.
9//!
10//! The pool keeps `size` long-lived workers and dispatches parses round-robin,
11//! one in flight per worker. A worker that dies (crash) or hangs past the
12//! per-parse `timeout` is dropped and lazily respawned on next use; a death is
13//! retried once on a fresh worker so an unlucky crash doesn't fail a good parse.
14
15use gonzalo_graph::{CodeGraph, Language};
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::process::Stdio;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use std::time::Duration;
21use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
22use tokio::process::{ChildStdin, ChildStdout, Command};
23use tokio::sync::Mutex;
24
25/// One parse request sent to a worker: the source and the language to parse it
26/// as. Serialized as one JSON line (JSON escapes newlines, so a whole file is a
27/// single line).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ParseRequest {
30 pub language: Language,
31 pub source: String,
32}
33
34/// Why a parse did not return a graph. All variants are recoverable — the pool
35/// has already dropped the offending worker.
36#[derive(Debug, thiserror::Error)]
37pub enum ParseError {
38 #[error("failed to spawn parse worker: {0}")]
39 Spawn(#[source] std::io::Error),
40 #[error("parse worker died (crashed or closed its pipe)")]
41 WorkerDied,
42 #[error("parse worker exceeded the {0:?} timeout")]
43 Timeout(Duration),
44 #[error("parse worker sent a malformed response: {0}")]
45 Protocol(String),
46}
47
48/// Ask the worker binary at `bin` which extraction format it produces, giving
49/// up after `timeout`.
50///
51/// The worker is a **separately installed binary**, so a caller's own
52/// [`EXTRACTION_VERSION`](gonzalo_graph::EXTRACTION_VERSION) says nothing about
53/// what actually parses: a new CLI driving an old worker emits pre-upgrade
54/// extraction while believing it emitted current data (#228). This is how the
55/// caller finds out instead of assuming.
56///
57/// `None` means *unknown*, never *agreed*. It covers every way the answer can
58/// fail to arrive — the binary is missing or not executable, it exits non-zero,
59/// it prints something unparseable, it hangs, or it is a pre-#228 worker that
60/// ignores argv and blocks on stdin (the probe closes stdin, so such a worker
61/// exits silently). A caller must treat `None` as a version that cannot match.
62pub async fn worker_extraction_version(bin: &Path, timeout: Duration) -> Option<u32> {
63 const BUSY_RETRIES: u32 = 4;
64
65 for attempt in 0..=BUSY_RETRIES {
66 let probe = Command::new(bin)
67 .arg("--extraction-version")
68 // Closed, not inherited: a pre-#228 worker reads argv-less and would
69 // otherwise sit forever waiting for a parse request.
70 .stdin(Stdio::null())
71 .stdout(Stdio::piped())
72 .stderr(Stdio::null())
73 .kill_on_drop(true)
74 .output();
75
76 let out = match tokio::time::timeout(timeout, probe).await {
77 Err(_) => return None, // hung — do not retry, it will hang again
78 Ok(Ok(out)) => out,
79 Ok(Err(e)) => {
80 // ETXTBSY: someone holds the binary open for writing. On Linux
81 // a concurrent process spawn is enough — the fork inherits an
82 // open write fd until it execs — and a worker being reinstalled
83 // while an index runs hits the same window. Reporting "unknown"
84 // for a worker that is merely momentarily busy would trigger a
85 // spurious full walk (#228), so wait it out briefly.
86 if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempt < BUSY_RETRIES {
87 tokio::time::sleep(Duration::from_millis(20)).await;
88 continue;
89 }
90 return None;
91 }
92 };
93
94 if !out.status.success() {
95 return None;
96 }
97 return std::str::from_utf8(&out.stdout).ok()?.trim().parse().ok();
98 }
99 None
100}
101
102/// A pool of parse-worker subprocesses.
103pub struct ParserPool {
104 worker_bin: PathBuf,
105 worker_env: Vec<(String, String)>,
106 slots: Vec<Mutex<Option<Worker>>>,
107 next: AtomicUsize,
108 timeout: Duration,
109}
110
111impl ParserPool {
112 /// Create a pool of `size` workers running `worker_bin` (the
113 /// `gonzalo-parse-worker` binary), each parse bounded by `timeout`. Workers
114 /// spawn lazily on first use.
115 pub fn new(worker_bin: impl Into<PathBuf>, size: usize, timeout: Duration) -> Self {
116 let size = size.max(1);
117 let slots = (0..size).map(|_| Mutex::new(None)).collect();
118 Self {
119 worker_bin: worker_bin.into(),
120 worker_env: Vec::new(),
121 slots,
122 next: AtomicUsize::new(0),
123 timeout,
124 }
125 }
126
127 /// Set extra environment variables handed to every spawned worker (on top of
128 /// the inherited environment).
129 pub fn with_worker_env(mut self, vars: Vec<(String, String)>) -> Self {
130 self.worker_env = vars;
131 self
132 }
133
134 /// The number of worker slots.
135 pub fn size(&self) -> usize {
136 self.slots.len()
137 }
138
139 /// Parse `source` as `language` into a [`CodeGraph`] on an isolated worker.
140 /// A worker crash is retried once on a fresh worker; a hang past the timeout
141 /// is not retried (it may be pathological input that always hangs).
142 pub async fn parse(&self, language: Language, source: &str) -> Result<CodeGraph, ParseError> {
143 let idx = self.next.fetch_add(1, Ordering::Relaxed) % self.slots.len();
144 let mut slot = self.slots[idx].lock().await;
145
146 let mut last_err = ParseError::WorkerDied;
147 for _ in 0..2 {
148 if slot.is_none() {
149 *slot = Some(
150 Worker::spawn(&self.worker_bin, &self.worker_env).map_err(ParseError::Spawn)?,
151 );
152 }
153 let worker = slot.as_mut().expect("worker present");
154 match tokio::time::timeout(self.timeout, worker.roundtrip(language, source)).await {
155 Ok(Ok(graph)) => return Ok(graph),
156 Ok(Err(e)) => {
157 // Dead worker: drop it (kill_on_drop) and retry on a fresh one.
158 *slot = None;
159 last_err = e;
160 }
161 Err(_) => {
162 // Hung worker: drop it and give up (don't retry a hang).
163 *slot = None;
164 return Err(ParseError::Timeout(self.timeout));
165 }
166 }
167 }
168 Err(last_err)
169 }
170}
171
172/// One live worker subprocess and its pipes.
173struct Worker {
174 // Held so `kill_on_drop` tears the child down when the worker is dropped.
175 _child: tokio::process::Child,
176 stdin: ChildStdin,
177 stdout: BufReader<ChildStdout>,
178}
179
180impl Worker {
181 fn spawn(bin: &Path, env: &[(String, String)]) -> std::io::Result<Self> {
182 let mut child = Command::new(bin)
183 .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
184 .stdin(Stdio::piped())
185 .stdout(Stdio::piped())
186 .stderr(Stdio::null())
187 .kill_on_drop(true)
188 .spawn()?;
189 let stdin = child.stdin.take().expect("stdin piped");
190 let stdout = BufReader::new(child.stdout.take().expect("stdout piped"));
191 Ok(Self {
192 _child: child,
193 stdin,
194 stdout,
195 })
196 }
197
198 /// Send one request and read one response. A broken pipe or EOF means the
199 /// worker died.
200 async fn roundtrip(
201 &mut self,
202 language: Language,
203 source: &str,
204 ) -> Result<CodeGraph, ParseError> {
205 let request = ParseRequest {
206 language,
207 source: source.to_string(),
208 };
209 let mut req = serde_json::to_string(&request).expect("ParseRequest serializes");
210 req.push('\n');
211 self.stdin
212 .write_all(req.as_bytes())
213 .await
214 .map_err(|_| ParseError::WorkerDied)?;
215 self.stdin
216 .flush()
217 .await
218 .map_err(|_| ParseError::WorkerDied)?;
219
220 let mut line = String::new();
221 let n = self
222 .stdout
223 .read_line(&mut line)
224 .await
225 .map_err(|_| ParseError::WorkerDied)?;
226 if n == 0 {
227 return Err(ParseError::WorkerDied); // EOF: the worker exited
228 }
229 serde_json::from_str(&line).map_err(|e| ParseError::Protocol(e.to_string()))
230 }
231}