fix(nim-bindings): fix ABI mismatch in destroy_* FFI functions and add defer-based cleanup

Nim's C backend silently transforms large struct parameters (>16 bytes) into
pointer parameters when calling importc functions. The destroy_* functions were
declared taking T by value in Rust, but Nim always passed &T — causing Rust to
read garbage from the stack on x86-64 (SIGILL on CI) while accidentally working
on ARM64 macOS due to that ABI coincidentally also using pointers for large structs.

Fix by changing all destroy_* functions to take &mut T and using drop_in_place,
which is the correct idiom for dropping a value through a pointer.

On the Nim side, replace scattered manual destroy calls with defer, which
guarantees cleanup on all exit paths and prevents use-after-destroy bugs.
This commit is contained in:
osmaczko 2026-02-25 17:21:49 +01:00
parent f150619b17
commit 1f8c38d2ce
No known key found for this signature in database
GPG Key ID: 6A385380FD275B44
2 changed files with 37 additions and 53 deletions

View File

@ -214,9 +214,13 @@ pub struct CreateIntroResult {
}
/// Free the result from create_intro_bundle
///
/// # ABI note
/// Takes `&mut` instead of ownership because Nim always passes large structs as a pointer;
/// accepting the struct by value would be an ABI mismatch on the caller side.
#[ffi_export]
pub fn destroy_intro_result(result: CreateIntroResult) {
drop(result);
pub fn destroy_intro_result(result: &mut CreateIntroResult) {
unsafe { std::ptr::drop_in_place(result) }
}
/// Payload structure for FFI
@ -238,9 +242,13 @@ pub struct SendContentResult {
}
/// Free the result from send_content
///
/// # ABI note
/// Takes `&mut` instead of ownership because Nim always passes large structs as a pointer;
/// accepting the struct by value would be an ABI mismatch on the caller side.
#[ffi_export]
pub fn destroy_send_content_result(result: SendContentResult) {
drop(result);
pub fn destroy_send_content_result(result: &mut SendContentResult) {
unsafe { std::ptr::drop_in_place(result) }
}
/// Result structure for handle_payload
@ -256,9 +264,13 @@ pub struct HandlePayloadResult {
}
/// Free the result from handle_payload
///
/// # ABI note
/// Takes `&mut` instead of ownership because Nim always passes large structs as a pointer;
/// accepting the struct by value would be an ABI mismatch on the caller side.
#[ffi_export]
pub fn destroy_handle_payload_result(result: HandlePayloadResult) {
drop(result);
pub fn destroy_handle_payload_result(result: &mut HandlePayloadResult) {
unsafe { std::ptr::drop_in_place(result) }
}
impl From<ContentData> for HandlePayloadResult {
@ -310,7 +322,11 @@ pub struct NewConvoResult {
}
/// Free the result from create_new_private_convo
///
/// # ABI note
/// Takes `&mut` instead of ownership because Nim always passes large structs as a pointer;
/// accepting the struct by value would be an ABI mismatch on the caller side.
#[ffi_export]
pub fn destroy_convo_result(result: NewConvoResult) {
drop(result);
pub fn destroy_convo_result(result: &mut NewConvoResult) {
unsafe { std::ptr::drop_in_place(result) }
}

View File

@ -44,11 +44,10 @@ proc createIntroductionBundle*(ctx: LibChat): Result[seq[byte], string] =
return err("Context handle is nil")
let res = create_intro_bundle(ctx.handle)
defer: destroy_intro_result(res)
if res.error_code != ErrNone:
result = err("Failed to create private convo: " & $res.error_code)
destroy_intro_result(res)
return
return err("Failed to create intro bundle: " & $res.error_code)
return ok(res.intro_bytes.toSeq())
@ -62,32 +61,18 @@ proc createNewPrivateConvo*(ctx: LibChat, bundle: seq[byte], content: seq[byte])
if content.len == 0:
return err("content is zero length")
let res = bindings.create_new_private_convo(
ctx.handle,
bundle.toSlice(),
content.toSlice()
)
let res = bindings.create_new_private_convo(ctx.handle, bundle.toSlice(), content.toSlice())
defer: destroy_convo_result(res)
if res.error_code != 0:
result = err("Failed to create private convo: " & $res.error_code)
destroy_convo_result(res)
return
return err("Failed to create private convo: " & $res.error_code)
# Convert payloads to Nim types
var payloads = newSeq[PayloadResult](res.payloads.len)
for i in 0 ..< res.payloads.len:
let p = res.payloads[int(i)]
payloads[int(i)] = PayloadResult(
address: $p.address,
data: p.data.toSeq()
)
payloads[int(i)] = PayloadResult(address: $p.address, data: p.data.toSeq())
let convoId = $res.convo_id
# Free the result
destroy_convo_result(res)
return ok((convoId, payloads))
return ok(($res.convo_id, payloads))
## Send content to an existing conversation
proc sendContent*(ctx: LibChat, convoId: string, content: seq[byte]): Result[seq[PayloadResult], string] =
@ -97,24 +82,13 @@ proc sendContent*(ctx: LibChat, convoId: string, content: seq[byte]): Result[seq
if content.len == 0:
return err("content is zero length")
let res = bindings.send_content(
ctx.handle,
convoId.toReprCString,
content.toSlice()
)
let res = bindings.send_content(ctx.handle, convoId.toReprCString, content.toSlice())
defer: destroy_send_content_result(res)
if res.error_code != 0:
result = err("Failed to send content: " & $res.error_code)
destroy_send_content_result(res)
return
return err("Failed to send content: " & $res.error_code)
let payloads = res.payloads.toSeq().mapIt(PayloadResult(
address: $it.address,
data: it.data.toSeq()
))
destroy_send_content_result(res)
let payloads = res.payloads.toSeq().mapIt(PayloadResult(address: $it.address, data: it.data.toSeq()))
return ok(payloads)
type
@ -131,14 +105,8 @@ proc handlePayload*(ctx: LibChat, payload: seq[byte]): Result[Option[ContentResu
if payload.len == 0:
return err("payload is zero length")
var conversationIdBuf = newSeq[byte](ctx.buffer_size)
var contentBuf = newSeq[byte](ctx.buffer_size)
var conversationIdLen: uint32 = 0
let res = bindings.handle_payload(
ctx.handle,
payload.toSlice(),
)
let res = bindings.handle_payload(ctx.handle, payload.toSlice())
defer: destroy_handle_payload_result(res)
if res.error_code != ErrNone:
return err("Failed to handle payload: " & $res.error_code)