Skip to main content

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}