Skip to main content

xtask/codex_schema/
typify_probe.rs

1//! Runs `typify::TypeSpace::add_root_schema` against a candidate schema
2//! inside `catch_unwind`, classifying the outcome so `bisect` can
3//! distinguish "converts fine", "panics with the known typify-0.7.0
4//! `merge.rs:427` shape", and "something else entirely" (a different panic,
5//! or an ordinary `Result::Err`).
6
7use std::panic::{self, AssertUnwindSafe};
8use std::sync::{Arc, Mutex, OnceLock};
9
10use serde_json::Value;
11
12#[derive(Debug, Clone)]
13pub enum ProbeOutcome {
14    /// typify converted the schema to Rust types without error.
15    Success,
16    /// Panicked with a message/location matching the known typify 0.7.0
17    /// `merge.rs` "not yet implemented" failure mode this tool hunts for.
18    TargetPanic {
19        message: String,
20        location: Option<String>,
21    },
22    /// Panicked, but not matching that specific signature - still worth
23    /// surfacing to a human, just not what `bisect` is purpose-built to
24    /// search for.
25    OtherPanic {
26        message: String,
27        location: Option<String>,
28    },
29    /// typify returned an ordinary `Result::Err` - no panic, just a schema
30    /// typify doesn't support for some other reason.
31    TypifyError(String),
32    /// The candidate JSON didn't even parse as a `schemars::schema::RootSchema`.
33    InvalidRootSchema(String),
34}
35
36impl ProbeOutcome {
37    pub fn reproduces_target(&self) -> bool {
38        matches!(self, ProbeOutcome::TargetPanic { .. })
39    }
40
41    pub fn summary(&self) -> String {
42        match self {
43            ProbeOutcome::Success => "success".to_string(),
44            ProbeOutcome::TargetPanic { message, location } => {
45                format!("PANIC (target merge.rs shape) at {location:?}: {message}")
46            }
47            ProbeOutcome::OtherPanic { message, location } => {
48                format!("panic (other, not the target shape) at {location:?}: {message}")
49            }
50            ProbeOutcome::TypifyError(e) => format!("typify error (no panic): {e}"),
51            ProbeOutcome::InvalidRootSchema(e) => format!("invalid RootSchema JSON: {e}"),
52        }
53    }
54}
55
56/// Process-wide lock serializing every `probe()` call. `std::panic::set_hook`
57/// / `take_hook` are global, process-wide state, not thread-local - without
58/// this, two `probe()` calls running on different threads at once (e.g.
59/// `cargo test`'s default parallel test execution) can install/restore each
60/// other's hooks out of order and corrupt the captured panic location. The
61/// bisection driver in `bisect.rs` only ever calls `probe()` sequentially on
62/// one thread anyway, so this lock is uncontended in normal use; it exists
63/// purely to make `probe()` itself safe to call concurrently.
64fn probe_lock() -> &'static Mutex<()> {
65    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
66    LOCK.get_or_init(|| Mutex::new(()))
67}
68
69/// Runs typify against `schema` (a full combined-schema JSON document, the
70/// same shape `build.rs` feeds it) inside `catch_unwind`, classifying the
71/// result. Temporarily installs a panic hook to capture the panic location
72/// (the payload alone doesn't carry it), always restoring the previous hook
73/// before returning - there is no early return between install and restore,
74/// so this stays paired even on the panicking path. Serialized process-wide
75/// via `probe_lock` - see its docs.
76pub fn probe(schema: &Value) -> ProbeOutcome {
77    let root: schemars::schema::RootSchema = match serde_json::from_value(schema.clone()) {
78        Ok(r) => r,
79        Err(e) => return ProbeOutcome::InvalidRootSchema(e.to_string()),
80    };
81
82    let _guard = probe_lock()
83        .lock()
84        .unwrap_or_else(|poisoned| poisoned.into_inner());
85
86    let location: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
87    let location_for_hook = Arc::clone(&location);
88    // `probe_lock` only serializes against *other `probe()` calls* - it does
89    // nothing to stop some unrelated panic on a different thread (e.g. an
90    // assertion failure in an unrelated test running concurrently under
91    // `cargo test`'s default parallel execution) from also invoking this
92    // hook, since `panic::set_hook` is process-global, not per-thread.
93    // Filtering on the calling thread's id keeps such a stray panic from
94    // clobbering the location this specific `probe()` call is trying to
95    // capture.
96    let calling_thread = std::thread::current().id();
97    let previous_hook = panic::take_hook();
98    panic::set_hook(Box::new(move |info| {
99        if std::thread::current().id() != calling_thread {
100            return;
101        }
102        if let Some(loc) = info.location() {
103            *location_for_hook.lock().unwrap() =
104                Some(format!("{}:{}:{}", loc.file(), loc.line(), loc.column()));
105        }
106    }));
107
108    let result = panic::catch_unwind(AssertUnwindSafe(|| {
109        let settings = typify::TypeSpaceSettings::default();
110        let mut type_space = typify::TypeSpace::new(&settings);
111        type_space.add_root_schema(root)
112    }));
113
114    panic::set_hook(previous_hook);
115    let captured_location = location.lock().unwrap().clone();
116
117    match result {
118        Ok(Ok(_)) => ProbeOutcome::Success,
119        Ok(Err(e)) => ProbeOutcome::TypifyError(e.to_string()),
120        Err(payload) => {
121            // `&*payload`, not `&payload`: `payload: Box<dyn Any + Send>` is
122            // itself a `'static` type, so it satisfies `Any`'s blanket impl
123            // too - `&payload` would coerce to a trait object representing
124            // the *Box's own* type identity, not the panic value it wraps,
125            // making every downcast_ref below silently fail regardless of
126            // the real payload type. Deref first to reach the actual
127            // panic value before widening it to `&dyn Any + Send`.
128            let message = panic_message(&*payload);
129            // Requires BOTH signals, not just the message: a bare
130            // "not yet implemented" match alone would misclassify any
131            // unrelated `todo!()` panic anywhere in typify's call graph as
132            // this specific known bug. The captured location narrows it to
133            // the exact source file the target panic actually comes from.
134            let is_target = message.contains("not yet implemented")
135                && captured_location
136                    .as_deref()
137                    .is_some_and(|l| l.contains("merge.rs"));
138            if is_target {
139                ProbeOutcome::TargetPanic {
140                    message,
141                    location: captured_location,
142                }
143            } else {
144                ProbeOutcome::OtherPanic {
145                    message,
146                    location: captured_location,
147                }
148            }
149        }
150    }
151}
152
153fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
154    if let Some(s) = payload.downcast_ref::<&str>() {
155        (*s).to_string()
156    } else if let Some(s) = payload.downcast_ref::<String>() {
157        s.clone()
158    } else {
159        "<non-string panic payload>".to_string()
160    }
161}
162
163#[cfg(test)]
164#[path = "typify_probe_tests.rs"]
165mod tests;