Skip to main content

irgx/runtime/
mod.rs

1//! Transports, the fallback ladder, and the crate's typed failures.
2//!
3//! Every face of this crate asks its question here, and `runtime` decides *how*
4//! it gets answered:
5//!
6//! | tier | when |
7//! |---|---|
8//! | [`plane`] — the in-process analytic C ABI | the symbols are present and the schema digest agrees |
9//! | [`relay`] — the certified CLIs, NDJSON in | always; the fail-open floor |
10//! | [`shell`] / [`session`] — the exact plane's process + resident transports | the rg-parity search surface |
11//!
12//! The ladder is **fail-open by construction**. `IRGX_STALE` is a
13//! *declinature*, not a failure: the tier is saying "ask the next one and you
14//! will get the same answer", so it never reaches the caller as an `Err`. The
15//! same is true of an absent analytic symbol — an engine built before the analytic plane
16//! landed simply has no in-process plane, and the crate keeps working.
17//!
18//! Exactly two things are loud. A **schema digest mismatch** ([`Error::SchemaDrift`])
19//! means the library's row tables and this build's decoder disagree, so falling
20//! back would hide a real version skew; and a **row that contradicts its own
21//! declaration** ([`Error::Decode`]) is corruption, not a miss.
22
23pub mod answer;
24mod cancel;
25pub mod cell;
26pub mod decode;
27pub mod handshake;
28mod lower;
29pub mod plane;
30mod readout;
31pub mod relay;
32#[cfg(unix)]
33pub mod session;
34pub mod shell;
35pub mod sys;
36mod verify;
37
38/// Synthesized wire buffers the decoder tests are driven from.
39#[cfg(test)]
40mod fixture;
41
42#[cfg(unix)]
43pub use session::{Session, default_socket_path, warm_eligible};
44
45use std::fmt;
46use std::os::raw::c_void;
47
48pub use answer::{Batch, BatchIter, RowIter, Rows, Stats, Tier};
49pub use cancel::CancelToken;
50pub use cell::{OwnedRow, OwnedValue, RowSeq, Texts, Value};
51pub use decode::Row;
52
53/// The one error type every fallible call in this crate returns.
54#[derive(Debug)]
55#[non_exhaustive]
56pub enum Error {
57    /// No engine binary could be located: not at the per-face env override
58    /// (`<FACE>_BIN`), not at a built `zig-out/bin/<name>` in
59    /// this checkout, an ancestor, or the sibling checkout that owns the name,
60    /// and not on `PATH`. The message names every path it tried, in order.
61    /// Build one with `zig build`.
62    NotFound(String),
63    /// The pattern or flag combination is outside the linear-time engine
64    /// (e.g. PCRE2 lookaround/backreferences, `-U` multiline) — the engine
65    /// exited 2 and named the ripgrep fallback on stderr.
66    UnsupportedPattern(String),
67    /// The pattern is malformed in EVERY grammar the engine has, so no `engine`
68    /// choice lifts it — the message names the defect and points at the
69    /// offending byte. Distinct from [`Error::UnsupportedPattern`] because the
70    /// two ask for opposite responses: that one says *retry on another engine*,
71    /// this one says *fix the pattern*. The engine only makes this claim after
72    /// asking PCRE2 and being refused too.
73    BadPattern(String),
74    /// The engine exited non-zero for an I/O, walk, or timeout reason (an
75    /// unreadable directory, a missing explicit path) — fail-loud, never a
76    /// silent empty result.
77    Failed(String),
78    /// A [`crate::SearchRequest`] option the in-process cursor ABI cannot honor
79    /// (glob/type scoping, multiline, `no_index`, a non-linear `engine`, …). The
80    /// in-process `Engine` carries only match-finding intent the C ABI has a
81    /// field for; run the full CLI surface through [`crate::SearchRequest::run`]
82    /// instead.
83    Unrepresentable(String),
84    /// The loaded library's row-schema digest disagrees with the table this
85    /// crate's decoder was generated from. Named down to the drifting schema,
86    /// because the alternative is a silently mis-decoded row.
87    SchemaDrift(String),
88    /// A row contradicted its own declaration — an unknown schema id, a value
89    /// tag disagreeing with the contract, or text that is not UTF-8.
90    Decode(String),
91    /// This process has no in-process analytic plane, or its engine predates the
92    /// cancellation trio, so there is no query a token could stop. Distinct from
93    /// [`Error::Failed`] because it is a statement about the build rather than a
94    /// fault: every verb still answers, through the subprocess tier, and a host
95    /// that does not need to interrupt one can ignore this entirely.
96    Uncancellable,
97    /// The child process could not be spawned or its pipes could not be read.
98    Io(std::io::Error),
99}
100
101/// Crate-wide `Result` alias.
102pub type Result<T> = std::result::Result<T, Error>;
103
104impl fmt::Display for Error {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            // The message already names the tool it went looking for, and it is
108            // not always the exact face — the same error carries a missing
109            // kinship or composed binary. Prefix with the crate, not with one
110            // of the three faces.
111            Self::NotFound(m) => write!(f, "irregex: {m}"),
112            Self::UnsupportedPattern(m) => write!(f, "unsupported pattern: {m}"),
113            Self::BadPattern(m) => write!(f, "malformed pattern: {m}"),
114            Self::Failed(m) => write!(f, "gist search failed: {m}"),
115            Self::Unrepresentable(m) => write!(f, "option not representable in-process: {m}"),
116            Self::SchemaDrift(m) => write!(f, "analytic schema drift: {m}"),
117            Self::Decode(m) => write!(f, "analytic row does not match its schema: {m}"),
118            Self::Uncancellable => write!(
119                f,
120                "this process has no in-process analytic plane to cancel; \
121                 every verb still answers through the subprocess tier"
122            ),
123            Self::Io(e) => write!(f, "gist io error: {e}"),
124        }
125    }
126}
127
128impl std::error::Error for Error {
129    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
130        match self {
131            Self::Io(e) => Some(e),
132            _ => None,
133        }
134    }
135}
136
137impl From<std::io::Error> for Error {
138    fn from(e: std::io::Error) -> Self {
139        Self::Io(e)
140    }
141}
142
143// ── the request seam ───────────────────────────────────────────────────────
144
145/// One analytic request, lowered for whichever tier answers it.
146///
147/// Each of the five `[analytic.params]` families implements this once; the two
148/// transports then consume the *same* request without either knowing the other
149/// exists. Adding a verb to an existing family costs one [`Query::op`] arm.
150pub trait Query {
151    /// The `IRGX_OP_*` code (`[analytic.verbs]`).
152    fn op(&self) -> u32;
153    /// The size-checked C params struct, borrowing this request's buffers.
154    fn wire(&self) -> Wire<'_>;
155    /// The CLI invocation that answers the same question out of process.
156    ///
157    /// # Errors
158    /// [`Error::Unrepresentable`] for a request the CLI surface cannot spell.
159    fn argv(&self) -> Result<relay::Invocation>;
160    /// The corpus roots. The in-process plane carries them on the *engine*
161    /// (there is no roots field in any params family), the CLIs carry them as
162    /// trailing argv — so the request declares them once, here.
163    fn roots(&self) -> &[std::path::PathBuf];
164    /// The pattern set for the two families that carry one (`sweep`, `compose`).
165    ///
166    /// Kept off [`Query::wire`] because an `irgx_text[]` has to be built
167    /// somewhere that outlives the params struct, and the only such place is the
168    /// caller of both.
169    fn texts(&self) -> Vec<&str> {
170        Vec::new()
171    }
172    /// The directory to run a subprocess in; `None` = inherit.
173    fn cwd(&self) -> Option<&std::path::Path> {
174        None
175    }
176}
177
178/// A filled `[analytic.params]` family, borrowing the request that built it.
179///
180/// The families are separate structs (rather than one union) because a
181/// producer entry size-checks each against its declared shape, and a
182/// mismatched size is `IRGX_INVALID` by design.
183pub enum Wire<'a> {
184    Kinship(sys::KinshipParams, std::marker::PhantomData<&'a ()>),
185    Retrieval(sys::RetrievalParams, std::marker::PhantomData<&'a ()>),
186    Sweep(sys::SweepParams, std::marker::PhantomData<&'a ()>),
187    Compose(sys::ComposeParams, std::marker::PhantomData<&'a ()>),
188    Rank(sys::RankParams, std::marker::PhantomData<&'a ()>),
189}
190
191impl<'a> Wire<'a> {
192    /// Point the two pattern-carrying families at a `irgx_text[]` the caller
193    /// owns. `'a` ties that array to the same request the params borrow from, so
194    /// the pointer cannot outlive the strings behind it.
195    pub fn bind(&mut self, texts: &'a [sys::Text]) {
196        let (ptr, n) = (texts.as_ptr(), texts.len());
197        match self {
198            Self::Sweep(p, _) => (p.patterns, p.npatterns) = (ptr, n),
199            Self::Compose(p, _) => (p.patterns, p.npatterns) = (ptr, n),
200            _ => {},
201        }
202    }
203
204    /// The opaque pointer a producer entry expects, valid for `&self`.
205    pub fn as_ptr(&self) -> *const c_void {
206        match self {
207            Self::Kinship(p, _) => std::ptr::from_ref(p).cast(),
208            Self::Retrieval(p, _) => std::ptr::from_ref(p).cast(),
209            Self::Sweep(p, _) => std::ptr::from_ref(p).cast(),
210            Self::Compose(p, _) => std::ptr::from_ref(p).cast(),
211            Self::Rank(p, _) => std::ptr::from_ref(p).cast(),
212        }
213    }
214}
215
216/// `struct_size` for a params family — the fail-closed handshake every
217/// `[analytic.params]` struct opens with.
218pub fn struct_size<T>() -> u32 {
219    u32::try_from(std::mem::size_of::<T>()).unwrap_or(u32::MAX)
220}
221
222/// Answer one analytic request, walking the ladder.
223///
224/// # Errors
225/// Propagates [`Error::SchemaDrift`] and [`Error::Decode`] loud; every other
226/// in-process refusal falls through to the subprocess tier.
227pub fn answer(query: &impl Query) -> Result<Rows> {
228    match plane::run(query)? {
229        Some(rows) => Ok(rows),
230        None => relay::run(query),
231    }
232}