fix(codegen): emit 3-arg async destroy ABI in C++/Rust bindings

The recycle/async-destroy work changed the Nim `ffiDtor` export from
`int destroy(ctx)` to `int destroy(ctx, callback, userData)`, but the C++
and Rust generators still emitted the 1-arg signature. Foreign callers
therefore passed only `ctx`; inside Nim, `callback`/`userData` held
uninitialised register garbage. `requestRecycle` stored the garbage
callback and the recycle handler later invoked it — a jump through a wild
pointer that segfaulted in every C++ E2E / ASan / TSan job (the crash
surfaced at teardown, after each test's assertions had already passed).

Generate the 3-arg ABI and have the destructor/Drop block on the recycle
callback via the existing sync-call helper, so the pool slot is fully
drained and parked before the handle goes away — otherwise rapid
create/destroy churn (StressShortLivedPerThreadContext, ThreadedHammer)
could outrun the recycle and exhaust the pool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-06-12 16:11:42 +02:00
parent fe6749d3af
commit 6bc626946e
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
7 changed files with 45 additions and 11 deletions

View File

@ -406,7 +406,7 @@ typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_dat
void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data);
int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_destroy(void* ctx);
int echo_destroy(void* ctx, FFICallback callback, void* user_data);
uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data);
int echo_remove_event_listener(void* ctx, uint64_t listener_id);
} // extern "C"
@ -516,7 +516,14 @@ public:
// context.
~EchoCtx() {
if (ptr_) {
echo_destroy(ptr_);
// `echo_destroy` is non-blocking at the C ABI: it parks the
// context for reuse and reports the outcome via the callback. Block
// here until that callback fires so the pool slot is fully drained
// and parked before this object goes away — otherwise a rapid
// create/destroy churn could outrun the recycle and exhaust the pool.
(void)ffi_call_([this](FFICallback cb, void* ud) {
return echo_destroy(ptr_, cb, ud);
}, timeout_);
ptr_ = nullptr;
}
}

View File

@ -706,7 +706,7 @@ int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_
int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int my_timer_destroy(void* ctx);
int my_timer_destroy(void* ctx, FFICallback callback, void* user_data);
uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data);
int my_timer_remove_event_listener(void* ctx, uint64_t listener_id);
} // extern "C"
@ -816,7 +816,14 @@ public:
// context.
~MyTimerCtx() {
if (ptr_) {
my_timer_destroy(ptr_);
// `my_timer_destroy` is non-blocking at the C ABI: it parks the
// context for reuse and reports the outcome via the callback. Block
// here until that callback fires so the pool slot is fully drained
// and parked before this object goes away — otherwise a rapid
// create/destroy churn could outrun the recycle and exhaust the pool.
(void)ffi_call_([this](FFICallback cb, void* ud) {
return my_timer_destroy(ptr_, cb, ud);
}, timeout_);
ptr_ = nullptr;
}
}

View File

@ -141,7 +141,9 @@ unsafe impl Sync for MyTimerCtx {}
impl Drop for MyTimerCtx {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { ffi::my_timer_destroy(self.ptr); }
let _ = ffi_call_sync(self.timeout, |cb, ud| unsafe {
ffi::my_timer_destroy(self.ptr, cb, ud)
});
self.ptr = std::ptr::null_mut();
}
}

View File

@ -14,7 +14,7 @@ extern "C" {
pub fn my_timer_version(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int;
pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int;
pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int;
pub fn my_timer_destroy(ctx: *mut c_void) -> c_int;
pub fn my_timer_destroy(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void) -> c_int;
pub fn my_timer_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64;
pub fn my_timer_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int;
}

View File

@ -339,7 +339,9 @@ proc generateCppHeader*(
[p.procName]
)
of FFIKind.DTOR:
lines.add("int $1(void* ctx);" % [p.procName])
lines.add(
"int $1(void* ctx, FFICallback callback, void* user_data);" % [p.procName]
)
# `declareLibrary` always exports the listener-registration ABI. Declare
# it here so the typed event-handler wiring below can call into it.
lines.add(
@ -570,8 +572,8 @@ proc generateCppHeader*(
lines.add(" std::chrono::milliseconds timeout_;")
if events.len > 0:
# One owning entry per live listener, keyed by id. Destroyed after
# the destructor body runs `<lib>_destroy(ptr_)`, by which point the
# FFI side has joined its threads so no callback is mid-flight.
# the destructor blocks on `<lib>_destroy`'s recycle callback, by which
# point the FFI side has drained/parked the slot so no callback is mid-flight.
lines.add(
" std::unordered_map<std::uint64_t, std::unique_ptr<ListenerBase>> listeners_;"
)

View File

@ -207,6 +207,8 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string =
lines.add(" pub fn $1($2) -> *mut c_void;" % [p.procName, params.join(", ")])
of FFIKind.DTOR:
params.add("ctx: *mut c_void")
params.add("callback: FFICallback")
params.add("user_data: *mut c_void")
lines.add(" pub fn $1($2) -> c_int;" % [p.procName, params.join(", ")])
# Listener-registration ABI — emitted on the Nim side by `declareLibrary`,
@ -531,7 +533,14 @@ proc generateApiRs*(
lines.add("impl Drop for $1 {" % [ctxTypeName])
lines.add(" fn drop(&mut self) {")
lines.add(" if !self.ptr.is_null() {")
lines.add(" unsafe { ffi::$1(self.ptr); }" % [dtorProcName])
# `<lib>_destroy` is non-blocking at the C ABI: it parks the context for
# reuse and reports the outcome via the callback. Block until that callback
# fires so the pool slot is fully drained and parked before this handle goes
# away — otherwise rapid create/destroy churn could outrun the recycle and
# exhaust the pool. The recycle outcome is best-effort on drop, so discard it.
lines.add(" let _ = ffi_call_sync(self.timeout, |cb, ud| unsafe {")
lines.add(" ffi::$1(self.ptr, cb, ud)" % [dtorProcName])
lines.add(" });")
lines.add(" self.ptr = std::ptr::null_mut();")
lines.add(" }")
# `listeners` is dropped automatically after this body returns. By

View File

@ -9,7 +9,14 @@
// context.
~{{CTX}}() {
if (ptr_) {
{{LIB}}_destroy(ptr_);
// `{{LIB}}_destroy` is non-blocking at the C ABI: it parks the
// context for reuse and reports the outcome via the callback. Block
// here until that callback fires so the pool slot is fully drained
// and parked before this object goes away — otherwise a rapid
// create/destroy churn could outrun the recycle and exhaust the pool.
(void)ffi_call_([this](FFICallback cb, void* ud) {
return {{LIB}}_destroy(ptr_, cb, ud);
}, timeout_);
ptr_ = nullptr;
}
}