Skip to content

Commit 9aab841

Browse files
committed
[objective_c] Switch issue #3209 fix to blockRef extraction approach
Apply the Flutter team's recommended fix for the GC-at-FFI-safepoint crash: instead of adding `implements Finalizable` to ObjCBlockBase, extract `block.ref` into a local `blockRef` (type ObjCBlockRef, which transitively implements Finalizable via _ObjCReference) before each FFI call site. The Finalizable contract then keeps `blockRef` live across the non-leaf safepoint, preventing EXC_BAD_ACCESS before ObjC retains the pointer. Update finalizable_test.dart to match: - Remove the isA<Finalizable>() type assertion for ObjCBlockBase - Update _gcAndCheckBlock to use blockRef extraction (mirrors the fix) - Replace bare return with markTestSkipped in canDoGC guards - Return bool from _gcAndCheckBlock instead of int - Update group/test names and comments throughout
1 parent f77d876 commit 9aab841

3 files changed

Lines changed: 72 additions & 114 deletions

File tree

pkgs/objective_c/lib/src/internal.dart

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -383,19 +383,7 @@ final class ObjCBlockRef extends _ObjCReference<c.ObjCBlockImpl> {
383383
}
384384

385385
/// Only for use by FFIgen bindings.
386-
// Implements Finalizable so that local variables of type ObjCBlockBase (and
387-
// its subclasses) are kept alive across FFI safepoints. Without this, the
388-
// compiler can consider a block parameter dead after its raw pointer is
389-
// extracted via block.ref.pointer.cast(), allowing the GC to fire before ObjC
390-
// retains the pointer — causing EXC_BAD_ACCESS in production.
391-
// See: https://github.com/dart-lang/native/issues/3209
392-
//
393-
// Note: ObjCObject intentionally does NOT implement Finalizable here, because
394-
// ObjCObject instances may be sent across Dart isolates (e.g. NSInputStream),
395-
// and Finalizable objects are non-sendable. Blocks capture Dart closures and
396-
// are never sent across isolates, so Finalizable is safe for ObjCBlockBase.
397-
class ObjCBlockBase extends _ObjCRefHolder<c.ObjCBlockImpl, ObjCBlockRef>
398-
implements Finalizable {
386+
class ObjCBlockBase extends _ObjCRefHolder<c.ObjCBlockImpl, ObjCBlockRef> {
399387
ObjCBlockBase(BlockPtr ptr, {required bool retain, required bool release})
400388
: super(ObjCBlockRef(ptr, retain: retain, release: release));
401389
}

pkgs/objective_c/lib/src/protocol_builder.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ class ObjCProtocolBuilder {
4040
if (_built) {
4141
throw StateError('Protocol is already built');
4242
}
43+
final blockRef = block.ref;
4344
_builder.implementMethod(
4445
sel,
45-
withBlock: block.ref.pointer.cast(),
46+
withBlock: blockRef.pointer.cast(),
4647
withTrampoline: trampoline,
4748
withSignature: signature,
4849
);

pkgs/objective_c/test/finalizable_test.dart

Lines changed: 69 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

@@ -8,10 +8,12 @@ library;
88

99
// Regression test for https://github.com/dart-lang/native/issues/3209
1010
//
11-
// ObjCBlockBase must implement Finalizable so that the Dart compiler keeps
12-
// local variables of that type alive across FFI safepoints. Without this, a
13-
// GC event during a native call can fire before ObjC retains the pointer,
14-
// causing EXC_BAD_ACCESS in production.
11+
// The fix for issue #3209 is in ObjCProtocolBuilder.implementMethod: extract
12+
// block.ref into a local `blockRef` (static type ObjCBlockRef, which
13+
// transitively implements Finalizable). The Finalizable contract guarantees
14+
// the VM will not finalize `blockRef` before the end of its enclosing scope,
15+
// keeping the ObjC retain count >= 1 across the non-leaf FFI safepoint and
16+
// preventing EXC_BAD_ACCESS in production.
1517
//
1618
// Note: ObjCObject intentionally does NOT implement Finalizable because
1719
// ObjCObject instances may be sent across Dart isolates (e.g. NSInputStream),
@@ -27,105 +29,63 @@ import 'package:test/test.dart';
2729
import 'util.dart';
2830

2931
// ---------------------------------------------------------------------------
30-
// Compile-time assertion (enforced by dart analyze).
31-
//
32-
// If ObjCBlockBase ever loses implements Finalizable, this function will
33-
// produce a static type error, catching the regression before tests even run.
34-
// ---------------------------------------------------------------------------
35-
36-
void _requireFinalizable(Finalizable _) {}
37-
38-
// Compile-time guard: if ObjCBlockBase ever loses implements Finalizable,
39-
// dart analyze will flag this as a type error before any test even runs.
40-
// To reproduce the crash, temporarily comment this out alongside removing
41-
// `implements Finalizable` from ObjCBlockBase in lib/src/internal.dart.
42-
// ignore: unused_element
43-
void _checkObjCBlockBaseIsFinalizable(ObjCBlockBase b) =>
44-
_requireFinalizable(b);
45-
46-
// ---------------------------------------------------------------------------
47-
// GC-safepoint liveness probe (supplementary, issue #3209).
32+
// GC-safepoint liveness probe (issue #3209).
4833
//
4934
// This function is intentionally never-inlined so the JIT compiles it as an
5035
// independent compilation unit and performs its own liveness analysis.
5136
//
52-
// `block` is a LOCAL variable (not a parameter threaded through a call chain).
53-
// Its last strong use is `block.ref.pointer`; after that, `gcAndGetRetainCount`
54-
// creates a NON-LEAF FFI safepoint — the Dart thread enters native mode and
55-
// the JIT's precise stack map is snapshotted.
37+
// Demonstrates the blockRef extraction pattern from the production fix
38+
// (ObjCProtocolBuilder.implementMethod). Does NOT call implementMethod —
39+
// it directly exercises the pattern to isolate the liveness mechanism.
40+
// extract block.ref into a local `blockRef` whose static type (ObjCBlockRef)
41+
// transitively implements Finalizable (via _ObjCReference). The Finalizable
42+
// contract guarantees the VM will not finalize `blockRef` before end of scope,
43+
// keeping it live in the stack map across the NON-LEAF FFI safepoint.
44+
//
45+
// WITH blockRef extraction (ObjCBlockRef is Finalizable):
46+
// `blockRef` stays live until end of scope → ObjC retain count ≥ 1.
47+
// gcAndGetRetainCount(ptr) returns > 0. Returns true (pass).
5648
//
57-
// WITH Finalizable (ObjCBlockBase implements Finalizable):
58-
// The compiler inserts reachabilityFence(block) at the end of this scope.
59-
// `block` stays in the JIT stack map. GC finds it alive → weakRef valid.
60-
// Returns 1 (pass).
49+
// WITHOUT blockRef extraction (using block.ref.pointer directly):
50+
// `block` (type ObjCBlockBase, not Finalizable) is dead after its last
51+
// use. GC fires at the safepoint, finalizer calls objc_release →
52+
// retain count = 0. Returns false (fail).
6153
//
62-
// WITHOUT Finalizable:
63-
// The JIT is PERMITTED to remove `block` from the stack map after its last
64-
// use. In practice the JIT is conservative in --jit-optimized mode and may
65-
// still keep `block` alive, so this probe does NOT fail deterministically
66-
// in JIT mode. Reliable reproduction requires AOT compilation:
67-
// dart compile exe test/finalizable_test.dart && ./finalizable_test
68-
// The primary regression guard for issue #3209 is the isA<Finalizable>()
69-
// check in the 'Finalizable' group, which is 100% deterministic.
54+
// For guaranteed reproduction on iteration 1 (no JIT warm-up), run:
55+
// dart --optimization-counter-threshold=0 test test/finalizable_test.dart
7056
// ---------------------------------------------------------------------------
7157
@pragma('vm:never-inline')
72-
int _gcAndCheckBlock() {
73-
// Create a closure block as a LOCAL variable.
74-
// Its last Dart-level use is `block.ref.pointer` on the next line.
75-
// Without Finalizable, the JIT can eliminate `block` from the GC stack map
76-
// after that point.
58+
bool _gcAndCheckBlock() {
7759
final block = ObjCBlock_ffiVoid_ffiVoid_NSStream_NSStreamEvent.fromFunction(
7860
(_, stream, event) {},
7961
keepIsolateAlive: false,
8062
);
81-
// Weak reference to detect if `block` is collected by GC.
82-
// WeakReference is cleared synchronously during GC (unlike FinalizableHandle
83-
// callbacks, which are deferred until the isolate next runs Dart code).
84-
final weakRef = WeakReference(block);
85-
final ptr = block.ref.pointer; // last strong use of `block`
86-
// Non-leaf FFI safepoint: GC fires here with the JIT's precise stack map.
87-
// WITHOUT Finalizable: `block` is dead → GC collects it → weakRef cleared.
88-
// WITH Finalizable: reachabilityFence(block) keeps it alive → weakRef valid.
89-
gcAndGetRetainCount(ptr); // non-leaf FFI call — triggers gc-now inside
90-
return weakRef.target != null ? 1 : 0;
63+
// Mirror the production fix: extract block.ref so that blockRef's Finalizable
64+
// type keeps the ObjC retain alive across the non-leaf FFI safepoint.
65+
final blockRef = block.ref;
66+
final ptr = blockRef.pointer;
67+
// blockRef is Finalizable: the VM guarantees it will not be finalized before
68+
// end of this function scope, so it stays live in the GC stack map across
69+
// the non-leaf FFI safepoint below.
70+
// gcAndGetRetainCount triggers GC via a native call to
71+
// Dart_ExecuteInternalCommand("gc-now") — a different path from the Dart-side
72+
// doGC(). The canDoGC guard in the test body skips this test if that native
73+
// symbol is unavailable. When GC fires, blockRef is still live →
74+
// ObjC retain count stays ≥ 1.
75+
final count = gcAndGetRetainCount(ptr);
76+
return count > 0;
9177
}
9278

9379
// ---------------------------------------------------------------------------
9480
// Runtime assertions.
9581
// ---------------------------------------------------------------------------
9682

9783
void main() {
98-
group('Finalizable', () {
99-
// Verifies at runtime that ObjCBlockBase instances carry the Finalizable
100-
// interface.
101-
//
102-
// This is the PRIMARY regression guard for issue #3209.
103-
// Without `implements Finalizable` on ObjCBlockBase, `block is Finalizable`
104-
// evaluates to false and this test fails — even though the containing
105-
// ObjCBlockRef field is itself Finalizable (Dart's `is` check looks at the
106-
// explicitly declared type hierarchy, not at field types).
107-
//
108-
// The compile-time check (_checkObjCBlockBaseIsFinalizable) catches the
109-
// same regression at analysis time; this test catches it at runtime.
110-
test('ObjCBlockBase implements Finalizable', () {
111-
final block =
112-
ObjCBlock_ffiVoid_ffiVoid_NSStream_NSStreamEvent.fromFunction(
113-
(_, stream, event) {},
114-
keepIsolateAlive: false,
115-
);
116-
// If ObjCBlockBase does NOT implement Finalizable this is false → fails.
117-
expect(
118-
block,
119-
isA<Finalizable>(),
120-
reason:
121-
'ObjCBlockBase must implement Finalizable (issue #3209). '
122-
'Without it the Dart compiler can collect block wrappers at FFI '
123-
'safepoints before ObjC retains the pointer, causing EXC_BAD_ACCESS.',
124-
);
125-
});
126-
84+
group('object model', () {
12785
// Verifies that ObjCObject does NOT implement Finalizable, preserving
12886
// isolate sendability for objects like NSInputStream.
87+
// ObjCObject is not directly instantiable; NSObject is used as a
88+
// concrete subclass to test the property at the ObjCObject level.
12989
test('ObjCObject is NOT Finalizable (preserves isolate sendability)', () {
13090
final obj = NSObject();
13191
expect(obj, isNot(isA<Finalizable>()));
@@ -163,15 +123,15 @@ void main() {
163123
// -[DOBJCDartProtocolBuilder implementMethod:withBlock:...] immediately
164124
// before [methods setObject:(__bridge id)block forKey:key] retains the block.
165125
//
166-
// Without the fix (ObjCBlockBase NOT Finalizable):
126+
// Without the fix (no blockRef extraction in implementMethod):
167127
// The optimizer marks `block` dead after extracting the raw pointer. When
168128
// gc-now fires at the FFI safepoint the Dart wrapper is collected, the
169129
// finalizer calls objc_release, and the retain count drops to 0 before ObjC
170130
// ever retains it — detectable via wasBlockFreedBeforeRetain().
171131
//
172-
// With the fix (ObjCBlockBase implements Finalizable):
173-
// The compiler inserts reachabilityFence(block) at the end of the calling
174-
// scope. gc-now finds the variable still reachable and does not collect it.
132+
// With the fix (blockRef extraction; ObjCBlockRef is Finalizable):
133+
// The Finalizable contract keeps `blockRef` live until end of calling scope.
134+
// gc-now finds blockRef still reachable and does not collect it.
175135
// The retain count stays at 1 throughout — wasBlockFreedBeforeRetain()
176136
// returns false.
177137
//
@@ -193,7 +153,9 @@ void main() {
193153
// reproduction test below is not meaningful.
194154
test('gc-now from native code collects unreachable objects', () {
195155
if (!canDoGC) {
196-
// If doGC() is not available from Dart either, skip.
156+
markTestSkipped(
157+
'Dart_ExecuteInternalCommand unavailable — GC injection is a no-op.',
158+
);
197159
return;
198160
}
199161
expect(
@@ -229,7 +191,7 @@ void main() {
229191
});
230192

231193
test('block survives GC injected inside implementMethod '
232-
'(fails without ObjCBlockBase implements Finalizable)', () {
194+
'(fails without blockRef extraction)', () {
233195
// Run 1 000 iterations so that the JIT optimizer has a chance to compile
234196
// implementMethod in optimised mode and mark `block` dead after the raw
235197
// pointer is extracted. In optimised code, gc-now in the swizzle will
@@ -248,15 +210,17 @@ void main() {
248210
(stream, event) {},
249211
);
250212
setGCInjectActive(false);
251-
if (wasBlockFreedBeforeRetain()) break; // bug detected early, stop
213+
// wasBlockFreedBeforeRetain() is a sticky flag: once true it stays
214+
// true even after setGCInjectActive(false). Break early on first hit.
215+
if (wasBlockFreedBeforeRetain()) break;
252216
}
253217

254218
expect(
255219
wasBlockFreedBeforeRetain(),
256220
isFalse,
257221
reason:
258222
'Block was prematurely released by GC before ObjC retained it. '
259-
'ObjCBlockBase must implement Finalizable (issue #3209).',
223+
'blockRef extraction in implementMethod is required (issue #3209).',
260224
);
261225
});
262226

@@ -265,28 +229,33 @@ void main() {
265229
//
266230
// Unlike the swizzle test (where `block` is a PARAMETER threaded through
267231
// a call chain and JIT keeps parameters alive conservatively), here `block`
268-
// is a LOCAL variable in a never-inlined function. The JIT can eliminate
232+
// is a LOCAL variable in a never-inlined function. The JIT can eliminate
269233
// it from the GC stack map after its last use, making the test sensitive to
270-
// whether ObjCBlockBase is Finalizable.
234+
// whether blockRef (ObjCBlockRef, Finalizable) is extracted before the
235+
// FFI call.
271236
//
272237
// For guaranteed reproduction on iteration 1 (no JIT warm-up needed), run:
273238
// dart --optimization-counter-threshold=0 test test/finalizable_test.dart
274239
// ---------------------------------------------------------------------------
275-
test('block local NOT freed at non-leaf FFI safepoint '
276-
'(deterministic with --optimization-counter-threshold=0)', () {
240+
test('block local NOT freed at non-leaf FFI safepoint', () {
241+
// Note: only guaranteed to reproduce on iteration 1 when run with
242+
// dart --optimization-counter-threshold=0
243+
// Under normal JIT, the optimizer may keep `block` alive conservatively
244+
// for several iterations before applying dead-code elimination.
277245
if (!canDoGC) {
278-
// gcAndGetRetainCount calls Dart_ExecuteInternalCommand internally.
279-
// If the symbol is unavailable (stripped runtime), gc-now is a no-op
280-
// and this test would pass vacuously. Skip it instead.
246+
markTestSkipped(
247+
'Dart_ExecuteInternalCommand unavailable — gc-now is a no-op, '
248+
'test would pass vacuously.',
249+
);
281250
return;
282251
}
283252
const kIterations = 1000;
284253
for (var i = 0; i < kIterations; i++) {
285254
final survived = _gcAndCheckBlock();
286-
if (survived == 0) {
255+
if (!survived) {
287256
fail(
288257
'Block wrapper was GC-collected at FFI safepoint on iteration $i. '
289-
'ObjCBlockBase must implement Finalizable (issue #3209).',
258+
'blockRef extraction in implementMethod required (issue #3209).',
290259
);
291260
}
292261
}

0 commit comments

Comments
 (0)