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;