wip callback migration
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test.dart b/pkgs/ffigen/test/native_objc_test/isolate_test.dart index 6e0c013..d6d145b 100644 --- a/pkgs/ffigen/test/native_objc_test/isolate_test.dart +++ b/pkgs/ffigen/test/native_objc_test/isolate_test.dart
@@ -102,6 +102,23 @@ port.close(); } + // Runs on other isolate. + void case2Helper(SendPort sendPort) async { + final port = ReceivePort(); + sendPort.send(port.sendPort); + + final block = ObjCBlock_ffiVoid_Sendable.listener((Sendable s) { + // Do nothing. + }, keepIsolateAlive: false); + + sendPort.send(block); + + await for (final _ in port) { + break; + } + port.close(); + } + test('Sending block through a port', () async { final completer = Completer<int>(); ObjCBlock<Void Function(Int32)>? block = ObjCBlock_ffiVoid_Int32.listener( @@ -214,5 +231,72 @@ expect(() => sendable.value, throwsA(isA<UseAfterReleaseError>())); expect(() => sendable.ref.release(), throwsA(isA<DoubleReleaseError>())); }); + + test( + 'Case 2: Isolate shuts down before handling message (Memory Leak)', + () async { + await using((arena) async { + final sendableTracker = ReferenceTracker(arena); + Sendable? sendable = Sendable(); + sendable.value = 789; + sendableTracker.track(sendable); + + final port = ReceivePort(); + final queue = StreamQueue(port); + final isolate = await Isolate.spawn( + case2Helper, + port.sendPort, + onExit: port.sendPort, + ); + + final helperSendPort = await queue.next as SendPort; + final block = await queue.next as ObjCBlock<Void Function(Sendable)>; + + // Invoke the block, passing the sendable object. + block(sendable!); + + // IMMEDIATELY kill the helper isolate. + isolate.kill(priority: Isolate.immediate); + + // Wait for the helper isolate to completely exit. + while (await queue.next != null) { + // Wait for onExit signal which sends null. + } + port.close(); + + // Clear our own references to sendable and the block. + sendable = null; + + // Force Garbage Collection. + doGC(); + + // In a buggy implementation, the Sendable object will have leaked + // because the message containing it was dropped during isolate shutdown + // without releasing the retained ObjC reference. + // So sendableTracker.isAlive will still be true. + expect(sendableTracker.isAlive, true); + }); + }, + skip: !canDoGC, + ); + + test( + 'Case 3: Isolate is already shut down before message is sent (Crash/UB)', + () async { + // Run the subprocess script and expect it to crash (exit code != 0). + final scriptPath = path.join( + packagePathForTests, + 'test', + 'native_objc_test', + 'isolate_test_case3_subprocess.dart', + ); + final result = await Process.run(Platform.resolvedExecutable, [ + scriptPath, + ]); + + // In a buggy implementation, the subprocess crashes (segfaults) so the exit code is non-zero. + expect(result.exitCode, isNot(0)); + }, + ); }); }
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test.h b/pkgs/ffigen/test/native_objc_test/isolate_test.h index 221f1fe..0c4c9b1 100644 --- a/pkgs/ffigen/test/native_objc_test/isolate_test.h +++ b/pkgs/ffigen/test/native_objc_test/isolate_test.h
@@ -4,9 +4,13 @@ #import <Foundation/NSObject.h> +@class Sendable; + typedef void (^Listener)(int32_t); +typedef void (^ListenerWithSendable)(Sendable *); @interface Sendable : NSObject {} @property int32_t value; + (Listener)dummyMethodToForceGenerationOfListener; ++ (ListenerWithSendable)dummyMethodToForceGenerationOfListenerWithSendable; @end
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test.m b/pkgs/ffigen/test/native_objc_test/isolate_test.m index fc205af..e0ce9c6 100644 --- a/pkgs/ffigen/test/native_objc_test/isolate_test.m +++ b/pkgs/ffigen/test/native_objc_test/isolate_test.m
@@ -5,4 +5,10 @@ #include "isolate_test.h" @implementation Sendable ++ (Listener)dummyMethodToForceGenerationOfListener { + return nil; +} ++ (ListenerWithSendable)dummyMethodToForceGenerationOfListenerWithSendable { + return nil; +} @end
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.dart b/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.dart index b13b57e..a5dc422 100644 --- a/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.dart +++ b/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.dart
@@ -24,12 +24,32 @@ ); @ffi.Native< + ffi.Pointer<objc.ObjCBlockImpl> Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.DOBJC_Context>, + ) +>(isLeaf: true) +external ffi.Pointer<objc.ObjCBlockImpl> _rdx59v_wrapBlockingBlock_xtuoz7( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<objc.ObjCBlockImpl> listnerBlock, + ffi.Pointer<objc.DOBJC_Context> context, +); + +@ffi.Native< ffi.Pointer<objc.ObjCBlockImpl> Function(ffi.Pointer<objc.ObjCBlockImpl>) >(isLeaf: true) external ffi.Pointer<objc.ObjCBlockImpl> _rdx59v_wrapListenerBlock_1bqef4y( ffi.Pointer<objc.ObjCBlockImpl> block, ); +@ffi.Native< + ffi.Pointer<objc.ObjCBlockImpl> Function(ffi.Pointer<objc.ObjCBlockImpl>) +>(isLeaf: true) +external ffi.Pointer<objc.ObjCBlockImpl> _rdx59v_wrapListenerBlock_xtuoz7( + ffi.Pointer<objc.ObjCBlockImpl> block, +); + /// Construction methods for `objc.ObjCBlock<ffi.Void Function(ffi.Int32)>`. abstract final class ObjCBlock_ffiVoid_Int32 { /// Returns a block that wraps the given raw block pointer. @@ -246,6 +266,252 @@ } } +/// Construction methods for `objc.ObjCBlock<ffi.Void Function(Sendable)>`. +abstract final class ObjCBlock_ffiVoid_Sendable { + /// Returns a block that wraps the given raw block pointer. + static objc.ObjCBlock<ffi.Void Function(Sendable)> fromPointer( + ffi.Pointer<objc.ObjCBlockImpl> pointer, { + bool retain = false, + bool release = false, + }) => objc.ObjCBlock<ffi.Void Function(Sendable)>( + pointer, + retain: retain, + release: release, + ); + + /// Creates a block from a C function pointer. + /// + /// This block must be invoked by native code running on the same thread as + /// the isolate that registered it. Invoking the block on the wrong thread + /// will result in a crash. + static objc.ObjCBlock<ffi.Void Function(Sendable)> fromFunctionPointer( + ffi.Pointer< + ffi.NativeFunction< + ffi.Void Function(ffi.Pointer<objc.ObjCObjectImpl> arg0) + > + > + ptr, + ) => objc.ObjCBlock<ffi.Void Function(Sendable)>( + objc.newPointerBlock(_fnPtrCallable, ptr.cast()), + retain: false, + release: true, + ); + + /// Creates a block from a Dart function. + /// + /// This block must be invoked by native code running on the same thread as + /// the isolate that registered it. Invoking the block on the wrong thread + /// will result in a crash. + /// + /// If `keepIsolateAlive` is true, this block will keep this isolate alive + /// until it is garbage collected by both Dart and ObjC. + static objc.ObjCBlock<ffi.Void Function(Sendable)> fromFunction( + void Function(Sendable) fn, { + bool keepIsolateAlive = true, + }) => objc.ObjCBlock<ffi.Void Function(Sendable)>( + objc.newClosureBlock(_closureCallable, ( + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) { + return fn(Sendable.fromPointer(arg0, retain: true, release: true)); + }, keepIsolateAlive), + retain: false, + release: true, + ); + + /// Creates a listener block from a Dart function. + /// + /// This is based on FFI's NativeCallable.listener, and has the same + /// capabilities and limitations. This block can be invoked from any thread, + /// but only supports void functions, and is not run synchronously. See + /// NativeCallable.listener for more details. + /// + /// If `keepIsolateAlive` is true, this block will keep this isolate alive + /// until it is garbage collected by both Dart and ObjC. + static objc.ObjCBlock<ffi.Void Function(Sendable)> listener( + void Function(Sendable) fn, { + bool keepIsolateAlive = true, + }) { + final raw = objc.newClosureBlock(_listenerCallable.nativeFunction.cast(), ( + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) { + return fn(Sendable.fromPointer(arg0, retain: false, release: true)); + }, keepIsolateAlive); + final wrapper = _rdx59v_wrapListenerBlock_xtuoz7(raw); + objc.objectRelease(raw.cast()); + return objc.ObjCBlock<ffi.Void Function(Sendable)>( + wrapper, + retain: false, + release: true, + ); + } + + /// Creates a blocking block from a Dart function. + /// + /// This callback can be invoked from any native thread, and will block the + /// caller until the callback is handled by the Dart isolate that created + /// the block. Async functions are not supported. + /// + /// If `keepIsolateAlive` is true, this block will keep this isolate alive + /// until it is garbage collected by both Dart and ObjC. If the owner isolate + /// has shut down, and the block is invoked by native code, it may block + /// indefinitely, or have other undefined behavior. + static objc.ObjCBlock<ffi.Void Function(Sendable)> blocking( + void Function(Sendable) fn, { + bool keepIsolateAlive = true, + }) { + final raw = objc.newClosureBlock(_blockingCallable.nativeFunction.cast(), ( + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) { + return fn(Sendable.fromPointer(arg0, retain: false, release: true)); + }, keepIsolateAlive); + final rawListener = objc.newClosureBlock( + _blockingListenerCallable.nativeFunction.cast(), + (ffi.Pointer<objc.ObjCObjectImpl> arg0) { + return fn(Sendable.fromPointer(arg0, retain: false, release: true)); + }, + keepIsolateAlive, + ); + final wrapper = _rdx59v_wrapBlockingBlock_xtuoz7( + raw, + rawListener, + objc.objCContext, + ); + objc.objectRelease(raw.cast()); + objc.objectRelease(rawListener.cast()); + return objc.ObjCBlock<ffi.Void Function(Sendable)>( + wrapper, + retain: false, + release: true, + ); + } + + static void _listenerTrampoline( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) { + (objc.getBlockClosure(block) + as void Function(ffi.Pointer<objc.ObjCObjectImpl>))(arg0); + objc.objectRelease(block.cast()); + } + + static ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + > + _listenerCallable = + ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >.listener(_listenerTrampoline) + ..keepIsolateAlive = false; + static void _blockingTrampoline( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<ffi.Void> waiter, + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) { + try { + (objc.getBlockClosure(block) + as void Function(ffi.Pointer<objc.ObjCObjectImpl>))(arg0); + } catch (e) { + } finally { + objc.signalWaiter(waiter); + objc.objectRelease(block.cast()); + } + } + + static ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<ffi.Void>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + > + _blockingCallable = + ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<ffi.Void>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >.isolateLocal(_blockingTrampoline) + ..keepIsolateAlive = false; + static ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<ffi.Void>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + > + _blockingListenerCallable = + ffi.NativeCallable< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<ffi.Void>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >.listener(_blockingTrampoline) + ..keepIsolateAlive = false; + static void _fnPtrTrampoline( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) => block.ref.target + .cast< + ffi.NativeFunction< + ffi.Void Function(ffi.Pointer<objc.ObjCObjectImpl> arg0) + > + >() + .asFunction<void Function(ffi.Pointer<objc.ObjCObjectImpl>)>()(arg0); + static ffi.Pointer<ffi.Void> _fnPtrCallable = + ffi.Pointer.fromFunction< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >(_fnPtrTrampoline) + .cast(); + static void _closureTrampoline( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) => + (objc.getBlockClosure(block) + as void Function(ffi.Pointer<objc.ObjCObjectImpl>))(arg0); + static ffi.Pointer<ffi.Void> _closureCallable = + ffi.Pointer.fromFunction< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >(_closureTrampoline) + .cast(); +} + +/// Call operator for `objc.ObjCBlock<ffi.Void Function(Sendable)>`. +extension ObjCBlock_ffiVoid_Sendable$CallExtension + on objc.ObjCBlock<ffi.Void Function(Sendable)> { + void call(Sendable arg0) { + final _$$ref = arg0.ref; + return ref.pointer.ref.invoke + .cast< + ffi.NativeFunction< + ffi.Void Function( + ffi.Pointer<objc.ObjCBlockImpl> block, + ffi.Pointer<objc.ObjCObjectImpl> arg0, + ) + > + >() + .asFunction< + void Function( + ffi.Pointer<objc.ObjCBlockImpl>, + ffi.Pointer<objc.ObjCObjectImpl>, + ) + >()(ref.pointer, _$$ref.pointer); + } +} + /// Sendable extension type Sendable._(objc.ObjCObject object$) implements objc.ObjCObject, objc.NSObject { @@ -302,6 +568,20 @@ ); } + /// dummyMethodToForceGenerationOfListenerWithSendable + static objc.ObjCBlock<ffi.Void Function(Sendable)> + dummyMethodToForceGenerationOfListenerWithSendable() { + final $ret = _objc_msgSend_uwvaik( + _class_Sendable, + _sel_dummyMethodToForceGenerationOfListenerWithSendable, + ); + return ObjCBlock_ffiVoid_Sendable.fromPointer( + $ret, + retain: true, + release: true, + ); + } + /// new static Sendable new$() { final $ret = _objc_msgSend_151sglz(_class_Sendable, _sel_new); @@ -450,6 +730,8 @@ late final _sel_dummyMethodToForceGenerationOfListener = objc.registerName( "dummyMethodToForceGenerationOfListener", ); +late final _sel_dummyMethodToForceGenerationOfListenerWithSendable = objc + .registerName("dummyMethodToForceGenerationOfListenerWithSendable"); late final _sel_init = objc.registerName("init"); late final _sel_isKindOfClass_ = objc.registerName("isKindOfClass:"); late final _sel_new = objc.registerName("new");
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.m b/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.m index e04acaf..fcc267b 100644 --- a/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.m +++ b/pkgs/ffigen/test/native_objc_test/isolate_test_bindings.m
@@ -71,6 +71,29 @@ listenerBlock(waiter, arg0); }); } + +typedef void (^_ListenerTrampoline_1)(id arg0); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_1 _rdx59v_wrapListenerBlock_xtuoz7(_ListenerTrampoline_1 block) NS_RETURNS_RETAINED { + return ^void(id arg0) { + objc_retainBlock(block); + block((__bridge id)(__bridge_retained void*)(arg0)); + }; +} + +typedef void (^_BlockingTrampoline_1)(void * waiter, id arg0); +__attribute__((visibility("default"))) __attribute__((used)) +_ListenerTrampoline_1 _rdx59v_wrapBlockingBlock_xtuoz7( + _BlockingTrampoline_1 block, _BlockingTrampoline_1 listenerBlock, + DOBJC_Context* ctx) NS_RETURNS_RETAINED { + BLOCKING_BLOCK_IMPL(ctx, ^void(id arg0), { + objc_retainBlock(block); + block(nil, (__bridge id)(__bridge_retained void*)(arg0)); + }, { + objc_retainBlock(listenerBlock); + listenerBlock(waiter, (__bridge id)(__bridge_retained void*)(arg0)); + }); +} #undef BLOCKING_BLOCK_IMPL #pragma clang diagnostic pop
diff --git a/pkgs/ffigen/test/native_objc_test/isolate_test_case3_subprocess.dart b/pkgs/ffigen/test/native_objc_test/isolate_test_case3_subprocess.dart new file mode 100644 index 0000000..220668f --- /dev/null +++ b/pkgs/ffigen/test/native_objc_test/isolate_test_case3_subprocess.dart
@@ -0,0 +1,30 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:ffi'; +import 'dart:isolate'; +import 'isolate_test_bindings.dart'; + +void case3Helper(SendPort sendPort) async { + final block = ObjCBlock_ffiVoid_Int32.listener((int x) { + // Do nothing. + }, keepIsolateAlive: false); + sendPort.send(block); +} + +void main() async { + final port = ReceivePort(); + final exitPort = ReceivePort(); + await Isolate.spawn(case3Helper, port.sendPort, onExit: exitPort.sendPort); + + final block = await port.first as ObjCBlock<Void Function(Int32)>; + + // Wait for the helper isolate to exit. + await exitPort.first; + + // The helper isolate is now completely shut down. + // Invoking the block now will call the deleted native function pointer + // under the buggy implementation, resulting in a crash. + block(123); +}