on master help sign in
Contain the knot-compile-rt FFI boundary; share the marshalling helper

The prior commit wrapped the compiler binary's `knot_compile_impl`, but the `base.compile` boundary a *compiled program* actually links is the one in knot-compile-rt (via `knot_jit::compile_and_run`) — and it had no catch_unwind, so a panic there still unwound across `extern "C"` into the host. - Add `run_compile_contained` (+ `CompileOutcome`, `compile_out_params_init`, `compile_write_out`) to knot-runtime — the common dependency of both compile entry points. It owns the catch_unwind, the panic→`out_err` marshalling, and the ty/relations out-param writes. Both `knot_compile_impl` bodies (the compiler binary's and knot-compile-rt's) now delegate to it, so the two can't drift apart. - Verified `ResolvedType::Unit` can never reach the schema descriptor (nothing produces it), and unit-of-measure fields (`Int (M / S)`) resolve to a proper scalar column — so the descriptor's panic arm stays genuinely unreachable. - Add `compile_containment_tests` in knot-runtime: success writes out-params, a clean compile error returns null, and a panic is contained and surfaced via `out_err`. Full workspace suite: 522 passed, 0 failed.

commita620d5bda84668416d7916e7128856ac050794cf authoriko <ilyakooo0@gmail.com> dateSat 5 Sep 2026, 12:22 (1 month ago) parent5e8376b
3 files changed+292−134
crates/knot-compile-rt/src/lib.rs+41−79 crates/knot-compiler/src/main.rs+27−55 crates/knot-runtime/src/lib.rs+224
crates/knot-compile-rt/src/lib.rs+41−79
@@ -47,24 +47,11 @@ pub unsafe extern "C" fn knot_compile_impl(
4747) -> *mut knot_runtime::Value {
4848 // Null out-params up front so a compile error leaves them well-defined.
4949 unsafe {
50− if !out_ty_ptr.is_null() {
51− *out_ty_ptr = std::ptr::null_mut();
52− }
53− if !out_ty_len.is_null() {
54− *out_ty_len = 0;
55− }
56− if !out_rels_ptr.is_null() {
57− *out_rels_ptr = std::ptr::null_mut();
58− }
59− if !out_rels_len.is_null() {
60− *out_rels_len = 0;
61− }
62− if !out_err_ptr.is_null() {
63− *out_err_ptr = std::ptr::null_mut();
64− }
65− if !out_err_len.is_null() {
66− *out_err_len = 0;
67− }
50+ knot_runtime::compile_out_params_init([
51+ (out_ty_ptr, out_ty_len),
52+ (out_rels_ptr, out_rels_len),
53+ (out_err_ptr, out_err_len),
54+ ]);
6855 }
6956
7057 let src_bytes = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
@@ -81,69 +68,44 @@ pub unsafe extern "C" fn knot_compile_impl(
8168 std::str::from_utf8(b).ok()
8269 };
8370
84− match knot_jit::compile_and_run(
85− source,
86− db,
87− expected,
88− // Stamp the result's closures with the JIT module's code handle so the
89− // module's lifetime is refcounted by the values that reference it.
90− Some(set_code as unsafe extern "C" fn(*mut c_void, *const c_void)),
91− ) {
92− Ok(cv) => {
93− // Hand the inferred type back as a malloc'd buffer (C ABI — the
94− // runtime frees it). Only when we actually have one.
95− if let Some(ty) = cv.ty
96− && !out_ty_ptr.is_null()
97− && !out_ty_len.is_null()
98− {
99− let buf = unsafe { libc::malloc(ty.len()) as *mut u8 };
100− if !buf.is_null() {
101− unsafe {
102− std::ptr::copy_nonoverlapping(ty.as_ptr(), buf, ty.len());
103− *out_ty_ptr = buf;
104− *out_ty_len = ty.len();
105− }
106− } else {
107− unsafe { libc::free(buf as *mut c_void) };
108− }
109− }
110− // Hand the snippet's declared relations back as a comma-separated
111− // malloc'd buffer, for the runtime's host-relation check.
112− if !out_rels_ptr.is_null() && !out_rels_len.is_null() {
113− let csv = cv.relations.join(",");
114− let buf = unsafe { libc::malloc(csv.len().max(1)) as *mut u8 };
115− if !buf.is_null() {
116− unsafe {
117− std::ptr::copy_nonoverlapping(csv.as_ptr(), buf, csv.len());
118− *out_rels_ptr = buf;
119− *out_rels_len = csv.len();
120− }
121− } else {
122− unsafe { libc::free(buf as *mut c_void) };
123− }
124− }
125− // knot-jit treats the value as opaque *mut c_void; here it's a
126− // knot-runtime Value again.
127− cv.value as *mut knot_runtime::Value
128− }
129− Err(e) => {
130− // Hand the compile-error message back as a malloc'd buffer (C ABI —
131− // the runtime frees it) so `base.compile` can return `Err {error}`.
132− let msg = e.0;
133− if !out_err_ptr.is_null() && !out_err_len.is_null() {
134− let buf = unsafe { libc::malloc(msg.len().max(1)) as *mut u8 };
135− if !buf.is_null() {
136− unsafe {
137− std::ptr::copy_nonoverlapping(msg.as_ptr(), buf, msg.len());
138− *out_err_ptr = buf;
139− *out_err_len = msg.len();
71+ // This is the `knot_compile_impl` a *compiled program* links — the
72+ // `base.compile` boundary that runs in the host's process. Contain any
73+ // panic (in compilation or the JIT'd snippet) so it surfaces as an Err
74+ // instead of unwinding across `extern "C"` into the host. Shared with the
75+ // compiler binary's impl via `run_compile_contained` so the two can't drift.
76+ unsafe {
77+ knot_runtime::run_compile_contained(
78+ db,
79+ out_ty_ptr,
80+ out_ty_len,
81+ out_rels_ptr,
82+ out_rels_len,
83+ out_err_ptr,
84+ out_err_len,
85+ |db| {
86+ // A clean compile error: report it via out_err (as null), which
87+ // the shared helper leaves to the runtime's `Result.Err`. We
88+ // signal it by writing the message here and returning None.
89+ match knot_jit::compile_and_run(
90+ source,
91+ db,
92+ expected,
93+ Some(set_code as unsafe extern "C" fn(*mut c_void, *const c_void)),
94+ ) {
95+ Ok(cv) => Some(knot_runtime::CompileOutcome {
96+ // knot-jit treats the value as opaque *mut c_void; here
97+ // it's a knot-runtime Value again.
98+ value: cv.value as *mut knot_runtime::Value,
99+ ty: cv.ty,
100+ relations: cv.relations,
101+ }),
102+ Err(e) => {
103+ knot_runtime::compile_write_out(&e.0, out_err_ptr, out_err_len);
104+ None
140105 }
141− } else {
142− unsafe { libc::free(buf as *mut c_void) };
143106 }
144− }
145− std::ptr::null_mut()
146− }
107+ },
108+ )
147109 }
148110}
149111
crates/knot-compiler/src/main.rs+27−55
@@ -158,18 +158,11 @@ pub unsafe extern "C" fn knot_compile_impl(
158158) -> *mut knot_runtime::Value {
159159 // Null out-params up front so a compile error leaves them well-defined.
160160 unsafe {
161− for (p, l) in [
161+ knot_runtime::compile_out_params_init([
162162 (out_ty_ptr, out_ty_len),
163163 (out_rels_ptr, out_rels_len),
164164 (out_err_ptr, out_err_len),
165− ] {
166− if !p.is_null() {
167− *p = std::ptr::null_mut();
168− }
169− if !l.is_null() {
170− *l = 0;
171− }
172− }
165+ ]);
173166 }
174167
175168 let src_bytes = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
@@ -185,53 +178,32 @@ pub unsafe extern "C" fn knot_compile_impl(
185178 std::str::from_utf8(b).ok()
186179 };
187180
188− // Hand a string back as a malloc'd buffer (C ABI — the runtime frees it).
189− unsafe fn write_out(s: &str, ptr: *mut *mut u8, len: *mut usize) {
190− if ptr.is_null() || len.is_null() {
191− return;
192− }
193− let buf = unsafe { libc::malloc(s.len().max(1)) as *mut u8 };
194− if buf.is_null() {
195− return;
196− }
197− unsafe {
198− std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
199− *ptr = buf;
200− *len = s.len();
201− }
202− }
203−
204− // `compile` runs inside the host's process, on the host's db handle. A
205− // panic anywhere in compilation or in the generated snippet must never
206− // unwind across this `extern "C"` boundary (UB / host abort) — contain it
207− // and surface an Err. The raw pointers are !UnwindSafe, so assert it; we
208− // only *read* them and write the nulled-out out-params, no broken state.
209− let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
210− match codegen::jit_compile_typed(source, expected) {
211− Some(compiled) => {
212− if let Some(ty) = &compiled.body_ty {
213− unsafe { write_out(ty, out_ty_ptr, out_ty_len) };
181+ // `compile` runs inside the host's process, on the host's db handle.
182+ // `run_compile_contained` catches any panic and surfaces it as an Err
183+ // rather than unwinding across this `extern "C"` boundary.
184+ unsafe {
185+ knot_runtime::run_compile_contained(
186+ db,
187+ out_ty_ptr,
188+ out_ty_len,
189+ out_rels_ptr,
190+ out_rels_len,
191+ out_err_ptr,
192+ out_err_len,
193+ |db| match codegen::jit_compile_typed(source, expected) {
194+ Some(compiled) => {
195+ let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut knot_runtime::Value =
196+ std::mem::transmute(compiled.entry);
197+ let value = entry_fn(db);
198+ Some(knot_runtime::CompileOutcome {
199+ value,
200+ ty: compiled.body_ty,
201+ relations: compiled.relations,
202+ })
214203 }
215− let csv = compiled.relations.join(",");
216− unsafe { write_out(&csv, out_rels_ptr, out_rels_len) };
217− let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut knot_runtime::Value =
218− unsafe { std::mem::transmute(compiled.entry) };
219− entry_fn(db)
220− }
221− None => std::ptr::null_mut(),
222− }
223− }));
224− match r {
225− Ok(v) => v,
226− Err(payload) => {
227− let msg = payload
228− .downcast_ref::<&str>()
229− .map(|s| (*s).to_string())
230− .or_else(|| payload.downcast_ref::<String>().cloned())
231− .unwrap_or_else(|| "unknown panic".to_string());
232− unsafe { write_out(&format!("compile panicked: {msg}"), out_err_ptr, out_err_len) };
233− std::ptr::null_mut()
234− }
204+ None => None,
205+ },
206+ )
235207 }
236208}
237209
crates/knot-runtime/src/lib.rs+224
@@ -11855,6 +11855,107 @@ type CompileImpl = unsafe extern "C" fn(
1185511855static COMPILE_IMPL: std::sync::atomic::AtomicPtr<c_void> =
1185611856 std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
1185711857
11858+/// The successful result of running a compile closure: the value plus the
11859+/// optional body-type descriptor and the snippet's declared relations, to hand
11860+/// back through the FFI out-params.
11861+pub struct CompileOutcome {
11862+ pub value: *mut Value,
11863+ pub ty: Option<String>,
11864+ pub relations: Vec<String>,
11865+}
11866+
11867+/// Null the three out-param pairs so a compile error leaves them well-defined.
11868+///
11869+/// # Safety
11870+/// Each non-null pointer must be valid for writes of its payload.
11871+pub unsafe fn compile_out_params_init(pairs: [(*mut *mut u8, *mut usize); 3]) {
11872+ unsafe {
11873+ for (p, l) in pairs {
11874+ if !p.is_null() {
11875+ *p = std::ptr::null_mut();
11876+ }
11877+ if !l.is_null() {
11878+ *l = 0;
11879+ }
11880+ }
11881+ }
11882+}
11883+
11884+/// Hand a string back as a malloc'd buffer (C ABI — the caller frees it).
11885+///
11886+/// # Safety
11887+/// `ptr`/`len`, if non-null, must be valid for writes.
11888+pub unsafe fn compile_write_out(s: &str, ptr: *mut *mut u8, len: *mut usize) {
11889+ if ptr.is_null() || len.is_null() {
11890+ return;
11891+ }
11892+ let buf = unsafe { libc::malloc(s.len().max(1)) as *mut u8 };
11893+ if buf.is_null() {
11894+ return;
11895+ }
11896+ unsafe {
11897+ std::ptr::copy_nonoverlapping(s.as_ptr(), buf, s.len());
11898+ *ptr = buf;
11899+ *len = s.len();
11900+ }
11901+}
11902+
11903+/// Run a `base.compile` compile+run closure behind the FFI boundary, containing
11904+/// any panic. `compile` is the compiler- and backend-specific work; it returns
11905+/// `Ok(Some(outcome))` on success, `Ok(None)` on a clean compile error, and any
11906+/// panic is caught here. On success the outcome's ty/relations are written to
11907+/// the out-params and the value returned; on error or panic the (already
11908+/// nulled) out-params get the message in `out_err` and null is returned, so the
11909+/// runtime surfaces a `Result.Err` instead of unwinding into the host.
11910+///
11911+/// This is the single containment point shared by every `knot_compile_impl`
11912+/// (the compiler binary's and the `knot-compile-rt` one a compiled program
11913+/// links), so the two cannot drift apart.
11914+///
11915+/// # Safety
11916+/// `db` must be a live db handle; the out-param pointers, if non-null, must be
11917+/// valid for writes of their payload. They must already be nulled via
11918+/// [`compile_out_params_init`] by the caller.
11919+#[allow(clippy::too_many_arguments)]
11920+pub unsafe fn run_compile_contained(
11921+ db: *mut c_void,
11922+ out_ty_ptr: *mut *mut u8,
11923+ out_ty_len: *mut usize,
11924+ out_rels_ptr: *mut *mut u8,
11925+ out_rels_len: *mut usize,
11926+ out_err_ptr: *mut *mut u8,
11927+ out_err_len: *mut usize,
11928+ compile: impl FnOnce(*mut c_void) -> Option<CompileOutcome>,
11929+) -> *mut Value {
11930+ // The raw pointers are !UnwindSafe, so assert it; the closure only reads
11931+ // them and the out-params were nulled by the caller, so no broken state is
11932+ // observable across a caught panic.
11933+ let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compile(db)));
11934+ match r {
11935+ Ok(Some(outcome)) => {
11936+ if let Some(ty) = &outcome.ty {
11937+ unsafe { compile_write_out(ty, out_ty_ptr, out_ty_len) };
11938+ }
11939+ let csv = outcome.relations.join(",");
11940+ unsafe { compile_write_out(&csv, out_rels_ptr, out_rels_len) };
11941+ outcome.value
11942+ }
11943+ Ok(None) => std::ptr::null_mut(),
11944+ Err(payload) => {
11945+ let msg = payload
11946+ .downcast_ref::<&str>()
11947+ .map(|s| (*s).to_string())
11948+ .or_else(|| payload.downcast_ref::<String>().cloned())
11949+ .unwrap_or_else(|| "unknown panic".to_string());
11950+ unsafe {
11951+ compile_write_out(&format!("compile panicked: {msg}"), out_err_ptr, out_err_len)
11952+ };
11953+ std::ptr::null_mut()
11954+ }
11955+ }
11956+}
11957+
11958+
1185811959/// Registered by knot-compile-rt at program startup to install the compile
1185911960/// implementation. Called from the generated program's `main`.
1186011961#[unsafe(no_mangle)]
@@ -24986,3 +25087,126 @@ mod show_tests {
2498625087 );
2498725088 }
2498825089}
25090+
25091+#[cfg(test)]
25092+mod compile_containment_tests {
25093+ //! `base.compile` runs in the host's process; a panic in compilation or the
25094+ //! generated snippet must surface as an Err, never unwind across `extern
25095+ //! "C"`. These exercise the shared containment point both `knot_compile_impl`
25096+ //! entry points delegate to.
25097+ use super::*;
25098+
25099+ /// Out-param slots for a contained run, mirroring the FFI caller.
25100+ struct Outs {
25101+ ty: *mut u8,
25102+ ty_len: usize,
25103+ rels: *mut u8,
25104+ rels_len: usize,
25105+ err: *mut u8,
25106+ err_len: usize,
25107+ }
25108+ impl Outs {
25109+ fn new() -> Self {
25110+ Outs {
25111+ ty: std::ptr::null_mut(),
25112+ ty_len: 0,
25113+ rels: std::ptr::null_mut(),
25114+ rels_len: 0,
25115+ err: std::ptr::null_mut(),
25116+ err_len: 0,
25117+ }
25118+ }
25119+ unsafe fn init(&mut self) {
25120+ unsafe {
25121+ compile_out_params_init([
25122+ (&mut self.ty, &mut self.ty_len),
25123+ (&mut self.rels, &mut self.rels_len),
25124+ (&mut self.err, &mut self.err_len),
25125+ ]);
25126+ }
25127+ }
25128+ fn err_string(&self) -> Option<String> {
25129+ if self.err.is_null() {
25130+ return None;
25131+ }
25132+ let bytes = unsafe { std::slice::from_raw_parts(self.err, self.err_len) };
25133+ let s = String::from_utf8_lossy(bytes).into_owned();
25134+ unsafe { libc::free(self.err as *mut c_void) };
25135+ Some(s)
25136+ }
25137+ }
25138+
25139+ #[test]
25140+ fn success_writes_out_params_and_returns_value() {
25141+ let mut o = Outs::new();
25142+ unsafe { o.init() };
25143+ let sentinel = 0xdeadbeefusize as *mut Value;
25144+ let v = unsafe {
25145+ run_compile_contained(
25146+ std::ptr::null_mut(),
25147+ &mut o.ty,
25148+ &mut o.ty_len,
25149+ &mut o.rels,
25150+ &mut o.rels_len,
25151+ &mut o.err,
25152+ &mut o.err_len,
25153+ |_| Some(CompileOutcome {
25154+ value: sentinel,
25155+ ty: Some("Int".to_string()),
25156+ relations: vec!["a\t{..}".to_string()],
25157+ }),
25158+ )
25159+ };
25160+ assert_eq!(v, sentinel);
25161+ assert!(!o.ty.is_null(), "ty written");
25162+ assert!(!o.rels.is_null(), "relations written");
25163+ assert!(o.err.is_null(), "no error on success");
25164+ unsafe {
25165+ libc::free(o.ty as *mut c_void);
25166+ libc::free(o.rels as *mut c_void);
25167+ }
25168+ }
25169+
25170+ #[test]
25171+ fn clean_compile_error_returns_null() {
25172+ let mut o = Outs::new();
25173+ unsafe { o.init() };
25174+ let v = unsafe {
25175+ run_compile_contained(
25176+ std::ptr::null_mut(),
25177+ &mut o.ty,
25178+ &mut o.ty_len,
25179+ &mut o.rels,
25180+ &mut o.rels_len,
25181+ &mut o.err,
25182+ &mut o.err_len,
25183+ |_| None,
25184+ )
25185+ };
25186+ assert!(v.is_null(), "compile error returns null");
25187+ }
25188+
25189+ #[test]
25190+ fn panic_is_contained_and_surfaced_as_err() {
25191+ let mut o = Outs::new();
25192+ unsafe { o.init() };
25193+ let v = unsafe {
25194+ run_compile_contained(
25195+ std::ptr::null_mut(),
25196+ &mut o.ty,
25197+ &mut o.ty_len,
25198+ &mut o.rels,
25199+ &mut o.rels_len,
25200+ &mut o.err,
25201+ &mut o.err_len,
25202+ |_| -> Option<CompileOutcome> { panic!("boom in codegen") },
25203+ )
25204+ };
25205+ assert!(v.is_null(), "panic returns null, does not unwind");
25206+ let err = o.err_string().expect("panic message written to out_err");
25207+ assert!(
25208+ err.contains("compile panicked") && err.contains("boom in codegen"),
25209+ "panic payload surfaced: {err}"
25210+ );
25211+ }
25212+}