@@ -192,24 +192,44 @@ pub extern "C" fn remo_get_port() -> u16 {
192192pub type CapabilityCallback =
193193 unsafe extern "C" fn ( context : * mut std:: ffi:: c_void , params_json : * const c_char ) -> * mut c_char ;
194194
195+ /// Optional destroy callback invoked when the registration ends.
196+ ///
197+ /// Called exactly once per `remo_register_capability` call: when the
198+ /// capability is unregistered, replaced by another registration of the same
199+ /// name, or the registry entry is otherwise dropped. Use this to balance any
200+ /// retain performed on `context` at registration time (e.g.
201+ /// `Unmanaged.passRetained`).
202+ ///
203+ /// May be `NULL` if the context does not require cleanup.
204+ pub type CapabilityDestroy = unsafe extern "C" fn ( context : * mut std:: ffi:: c_void ) ;
205+
195206/// Register a capability handler from Swift.
196207///
208+ /// `destroy` (if non-null) is invoked exactly once when the registration ends —
209+ /// on unregister, on replacement by another registration of the same name, or
210+ /// when the handler is otherwise dropped from the registry. After that call the
211+ /// caller may release any resources owned by `context`.
212+ ///
197213/// # Safety
198214/// - `name` must be a valid null-terminated C string.
199- /// - `context` must remain valid for the lifetime of the registration.
215+ /// - `context` must remain valid until `destroy` is invoked (or, if `destroy`
216+ /// is null, for the lifetime of the process).
200217/// - `callback` must be a valid, thread-safe function pointer.
218+ /// - `destroy`, if non-null, must be a valid, thread-safe function pointer.
201219#[ no_mangle]
202220pub unsafe extern "C" fn remo_register_capability (
203221 name : * const c_char ,
204222 context : * mut std:: ffi:: c_void ,
205223 callback : CapabilityCallback ,
224+ destroy : Option < CapabilityDestroy > ,
206225) {
207226 let name = CStr :: from_ptr ( name) . to_string_lossy ( ) . into_owned ( ) ;
208227
209228 // Safety: Swift side guarantees context + callback are Send + Sync.
210229 let handle = CallbackHandle {
211230 ctx : SendPtr ( context) ,
212231 cb : callback,
232+ destroy,
213233 } ;
214234 // Prevent raw pointer parameters from being captured by the closure below.
215235 let _ = context;
@@ -294,9 +314,14 @@ unsafe impl Send for SendPtr {}
294314unsafe impl Sync for SendPtr { }
295315
296316/// Wraps the FFI callback context so the closure is Send + Sync.
317+ ///
318+ /// The optional `destroy` callback is invoked from `Drop` so the context is
319+ /// released exactly when the registry entry is removed — whether by an
320+ /// explicit unregister, by replacement, or by dropping the registry itself.
297321struct CallbackHandle {
298322 ctx : SendPtr ,
299323 cb : CapabilityCallback ,
324+ destroy : Option < CapabilityDestroy > ,
300325}
301326// SAFETY: CallbackHandle's fields (SendPtr + extern "C" fn) are thread-safe per Swift contract.
302327unsafe impl Send for CallbackHandle { }
@@ -313,7 +338,137 @@ impl CallbackHandle {
313338 }
314339}
315340
341+ impl Drop for CallbackHandle {
342+ fn drop ( & mut self ) {
343+ if let Some ( destroy) = self . destroy {
344+ // SAFETY: `destroy` was supplied by the FFI caller alongside `ctx`
345+ // and is contracted to be safe to call exactly once with that ctx.
346+ unsafe { destroy ( self . ctx . 0 ) } ;
347+ }
348+ }
349+ }
350+
316351extern "C" {
317352 #[ link_name = "free" ]
318353 fn libc_free ( ptr : * mut std:: ffi:: c_void ) ;
319354}
355+
356+ #[ cfg( test) ]
357+ mod tests {
358+ //! Tests for the FFI capability lifecycle — specifically that the
359+ //! `destroy` callback supplied alongside the context pointer is invoked
360+ //! exactly once per registration, regardless of how the registration ends
361+ //! (explicit unregister or replacement by another registration of the
362+ //! same name).
363+ //!
364+ //! These tests exercise `CallbackHandle` through `CapabilityRegistry`
365+ //! directly rather than the global FFI entry points, since the latter
366+ //! share process-wide state.
367+ use super :: * ;
368+ use crate :: registry:: CapabilityRegistry ;
369+ use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
370+ use std:: sync:: Arc ;
371+
372+ /// `context` argument is the pointer to a leaked `Arc<AtomicUsize>` —
373+ /// reclaim it and bump the destroy count.
374+ unsafe extern "C" fn destroy_counter ( context : * mut std:: ffi:: c_void ) {
375+ let counter = Arc :: from_raw ( context as * const AtomicUsize ) ;
376+ counter. fetch_add ( 1 , Ordering :: SeqCst ) ;
377+ }
378+
379+ unsafe extern "C" fn noop_callback (
380+ _context : * mut std:: ffi:: c_void ,
381+ _params_json : * const c_char ,
382+ ) -> * mut c_char {
383+ std:: ptr:: null_mut ( )
384+ }
385+
386+ /// Register a capability whose `destroy` increments `counter` when fired.
387+ /// Mirrors the bookkeeping `remo_register_capability` does internally so
388+ /// these tests don't depend on the global FFI registry.
389+ fn register_with_destroy ( reg : & CapabilityRegistry , name : & str , counter : Arc < AtomicUsize > ) {
390+ let context = Arc :: into_raw ( counter) as * mut std:: ffi:: c_void ;
391+ let handle = CallbackHandle {
392+ ctx : SendPtr ( context) ,
393+ cb : noop_callback,
394+ destroy : Some ( destroy_counter) ,
395+ } ;
396+ reg. register_sync ( name. to_string ( ) , move |_params| {
397+ let _ = & handle;
398+ Ok ( Value :: Null )
399+ } ) ;
400+ }
401+
402+ #[ tokio:: test]
403+ async fn destroy_fires_on_unregister ( ) {
404+ let reg = CapabilityRegistry :: new ( ) ;
405+ let counter = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
406+
407+ register_with_destroy ( & reg, "cap" , Arc :: clone ( & counter) ) ;
408+ assert_eq ! (
409+ counter. load( Ordering :: SeqCst ) ,
410+ 0 ,
411+ "destroy must not fire on register"
412+ ) ;
413+
414+ assert ! ( reg. unregister( "cap" ) ) ;
415+ assert_eq ! (
416+ counter. load( Ordering :: SeqCst ) ,
417+ 1 ,
418+ "destroy must fire exactly once on unregister"
419+ ) ;
420+ }
421+
422+ #[ tokio:: test]
423+ async fn destroy_fires_on_replacement ( ) {
424+ let reg = CapabilityRegistry :: new ( ) ;
425+ let first = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
426+ let second = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
427+
428+ register_with_destroy ( & reg, "cap" , Arc :: clone ( & first) ) ;
429+ register_with_destroy ( & reg, "cap" , Arc :: clone ( & second) ) ;
430+
431+ assert_eq ! (
432+ first. load( Ordering :: SeqCst ) ,
433+ 1 ,
434+ "old context must be destroyed when replaced"
435+ ) ;
436+ assert_eq ! (
437+ second. load( Ordering :: SeqCst ) ,
438+ 0 ,
439+ "new context must still be alive"
440+ ) ;
441+
442+ assert ! ( reg. unregister( "cap" ) ) ;
443+ assert_eq ! (
444+ second. load( Ordering :: SeqCst ) ,
445+ 1 ,
446+ "new context must be destroyed on unregister"
447+ ) ;
448+ }
449+
450+ #[ tokio:: test]
451+ async fn destroy_fires_on_registry_drop ( ) {
452+ let counter = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
453+ {
454+ let reg = CapabilityRegistry :: new ( ) ;
455+ register_with_destroy ( & reg, "cap" , Arc :: clone ( & counter) ) ;
456+ assert_eq ! ( counter. load( Ordering :: SeqCst ) , 0 ) ;
457+ }
458+ assert_eq ! (
459+ counter. load( Ordering :: SeqCst ) ,
460+ 1 ,
461+ "destroy must fire when registry is dropped"
462+ ) ;
463+ }
464+
465+ #[ tokio:: test]
466+ async fn null_destroy_is_safe ( ) {
467+ let handle = CallbackHandle {
468+ ctx : SendPtr ( std:: ptr:: null_mut ( ) ) ,
469+ cb : noop_callback,
470+ destroy : None ,
471+ } ;
472+ drop ( handle) ;
473+ }
474+ }
0 commit comments