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';
2729import '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
9783void 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