Panic-Safe Callbacks
A Rust panic that reaches a DuckDB callback boundary aborts the whole process:
the user's DuckDB session, and any application embedding it, goes with it. The
*_callback! macros in quack_rs::callback prevent that. Each one generates the
unsafe extern "C" fn a builder expects, runs your code inside
std::panic::catch_unwind, and reports a caught panic to DuckDB as an ordinary
SQL error.
#![allow(unused)] fn main() { use quack_rs::data_chunk::DataChunk; use quack_rs::vector::VectorWriter; quack_rs::scalar_callback!(double_it, |info, input, output| { let chunk = unsafe { DataChunk::from_raw(input) }; let mut writer = unsafe { VectorWriter::from_vector(output) }; for row in 0..chunk.size() { let value = unsafe { chunk.reader(0).read_i64(row) }; unsafe { writer.write_i64(row, value * 2) }; } }); // `double_it` is now a `pub unsafe extern "C" fn` you pass to // `ScalarFunctionBuilder::function`. }
A raw
unsafe extern "C" fnpassed to a builder is not guarded. The builders install whatever function you give them, as written. If you write the function by hand, a panic in it aborts the process. Write it with the matching macro, or wrap its body inquack_rs::callback::catch_ffi_panicand report theErryourself.
The macros
There are 16 macros. Each takes the name of the function to generate and a closure-like body whose parameters match the C callback's arguments.
| Macro | Callback | Body parameters | On a panic |
|---|---|---|---|
scalar_callback! | Scalar function | info, input, output | Query error |
scalar_bind_callback! | Scalar bind (duckdb-1-5) | info | Query error |
scalar_init_callback! | Scalar init (duckdb-1-5) | info | Query error |
table_bind_callback! | Table bind | info | Query error |
table_init_callback! | Table init and local init | info | Query error |
table_scan_callback! | Table scan | info, output | Query error; the scan ends |
aggregate_update_callback! | Aggregate update | info, input, states | Query error |
aggregate_combine_callback! | Aggregate combine | info, source, target, count | Query error |
aggregate_finalize_callback! | Aggregate finalize | info, source, result, count, offset | Query error |
aggregate_destroy_callback! | Aggregate destructor | states, count | Swallowed |
cast_callback! | Cast | info, count, input, output | Cast error; returns false |
replacement_scan_callback! | Replacement scan | info, table_name, data | Query error |
copy_bind_callback! | COPY … TO bind (duckdb-1-5) | info | Query error |
copy_global_init_callback! | COPY … TO global init (duckdb-1-5) | info | Query error |
copy_sink_callback! | COPY … TO sink (duckdb-1-5) | info, chunk | Query error |
copy_finalize_callback! | COPY … TO finalize (duckdb-1-5) | info | Query error |
Each macro reports the panic through the set_error function DuckDB provides
for that callback kind: duckdb_scalar_function_set_error for
scalar_callback!, duckdb_bind_set_error for table_bind_callback!,
duckdb_copy_function_sink_set_error for copy_sink_callback!, and so on. The
table scan also sets the output chunk's size to 0.
The macros cover every callback you write yourself except the aggregate's
state_size and state_init. You normally let ffi_state::<T>() install
FfiState's versions of those, which already catch a panic in your Default
impl. A COPY … FROM reader is a table function, so it uses the table_*
macros.
Each page shows its macros in use: Scalar Functions, Aggregate Functions, Table Functions, Replacement Scans, Cast Functions and Copy Functions.
What the user sees
The panic message becomes the query's error message:
#![allow(unused)] fn main() { use libduckdb_sys::DuckDBSuccess; use quack_rs::scalar::ScalarFunctionBuilder; use quack_rs::types::TypeId; quack_rs::scalar_callback!(always_fails, |info, input, output| { let _ = (info, input, output); panic!("always_fails: this input is not supported"); }); std::mem::forget(quack_rs::testing::InMemoryDb::open().unwrap()); let (mut db, mut con) = (std::ptr::null_mut(), std::ptr::null_mut()); unsafe { assert_eq!(libduckdb_sys::duckdb_open(std::ptr::null(), &mut db), DuckDBSuccess); assert_eq!(libduckdb_sys::duckdb_connect(db, &mut con), DuckDBSuccess); ScalarFunctionBuilder::new("always_fails") .param(TypeId::BigInt) .returns(TypeId::BigInt) .function(always_fails) .register(con) .unwrap(); } let err = unsafe { quack_rs::query::query(con, "SELECT always_fails(1)") }.err().unwrap(); assert!(err.as_str().contains("this input is not supported"), "{err}"); }
SELECT always_fails(1);
-- Error: ... always_fails: this input is not supported
The process keeps running, and the next query works normally. Details:
- An empty message (
panic!("")) is reported ascallback::EMPTY_PANIC_PLACEHOLDER, "an extension callback panicked without a message". DuckDB would otherwise show a bare error prefix, and a replacement scan would ignore the error altogether. - An interior NUL in the message is replaced by
?. - The panic payload is dropped under a second guard, because a payload's own
Dropis user code too. - The cast returns
false. In aTRY_CASTit also sets every row of the chunk toNULL, because rows written before the panic cannot be trusted. See Cast Functions for the cast's error rules. - The aggregate destructor has no way to report an error: DuckDB calls it with no info handle. A panic there is caught and discarded. Whatever the destructor had not freed yet leaks, which is better than aborting during query cleanup.
Writing a macro body
The body looks like a closure, but it expands to an ordinary nested fn. Three
rules follow from that.
The body is not an unsafe context. Wrap each unsafe call in its own
unsafe { … } block, as the examples do. A raw-pointer read with no unsafe
block does not compile.
The body returns (), except cast_callback!'s, which returns the cast's
bool. You cannot use ? in it: report an error through the callback's
set_error and return.
#![allow(unused)] fn main() { use quack_rs::table::BindInfo; use quack_rs::types::TypeId; quack_rs::table_bind_callback!(parse_limit, |info| { let bind = unsafe { BindInfo::new(info) }; let text = unsafe { bind.get_parameter_value(0) }.as_str_or_default(); match text.parse::<i64>() { Ok(_limit) => { bind.add_result_column("n", TypeId::BigInt); } Err(e) => bind.set_error(&format!("limit must be a number: {e}")), } }); }
return leaves the callback. return; (or return false; in a cast) ends
the body early, as in any function.
Each macro also has an argument type that matches only its own callback kind.
For example, scalar_bind_callback! takes a RawScalarBindInfo, so its output
does not compile in a table function's bind, and table_bind_callback!'s
output does not compile in a scalar function's.
Never build with panic = "abort"
catch_unwind cannot catch anything when the crate is built with
panic = "abort": the process ends at the panic site, before any guard runs.
Keep your extension on panic = "unwind". The scaffold generates it, and
validate_release_profile rejects "abort". See
Installation.
Prefer the closure-based builders where they fit
For the most common shapes you do not need a raw callback at all. The closure-based builders generate the callbacks for you, guard them, and also handle types, NULLs and state:
- Scalar functions:
ScalarFunctionBuilder::map1,map2,map1_str,map2_strand their_optvariants (quack_rs::scalar::typed). Parameter and return types come from the closure's signature, and a row with a NULL argument gets a NULL result. A panic in the closure fails the query instead of aborting. See Scalar Functions. - Table functions:
TableFunctionBuilder::with_stateandwith_bind_init(quack_rs::table::typed). The closures receive typed state, may returnErr(ExtensionError)to fail the query, and a panic in them is reported as a bind, init or scan error. See Table Functions.
#![allow(unused)] fn main() { use quack_rs::scalar::ScalarFunctionBuilder; fn demo(con: libduckdb_sys::duckdb_connection) -> Result<(), quack_rs::error::ExtensionError> { // No extern "C" fn, no macro: the builder generates and guards the callback. unsafe { ScalarFunctionBuilder::map1("double_it", |x: i64| x * 2)?.register(con) } } }
Use a macro and the raw builder when the closure-based builders cannot express the function:
- scalar functions with
STRUCT,LISTorMAParguments, variable arity, bind-time constant folding or per-thread local state; - table functions with parallel scans (
set_max_threadsabove 1), projection pushdown, orlocal_init; - aggregates, casts, replacement scans and copy functions, which have no closure-based builder.
Code quack-rs generates for you
You do not need a macro for callbacks quack-rs writes itself. These already run
your code under catch_unwind:
- the entry points generated by
entry_point!andentry_point_v2!, which turn a panic in your registration function into a load error; - the callbacks of the closure-based builders above;
FfiState'sinit_callbackanddestroy_callback(yourDefaultandDrop);- the destructors quack-rs registers for
FfiBindData,FfiInitDataand replacement-scan data.
For a destructor or other extern "C" fn of your own that has no macro, use
quack_rs::callback::catch_ffi_panic(|| …). It returns Err(message) when the
closure panics.
See also
- Pitfall L3: No panic across FFI boundaries
callbackmodule documentation