This commit is contained in:
Ben Allfree
2026-03-02 19:31:35 -08:00
committed by just-stuff-tm
parent e6c9a3fea7
commit 3c0c0d1dea
+41 -35
View File
@@ -34,14 +34,6 @@ class UsbSerialService {
FlSerial? _serial; FlSerial? _serial;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
/// Holds the last-opened native serial port across hot-restart boundaries.
/// On hot restart the Dart isolate is torn down without running [disconnect],
/// leaving the native SerialThread alive. The next [connect] call reads this
/// field and force-closes the orphaned port before creating a new one, which
/// causes the old native thread to unblock its blocking read and exit
/// naturally—before any new Dart FFI callbacks are registered.
static FlSerial? _lastSerial;
UsbSerialStatus get status => _status; UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey; String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel => String? get activePortDisplayLabel =>
@@ -151,25 +143,23 @@ class UsbSerialService {
throw StateError(msg); throw StateError(msg);
} }
} else { } else {
// Force-close any native serial port left open by the previous Dart // ── Hot-restart guard ─────────────────────────────────────────────────
// isolate (hot-restart case). The old SerialThread blocks on read(); once // On hot restart Dart tears down the isolate without calling dispose().
// the port is closed here it unblocks and exits before we register any // The NativeCallable registered by flserial's setCallback() is
// new Dart FFI callbacks, preventing the "callback invoked after deletion" // isolate-local and gets freed when the isolate dies, but the native
// crash. // SerialThread is still alive and will call it → crash.
final orphan = _lastSerial; //
if (orphan != null) { // flserial uses process-global native state. Calling fl_free() kills ALL
_lastSerial = null; // SerialThreads for every open port across all Dart isolates (there is
try { // only one in a Flutter app). Then fl_init() re-initialises the slot
if (orphan.isOpen() == FlOpenStatus.open) { // table so subsequent fl_open() calls work normally.
orphan.closePort(); //
} // This must happen before we register any new NativeCallable, so it must
} catch (_) {} // be the very first thing we do in the desktop branch.
try { try {
orphan.free(); bindings.fl_free();
} catch (_) {} bindings.fl_init(16);
// Give the native thread a moment to observe the port closure and exit. } catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
// On macOS, flserial lists both cu.* and tty.* device nodes. // On macOS, flserial lists both cu.* and tty.* device nodes.
// When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.* // When a cu.* open fails with FL_ERROR_PORT_NOT_EXIST, try the tty.*
@@ -203,7 +193,6 @@ class UsbSerialService {
serial.setRTS(false); serial.setRTS(false);
serial.setDTR(true); serial.setDTR(true);
_serial = serial; _serial = serial;
_lastSerial = serial;
// Update the normalized port name to whichever candidate succeeded. // Update the normalized port name to whichever candidate succeeded.
normalizedPortName = candidate; normalizedPortName = candidate;
_debugLogService?.info( _debugLogService?.info(
@@ -213,9 +202,8 @@ class UsbSerialService {
opened = true; opened = true;
break; break;
} on FlSerialException catch (error) { } on FlSerialException catch (error) {
// Do NOT call fl_free() here — it destroys global native library // The native fl_open() already called fl_close() on failure
// state and makes subsequent fl_init() calls unreliable. The native // internally, so no extra cleanup is needed here for this candidate.
// fl_open() already called fl_close() on failure internally.
debugPrint( debugPrint(
'[USB Serial] Failed to open $candidate: ${error.msg} (code ${error.error})', '[USB Serial] Failed to open $candidate: ${error.msg} (code ${error.error})',
); );
@@ -314,7 +302,6 @@ class UsbSerialService {
// crashes with "Callback invoked after it has been deleted". // crashes with "Callback invoked after it has been deleted".
final serial = _serial; final serial = _serial;
_serial = null; _serial = null;
_lastSerial = null;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.closePort(); serial?.closePort();
@@ -322,9 +309,9 @@ class UsbSerialService {
} catch (_) { } catch (_) {
// Ignore errors while closing. // Ignore errors while closing.
} }
try { // Note: we do NOT call free() here; that would globally reset native
serial?.free(); // state for all ports. The global reset is done in connect() instead,
} catch (_) {} // before the next open, which is the safer place to do it.
// Now it is safe to cancel the Dart subscription — the native thread has // Now it is safe to cancel the Dart subscription — the native thread has
// already seen the port close and will not fire any more callbacks. // already seen the port close and will not fire any more callbacks.
@@ -350,6 +337,25 @@ class UsbSerialService {
} }
void dispose() { void dispose() {
// Synchronously close the native port so the SerialThread exits before
// the Dart isolate is torn down (e.g. on hot restart). The async
// disconnect() path via unawaited() offers no ordering guarantee — the
// isolate may die before the Future resolves, leaving the thread alive
// with a dangling NativeCallable pointer.
if (_useDesktopFlSerial) {
final serial = _serial;
_serial = null;
_status = UsbSerialStatus.disconnected;
_connectedPortKey = null;
_connectedPortLabel = null;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.closePort(); // synchronous C call — kills the SerialThread
}
} catch (_) {}
}
// Kick off the full async teardown for anything else (subscription cancel,
// stream controller close). These are best-effort at dispose time.
unawaited(disconnect().whenComplete(_closeFrameController)); unawaited(disconnect().whenComplete(_closeFrameController));
} }