NativeHandleState records Isolate.current.hashCode and validates against it (bindings/dart/lib/src/internal/lifecycle/lifecycle.dart:66,113). The C API validates std::thread::id. These are not equivalent: a Dart isolate migrates between OS threads while its hash stays constant.
Measured with pthread_self through dart:ffi, one isolate, awaiting file I/O in a loop:
isolate=970152377 initial os=135237815236288
MIGRATED at iteration 11: os 135237815236288 -> 135237808420544 (isolate hash unchanged: 970152377)
MIGRATED at iteration 19: os 135237808420544 -> 135237815236288 (isolate hash unchanged: 970152377)
MIGRATED at iteration 29: os 135237815236288 -> 135237808420544 (isolate hash unchanged: 970152377)
It reproduces on the main isolate, Isolate.run, and Isolate.spawn. Future.delayed alone did not move it; awaiting I/O did, so the isolate appears to resume on whichever VM thread completed the I/O.
The consequence, through the public binding:
final runtime = RuntimeHandle.create();
runtime.pump(); // OK
while (!migrated) { // any awaited I/O
await File('/proc/self/stat').readAsString();
migrated = osThread() != start;
}
runtime.pump(); // wrongThread (-3): runtime call must be made on its owner thread
runtime.close(); // wrongThread (-3): runtime must be destroyed on its owner thread
Both fail. Because close fails too, the runtime becomes undestroyable and mln_runtime_destroy refuses for the rest of the process, so the runtime, its run loop, and everything under it leak.
This affects any thread-affine handle (runtime, map, projection, render session) held across an awaited I/O operation, which is ordinary Dart code.
The existing test suite does not catch it because tests do their native calls without awaiting I/O in between.
I do not have a fix to propose that fits inside the current design. Options seem to be pinning native calls to a thread the binding owns and routing them there, or documenting a hard constraint that no thread-affine handle may be held across an await. Flagging it rather than guessing.
NativeHandleStaterecordsIsolate.current.hashCodeand validates against it (bindings/dart/lib/src/internal/lifecycle/lifecycle.dart:66,113). The C API validatesstd::thread::id. These are not equivalent: a Dart isolate migrates between OS threads while its hash stays constant.Measured with
pthread_selfthroughdart:ffi, one isolate, awaiting file I/O in a loop:It reproduces on the main isolate,
Isolate.run, andIsolate.spawn.Future.delayedalone did not move it; awaiting I/O did, so the isolate appears to resume on whichever VM thread completed the I/O.The consequence, through the public binding:
Both fail. Because
closefails too, the runtime becomes undestroyable andmln_runtime_destroyrefuses for the rest of the process, so the runtime, its run loop, and everything under it leak.This affects any thread-affine handle (runtime, map, projection, render session) held across an awaited I/O operation, which is ordinary Dart code.
The existing test suite does not catch it because tests do their native calls without awaiting I/O in between.
I do not have a fix to propose that fits inside the current design. Options seem to be pinning native calls to a thread the binding owns and routing them there, or documenting a hard constraint that no thread-affine handle may be held across an await. Flagging it rather than guessing.