Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkgs/objective_c/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
## 9.4.0-wip
- Fix a [bug](https://github.com/dart-lang/native/issues/3209) where a Dart GC
safepoint during a non-leaf FFI call could prematurely release an ObjC block
before ObjC retained it, causing an EXC_BAD_ACCESS crash.
- Fix (https://github.com/dart-lang/native/issues/2877) such that all
occurances of ObjCObject `isA` now accepts a nullable `ObjCObject?` and
returns `false` when input is`null`
Expand Down
1 change: 1 addition & 0 deletions pkgs/objective_c/hook/build.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ void main(List<String> args) async {
input.userDefines['include_test_utils'] as bool? ?? false;
if (includeTestUtils) {
cFiles.add(input.packageRoot.resolve('test/util.c').toFilePath());
mFiles.add(input.packageRoot.resolve('test/gc_inject.m').toFilePath());
}

final sysroot = sdkPath(codeConfig);
Expand Down
3 changes: 2 additions & 1 deletion pkgs/objective_c/lib/src/protocol_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ class ObjCProtocolBuilder {
if (_built) {
throw StateError('Protocol is already built');
}
final blockRef = block.ref;
_builder.implementMethod(
sel,
withBlock: block.ref.pointer.cast(),
withBlock: blockRef.pointer.cast(),
withTrampoline: trampoline,
withSignature: signature,
);
Expand Down
102 changes: 102 additions & 0 deletions pkgs/objective_c/test/gc_inject.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// 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.

// GC injection helpers for regression-testing issue #3209.
//
// Swizzles DOBJCDartProtocolBuilder's implementMethod:withBlock:... to inject
// a Dart GC at the FFI safepoint between Dart extracting the raw block pointer
// and Objective-C retaining it — the exact window where premature GC can
// release the block before the handover completes.
//
// Run AOT to reproduce under production-like conditions — GC liveness issues
// like this are unreliable in JIT (from pkgs/objective_c/).
// Native assets must be enabled; stable from Dart 3.10.0:
// dart compile exe test/gc_safepoint_test.dart -o /tmp/gc_test
// DYLD_INSERT_LIBRARIES=.dart_tool/lib/objective_c.dylib /tmp/gc_test

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#include <dlfcn.h>
#include <stdbool.h>
#include <stdint.h>

// From util.c — reads the block ABI flags field for the retain count.
extern uint64_t getBlockRetainCount(void*);

typedef void* (*DartGCNow_t)(const char*, void*);
static DartGCNow_t g_dart_gc_now = NULL;

static IMP g_original_imp = NULL;
static bool g_swizzle_active = false;
// Written and read from the same Dart thread (native-mode FFI call), so a
// plain bool is safe.
static bool g_block_freed_before_retain = false;

// Replacement for -[DOBJCDartProtocolBuilder implementMethod:withBlock:...].
// Forces GC before calling the original, then checks the retain count.
static void gc_inject_imp(
id self, SEL _cmd, SEL sel, void* block,
void* trampoline, const char* signature) {
if (g_swizzle_active && g_dart_gc_now != NULL) {
g_dart_gc_now("gc-now", NULL);
// Use the same util as the Dart side (util.c:getBlockRetainCount).
int count = (int)getBlockRetainCount(block);
if (count == 0) {
g_block_freed_before_retain = true;
}
}
((void (*)(id, SEL, SEL, void*, void*, const char*))g_original_imp)(
self, _cmd, sel, block, trampoline, signature);
}

// Look up Dart_ExecuteInternalCommand via dlsym. Must be called once before
// installing the swizzle.
void initGCInject(void) {
g_dart_gc_now =
(DartGCNow_t)dlsym(RTLD_DEFAULT, "Dart_ExecuteInternalCommand");
}

bool gcNowAvailableFromNative(void) {
return g_dart_gc_now != NULL;
}

// Triggers gc-now from inside a non-leaf FFI call (Dart thread is at a
// safepoint), used to verify GC can actually fire from native mode.
void callGCNowFromNative(void) {
if (g_dart_gc_now != NULL) {
g_dart_gc_now("gc-now", NULL);
}
}

void installGCInjectSwizzle(void) {
g_block_freed_before_retain = false;
Class cls = NSClassFromString(@"DOBJCDartProtocolBuilder");
if (cls == nil) return;
SEL sel =
@selector(implementMethod:withBlock:withTrampoline:withSignature:);
Method m = class_getInstanceMethod(cls, sel);
if (m == NULL) return;
g_original_imp = method_setImplementation(m, (IMP)gc_inject_imp);
}

void removeGCInjectSwizzle(void) {
if (g_original_imp == NULL) return;
Class cls = NSClassFromString(@"DOBJCDartProtocolBuilder");
SEL sel =
@selector(implementMethod:withBlock:withTrampoline:withSignature:);
Method m = class_getInstanceMethod(cls, sel);
if (m != NULL) {
method_setImplementation(m, g_original_imp);
}
g_original_imp = NULL;
}

void setGCInjectActive(bool active) {
g_swizzle_active = active;
}

// Sticky flag: once true it stays true even after setGCInjectActive(false).
bool wasBlockFreedBeforeRetain(void) {
return g_block_freed_before_retain;
}
119 changes: 119 additions & 0 deletions pkgs/objective_c/test/protocol_builder_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2024, 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.

// Objective C support is only available on mac.
@TestOn('mac-os')
library;

import 'package:objective_c/objective_c.dart';
import 'package:objective_c/src/objective_c_bindings_generated.dart'
show ObjCBlock_ffiVoid_ffiVoid_NSStream_NSStreamEvent;
import 'package:test/test.dart';

import 'util.dart';

// Directly exercises the blockRef extraction pattern from the production fix.
// Extract block.ref into a local `blockRef` (static type ObjCBlockRef, which
// transitively implements Finalizable). The Finalizable contract keeps blockRef
// live in the GC stack map across the non-leaf FFI safepoint below.
//
// Intentionally never-inlined so the JIT performs its own liveness analysis.
@pragma('vm:never-inline')
bool _gcAndCheckBlock() {
final block = ObjCBlock_ffiVoid_ffiVoid_NSStream_NSStreamEvent.fromFunction(
(_, stream, event) {},
keepIsolateAlive: false,
);
final blockRef = block.ref;
final ptr = blockRef.pointer;
// Use callGCNowFromNative (non-leaf safepoint) to trigger GC, then check
// the retain count via blockRetainCount, which reads the block ABI flags
// field — the correct location for block retain counts on all architectures.
callGCNowFromNative();
return blockRetainCount(ptr) > 0;
}

void main() {
group('block wrapper not freed at GC safepoints', () {
setUpAll(() {
initGCInject();
Comment thread
henawey-t marked this conversation as resolved.
installGCInjectSwizzle();
});

tearDownAll(removeGCInjectSwizzle);

// Diagnostic: verify that gc-now from native code actually triggers GC.
// If this test fails, the GC-injection swizzle is a no-op and the
// reproduction test below is not meaningful.
test('gc-now from native code collects unreachable objects', () {
if (!canDoGC) {
markTestSkipped(
'Dart_ExecuteInternalCommand unavailable — GC injection is a no-op.',
);
return;
}
expect(gcNowAvailableFromNative(), isTrue);

WeakReference<Object>? weakRef;
(() {
final obj = Object();
weakRef = WeakReference(obj);
})();

callGCNowFromNative();

expect(weakRef!.target, isNull);
});

// Swizzle injects gc-now before ObjC retains the block. Without the
// blockRef extraction fix, the optimizer marks `block` dead after its raw
// pointer is extracted and GC drops the retain count to 0.
// Run 1000 iterations to trigger JIT optimisation of implementMethod.
test('block survives GC injected inside implementMethod '
'(fails without blockRef extraction)', () {
const kIterations = 1000;
for (var i = 0; i < kIterations; i++) {
final builder = ObjCProtocolBuilder();
setGCInjectActive(true);
NSStreamDelegate$Builder.stream_handleEvent_.implement(
builder,
(stream, event) {},
);
setGCInjectActive(false);
// wasBlockFreedBeforeRetain() is sticky: stays true once set.
if (wasBlockFreedBeforeRetain()) break;
}

expect(
wasBlockFreedBeforeRetain(),
isFalse,
reason:
'Block was prematurely released by GC before ObjC retained it. '
'blockRef extraction in implementMethod is required (issue #3209).',
);
});

test('block local NOT freed at non-leaf FFI safepoint', () {
// Guaranteed to reproduce on iteration 1 with:
// dart --optimization-counter-threshold=0 test ...
if (!canDoGC) {
markTestSkipped(
'Dart_ExecuteInternalCommand unavailable — gc-now is a no-op, '
'test would pass vacuously.',
);
return;
}
const kIterations = 1000;
for (var i = 0; i < kIterations; i++) {
final survived = _gcAndCheckBlock();
if (!survived) {
fail(
'Block wrapper was GC-collected at FFI safepoint on iteration $i. '
'blockRef extraction in implementMethod required (issue #3209).',
);
}
}
});
});
}
45 changes: 45 additions & 0 deletions pkgs/objective_c/test/util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
// 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.

// All @Native bindings in this file live in the package's native asset dylib.
@DefaultAsset('package:objective_c/objective_c.dylib')
library;

import 'dart:ffi';

import 'package:ffi/ffi.dart';
import 'package:native_test_helpers/native_test_helpers.dart';
import 'package:objective_c/objective_c.dart';
import 'package:objective_c/src/c_bindings_generated.dart' as c;
import 'package:objective_c/src/internal.dart'
as internal_for_testing
show isValidClass;
Expand Down Expand Up @@ -71,3 +76,43 @@ int objectRetainCount(Pointer<ObjCObjectImpl> object) {
}

String pkgDir = findPackageRoot('objective_c').toFilePath();

// ---------------------------------------------------------------------------
// Block retain count (from util.c: getBlockRetainCount).
// ---------------------------------------------------------------------------

@Native<Uint64 Function(Pointer<Void>)>(
isLeaf: true,
symbol: 'getBlockRetainCount',
)
external int _getBlockRetainCount(Pointer<Void> block);

int blockRetainCount(Pointer<c.ObjCBlockImpl> block) =>
_getBlockRetainCount(block.cast());

// ---------------------------------------------------------------------------
// GC injection helpers (from gc_inject.m) — only available on macOS.
// ---------------------------------------------------------------------------

@Native<Void Function()>(isLeaf: true, symbol: 'initGCInject')
external void initGCInject();

@Native<Void Function()>(isLeaf: true, symbol: 'installGCInjectSwizzle')
external void installGCInjectSwizzle();

@Native<Void Function()>(isLeaf: true, symbol: 'removeGCInjectSwizzle')
external void removeGCInjectSwizzle();

@Native<Void Function(Bool)>(isLeaf: true, symbol: 'setGCInjectActive')
external void setGCInjectActive(bool active);

@Native<Bool Function()>(isLeaf: true, symbol: 'wasBlockFreedBeforeRetain')
external bool wasBlockFreedBeforeRetain();

@Native<Bool Function()>(isLeaf: true, symbol: 'gcNowAvailableFromNative')
external bool gcNowAvailableFromNative();

// Must NOT be isLeaf: the native side calls Dart_ExecuteInternalCommand which
// requires the Dart thread to be at a proper native-mode safepoint.
@Native<Void Function()>(symbol: 'callGCNowFromNative')
external void callGCNowFromNative();
Loading