Skip to content

Commit 3feb48a

Browse files
yjmeqtclaude
andauthored
fix(remo-sdk): release capability handler context on unregister/replace (#60)
* fix(remo-sdk): release capability handler context on unregister/replace Add an optional `destroy` callback to `remo_register_capability` so the FFI side can free its retained context when the registration ends — by unregister, by replacement, or when the registry is dropped. Swift wires it to `Unmanaged.passRetained.release()` to balance the retain at registration, fixing a HandlerBox leak that grew with every register/unregister cycle. Covered by Rust unit tests on `CallbackHandle::drop` and Swift integration tests in the example app exercising the public Remo.register/unregister API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(example): drop unused try on non-throwing bridge.run calls Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e88c613 commit 3feb48a

6 files changed

Lines changed: 284 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ jobs:
7070
xcode-e2e-
7171
- name: Boot simulator
7272
run: xcrun simctl boot 'iPhone 17' 2>/dev/null || true
73+
- name: Build iOS SDK (simulator)
74+
run: ./build-ios.sh sim
75+
- name: Run iOS unit tests (RemoExampleFeatureTests)
76+
working-directory: examples/ios
77+
run: |
78+
xcodebuild \
79+
-workspace RemoExample.xcworkspace \
80+
-scheme RemoExample \
81+
-destination 'platform=iOS Simulator,name=iPhone 17' \
82+
-only-testing:RemoExampleFeatureTests \
83+
test
7384
- name: Run E2E tests
7485
run: ./scripts/e2e-test.sh --screenshots --record
7586
- name: Upload artifacts

crates/remo-sdk/src/ffi.rs

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,24 +192,44 @@ pub extern "C" fn remo_get_port() -> u16 {
192192
pub 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]
202220
pub 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 {}
294314
unsafe 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.
297321
struct 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.
302327
unsafe 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+
316351
extern "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+
}

examples/ios/RemoExamplePackage/Sources/RemoExampleFeature/UIKitDemo/UIKitDemoViewController+Remo.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ extension UIKitDemoViewController {
411411

412412
#remoCap(GridFeedReset.self) { _ in
413413
handleGridCapability {
414-
try bridge.run { controller in
414+
bridge.run { controller in
415415
controller.handleReset()
416416
}
417417
}
@@ -435,7 +435,7 @@ extension UIKitDemoViewController {
435435

436436
#remoCap(GridVisible.self) { _ in
437437
handleGridCapability {
438-
try bridge.run { controller in
438+
bridge.run { controller in
439439
controller.handleVisible()
440440
}
441441
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Testing
2+
import RemoSwift
3+
import Foundation
4+
5+
// End-to-end verification that registering a capability does not leak the
6+
// Swift HandlerBox or anything captured by the handler closure. Mirrors the
7+
// Rust-side unit tests for `CallbackHandle::drop` — those prove the destroy
8+
// callback fires; these prove `swiftCapabilityDestroy` correctly balances
9+
// `Unmanaged.passRetained`.
10+
//
11+
// Lives in the example's test target because RemoSwift's own SPM tests can't
12+
// run via `xcodebuild test -destination iOS` (the package's macro plugin
13+
// dependency is host-only). The example workspace handles macros correctly,
14+
// so we exercise the same Remo.register/unregister API here.
15+
16+
@Suite struct CapabilityLifecycleTests {
17+
18+
/// Reference type whose `deinit` increments a shared counter. Captured
19+
/// inside each registered handler closure so the counter reflects when
20+
/// the underlying HandlerBox (and its closure) is released.
21+
private final class DeallocSentinel: @unchecked Sendable {
22+
let onDealloc: @Sendable () -> Void
23+
init(onDealloc: @escaping @Sendable () -> Void) { self.onDealloc = onDealloc }
24+
deinit { onDealloc() }
25+
}
26+
27+
/// Register `name` with a handler that captures a fresh sentinel.
28+
/// In a separate function so the local `sentinel` reference is gone from
29+
/// the caller's stack frame on return — leaving the HandlerBox's retain
30+
/// (via `passRetained`) as the only strong reference to it.
31+
private func registerWithSentinel(
32+
_ name: String,
33+
onDealloc: @escaping @Sendable () -> Void
34+
) {
35+
let sentinel = DeallocSentinel(onDealloc: onDealloc)
36+
Remo.register(name) { _ in
37+
_ = sentinel
38+
return [:]
39+
}
40+
}
41+
42+
@Test
43+
func unregisterReleasesHandlerBox() {
44+
let counter = AtomicCounter()
45+
registerWithSentinel("remo.test.lifecycle.unregister") { counter.increment() }
46+
47+
#expect(counter.value == 0, "sentinel must remain alive while registered")
48+
49+
#expect(Remo.unregister("remo.test.lifecycle.unregister"))
50+
#expect(counter.value == 1, "sentinel must be released after unregister")
51+
}
52+
53+
@Test
54+
func replacementReleasesPreviousHandlerBox() {
55+
let counter = AtomicCounter()
56+
registerWithSentinel("remo.test.lifecycle.replace") { counter.increment() }
57+
#expect(counter.value == 0)
58+
59+
// Re-registering the same name must release the previous HandlerBox.
60+
Remo.register("remo.test.lifecycle.replace") { _ in [:] }
61+
#expect(counter.value == 1, "previous registration's HandlerBox must be released on replacement")
62+
63+
Remo.unregister("remo.test.lifecycle.replace")
64+
}
65+
66+
@Test
67+
func repeatedCyclesDoNotAccumulate() {
68+
let counter = AtomicCounter()
69+
let cycles = 50
70+
for i in 0..<cycles {
71+
let name = "remo.test.lifecycle.cycle.\(i)"
72+
registerWithSentinel(name) { counter.increment() }
73+
#expect(Remo.unregister(name))
74+
}
75+
#expect(counter.value == cycles, "all sentinels must be released across register/unregister cycles")
76+
}
77+
}
78+
79+
/// Thread-safe counter — `deinit` callbacks may run on whichever thread
80+
/// drops the last reference.
81+
private final class AtomicCounter: @unchecked Sendable {
82+
private let lock = NSLock()
83+
private var _value = 0
84+
85+
var value: Int {
86+
lock.lock(); defer { lock.unlock() }
87+
return _value
88+
}
89+
90+
func increment() {
91+
lock.lock(); defer { lock.unlock() }
92+
_value += 1
93+
}
94+
}

swift/RemoSwift/Sources/RemoSwift/Remo.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public final class Remo {
110110
let context = Unmanaged.passRetained(handlerBox).toOpaque()
111111

112112
name.withCString { namePtr in
113-
remo_register_capability(namePtr, context, swiftCapabilityTrampoline)
113+
remo_register_capability(namePtr, context, swiftCapabilityTrampoline, swiftCapabilityDestroy)
114114
}
115115
#else
116116
// Host-side package builds only need the macro-facing API surface to exist.
@@ -214,6 +214,15 @@ private final class HandlerBox {
214214
}
215215
}
216216

217+
/// Balances the `passRetained` performed in `register(_:handler:)` — invoked
218+
/// by the Rust side when the registration ends (unregister, replacement, or
219+
/// registry drop). Without this, the `HandlerBox` would leak for the lifetime
220+
/// of the process.
221+
private let swiftCapabilityDestroy: remo_capability_destroy = { context in
222+
guard let context = context else { return }
223+
Unmanaged<HandlerBox>.fromOpaque(context).release()
224+
}
225+
217226
/// C-compatible trampoline that bridges Rust -> Swift handler calls.
218227
private let swiftCapabilityTrampoline: remo_capability_callback = { context, paramsPtr in
219228
guard let context = context, let paramsPtr = paramsPtr else {

swift/RemoSwift/Sources/RemoSwift/include/remo.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,23 @@ void remo_stop(void);
2020
/// Returns: a null-terminated JSON string allocated with strdup().
2121
typedef char* (*remo_capability_callback)(void* context, const char* params_json);
2222

23+
/// Optional destroy callback paired with the context pointer.
24+
///
25+
/// Invoked exactly once when the registration ends — whether by unregister,
26+
/// by replacement (re-registering the same name), or by the registry itself
27+
/// being dropped. Use this to balance any retain performed on `context` at
28+
/// registration (e.g. Unmanaged.passRetained on Swift).
29+
typedef void (*remo_capability_destroy)(void* context);
30+
2331
/// Register a capability handler.
2432
/// - name: null-terminated capability name
2533
/// - context: opaque pointer passed to callback
2634
/// - callback: function pointer invoked when the capability is called
35+
/// - destroy: optional cleanup invoked when the registration ends (may be NULL)
2736
void remo_register_capability(const char* name,
2837
void* context,
29-
remo_capability_callback callback);
38+
remo_capability_callback callback,
39+
remo_capability_destroy destroy);
3040

3141
/// Unregister a capability by name.
3242
/// Returns true if the capability was found and removed.

0 commit comments

Comments
 (0)