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/// A pool of parse-worker subprocesses.
49pub struct ParserPool {
50    worker_bin: PathBuf,
51    worker_env: Vec<(String, String)>,
52    slots: Vec<Mutex<Option<Worker>>>,
53    next: AtomicUsize,
54    timeout: Duration,
55}
56
57impl ParserPool {
58    /// Create a pool of `size` workers running `worker_bin` (the
59    /// `gonzalo-parse-worker` binary), each parse bounded by `timeout`. Workers
60    /// spawn lazily on first use.
61    pub fn new(worker_bin: impl Into<PathBuf>, size: usize, timeout: Duration) -> Self {
62        let size = size.max(1);
63        let slots = (0..size).map(|_| Mutex::new(None)).collect();
64        Self {
65            worker_bin: worker_bin.into(),
66            worker_env: Vec::new(),
67            slots,
68            next: AtomicUsize::new(0),
69            timeout,
70        }
71    }
72
73    /// Set extra environment variables handed to every spawned worker (on top of
74    /// the inherited environment).
75    pub fn with_worker_env(mut self, vars: Vec<(String, String)>) -> Self {
76        self.worker_env = vars;
77        self
78    }
79
80    /// The number of worker slots.
81    pub fn size(&self) -> usize {
82        self.slots.len()
83    }
84
85    /// Parse `source` as `language` into a [`CodeGraph`] on an isolated worker.
86    /// A worker crash is retried once on a fresh worker; a hang past the timeout
87    /// is not retried (it may be pathological input that always hangs).
88    pub async fn parse(&self, language: Language, source: &str) -> Result<CodeGraph, ParseError> {
89        let idx = self.next.fetch_add(1, Ordering::Relaxed) % self.slots.len();
90        let mut slot = self.slots[idx].lock().await;
91
92        let mut last_err = ParseError::WorkerDied;
93        for _ in 0..2 {
94            if slot.is_none() {
95                *slot = Some(
96                    Worker::spawn(&self.worker_bin, &self.worker_env).map_err(ParseError::Spawn)?,
97                );
98            }
99            let worker = slot.as_mut().expect("worker present");
100            match tokio::time::timeout(self.timeout, worker.roundtrip(language, source)).await {
101                Ok(Ok(graph)) => return Ok(graph),
102                Ok(Err(e)) => {
103                    // Dead worker: drop it (kill_on_drop) and retry on a fresh one.
104                    *slot = None;
105                    last_err = e;
106                }
107                Err(_) => {
108                    // Hung worker: drop it and give up (don't retry a hang).
109                    *slot = None;
110                    return Err(ParseError::Timeout(self.timeout));
111                }
112            }
113        }
114        Err(last_err)
115    }
116}
117
118/// One live worker subprocess and its pipes.
119struct Worker {
120    // Held so `kill_on_drop` tears the child down when the worker is dropped.
121    _child: tokio::process::Child,
122    stdin: ChildStdin,
123    stdout: BufReader<ChildStdout>,
124}
125
126impl Worker {
127    fn spawn(bin: &Path, env: &[(String, String)]) -> std::io::Result<Self> {
128        let mut child = Command::new(bin)
129            .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
130            .stdin(Stdio::piped())
131            .stdout(Stdio::piped())
132            .stderr(Stdio::null())
133            .kill_on_drop(true)
134            .spawn()?;
135        let stdin = child.stdin.take().expect("stdin piped");
136        let stdout = BufReader::new(child.stdout.take().expect("stdout piped"));
137        Ok(Self {
138            _child: child,
139            stdin,
140            stdout,
141        })
142    }
143
144    /// Send one request and read one response. A broken pipe or EOF means the
145    /// worker died.
146    async fn roundtrip(
147        &mut self,
148        language: Language,
149        source: &str,
150    ) -> Result<CodeGraph, ParseError> {
151        let request = ParseRequest {
152            language,
153            source: source.to_string(),
154        };
155        let mut req = serde_json::to_string(&request).expect("ParseRequest serializes");
156        req.push('\n');
157        self.stdin
158            .write_all(req.as_bytes())
159            .await
160            .map_err(|_| ParseError::WorkerDied)?;
161        self.stdin
162            .flush()
163            .await
164            .map_err(|_| ParseError::WorkerDied)?;
165
166        let mut line = String::new();
167        let n = self
168            .stdout
169            .read_line(&mut line)
170            .await
171            .map_err(|_| ParseError::WorkerDied)?;
172        if n == 0 {
173            return Err(ParseError::WorkerDied); // EOF: the worker exited
174        }
175        serde_json::from_str(&line).map_err(|e| ParseError::Protocol(e.to_string()))
176    }
177}