From d2d859baafeef2953a11a03170104a04b9701a67 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Fri, 9 Oct 2026 12:03:45 +0200 Subject: [PATCH 1/4] Report a socket disconnect only once A connection reset or a broken pipe fires the socket stream's onError and then onDone, and both reported a disconnect. FletBackend then ran two reconnect chains that kept replacing each other's connection, so after a hot reload the desktop window flashed with a new session every few hundred milliseconds. Seen on Windows and Linux when the client was sending events while the app was restarted. --- CHANGELOG.md | 2 ++ .../flet_backend_channel_socket.dart | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05403a6120..d7f654ec0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ **Compatibility:** Apps built with `flet build` and the macOS desktop client now require macOS 12 or later, and iOS apps require iOS 15 or later. Custom build templates keep building, because Flutter 3.47 raises the deployment targets during the build; apply the same `Podfile` and Xcode project changes to skip that step. See the [macOS 12 and iOS 15 minimum](/docs/updates/breaking-changes/v1-1-0/minimum-macos-12-ios-15) guide. +* Fix the desktop window flashing endlessly after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913)) by @ndonkoHenri. + ### Documentation * Group the Reference > Controls section of the docs into Core, Material, Cupertino, Shadcn and Extensions ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner. diff --git a/packages/flet/lib/src/transport/flet_backend_channel_socket.dart b/packages/flet/lib/src/transport/flet_backend_channel_socket.dart index 4ee3913326..5525d68651 100644 --- a/packages/flet/lib/src/transport/flet_backend_channel_socket.dart +++ b/packages/flet/lib/src/transport/flet_backend_channel_socket.dart @@ -20,6 +20,7 @@ class FletSocketBackendChannel implements FletBackendChannel { FletBackendChannelOnPacketCallback onPacket; FletBackendChannelOnDisconnectCallback onDisconnect; Socket? _socket; + bool _disconnected = false; late final bool _isLocalConnection; late final int _defaultReconnectIntervalMs; @@ -62,17 +63,29 @@ class FletSocketBackendChannel implements FletBackendChannel { _onBytes, onError: (error) { debugPrint("Error: $error"); - _socket?.destroy(); - onDisconnect(); + _onClosed(); }, onDone: () { debugPrint('Server disconnected.'); - _socket?.destroy(); - onDisconnect(); + _onClosed(); }, ); } + /// Releases the socket and reports the disconnect to [onDisconnect] once. + /// + /// A socket error, such as a connection reset, is delivered as an `onError` + /// event immediately followed by `onDone`. Each [onDisconnect] call makes + /// [FletBackend] schedule a reconnect, and two overlapping reconnects keep + /// replacing each other's connection on the server, which restarts the + /// session over and over. + void _onClosed() { + _socket?.destroy(); + if (_disconnected) return; + _disconnected = true; + onDisconnect(); + } + void _onBytes(Uint8List chunk) { _inboundBuffer.add(chunk); // Parse as many complete packets as the buffer currently holds. From d56fdd54c327f7aae9ff24b5f64ef657bce496ee Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 10 Oct 2026 20:34:05 +0200 Subject: [PATCH 2/4] Keep one reconnect pending in FletBackend FletBackend scheduled a reconnect for every disconnect a channel reported, so any transport that reported a disconnect twice started two reconnect chains that kept replacing each other's connection. Only let the channel of the latest connection attempt trigger a reconnect, and schedule at most one reconnect at a time, so the backend is safe whatever the transport does. Checked with the socket reset test: without the channel fix from the previous commit, the backend now still reconnects exactly once. --- packages/flet/CHANGELOG.md | 1 + packages/flet/lib/src/flet_backend.dart | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index b0260b715c..f84b05ddef 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -4,6 +4,7 @@ * **Breaking:** `parseTheme()` now builds themes from a seed with neutral surfaces by default (`#ffffff` / `#121212` and a neutral grey ramp for surface containers, `onSurface`, `outline` and inverse surfaces); the seed still drives accent colors. A theme's `surfaces: "tonal"` restores the seed-tinted Material 3 surfaces. Adds the `ThemeSurfaces` enum, `parseThemeSurfaces()` and `neutralizeSurfaces()` ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Import Material and Cupertino from the `material_ui` and `cupertino_ui` packages instead of `package:flutter/material.dart` and `package:flutter/cupertino.dart`. Helpers that take or return Material or Cupertino types, such as `parseColor()`, `parseTextStyle()` and `parseButtonStyle()`, now use those packages' types, and `flet.dart` no longer re-exports `Icons` and `CupertinoIcons`. Requires Flutter 3.47 or later. * Add `markdownStyleSheetFromTheme()`, which builds the default `MarkdownStyleSheet` from a `material_ui` theme. `MarkdownStyleSheet.fromTheme()` from `flutter_markdown_plus` only accepts the Flutter SDK's `ThemeData`. +* Fix `FletBackend` running two competing reconnects when a connection reports its disconnect twice, such as a socket reset after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913)) by @ndonkoHenri. ## 1.0.4 diff --git a/packages/flet/lib/src/flet_backend.dart b/packages/flet/lib/src/flet_backend.dart index 6ab01370fa..6bbf01b785 100644 --- a/packages/flet/lib/src/flet_backend.dart +++ b/packages/flet/lib/src/flet_backend.dart @@ -86,6 +86,8 @@ class FletBackend extends ChangeNotifier { final int? _reconnectTimeoutMs; int _reconnectStarted = 0; int _reconnectDelayMs = 0; + int _connectionId = 0; + bool _reconnectScheduled = false; FletBackendChannel? _backendChannel; final FletBackendChannelBuilder? _channelBuilder; late final DataChannelFactory _dataChannelFactory; @@ -294,6 +296,14 @@ class FletBackend extends ChangeNotifier { Future connect() async { debugPrint("Connecting to Flet backend $pageUri..."); + // A newer connection attempt makes every earlier channel stale: only the + // channel of the latest attempt may trigger a reconnect. + final connectionId = ++_connectionId; + _reconnectScheduled = false; + void onChannelDisconnect() { + if (connectionId == _connectionId) _onDisconnect(); + } + try { final builder = _channelBuilder; if (builder != null) { @@ -301,14 +311,14 @@ class FletBackend extends ChangeNotifier { // bridge). The builder is responsible for the entire transport // lifecycle; we just wire its callbacks to ours. _backendChannel = - builder(onDisconnect: _onDisconnect, onPacket: _onPacket); + builder(onDisconnect: onChannelDisconnect, onPacket: _onPacket); } else { _backendChannel = FletBackendChannel( address: pageUri.toString(), args: args ?? {}, forcePyodide: forcePyodide == true, embedded: controlId != null, - onDisconnect: _onDisconnect, + onDisconnect: onChannelDisconnect, onPacket: _onPacket); } await _backendChannel!.connect(); @@ -772,7 +782,9 @@ class FletBackend extends ChangeNotifier { } _onDisconnect() { - if (_disposed) { + // One reconnect at a time: a second disconnect report for the same + // connection must not start a second, competing reconnect chain. + if (_disposed || _reconnectScheduled) { return; } @@ -795,6 +807,7 @@ class FletBackend extends ChangeNotifier { _reconnect(nextReconnectDelayMs); debugPrint("Reconnect in $nextReconnectDelayMs milliseconds"); + _reconnectScheduled = true; Future.delayed(Duration(milliseconds: nextReconnectDelayMs)) .then((value) async { await connect(); From 3c17fcfbc231372d75c42f184f0ca224e6be3b96 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 10 Oct 2026 20:38:56 +0200 Subject: [PATCH 3/4] Link #6945 in the 1.1.0 changelog entries --- CHANGELOG.md | 2 +- packages/flet/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f654ec0b..eb8d9a6c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ **Compatibility:** Apps built with `flet build` and the macOS desktop client now require macOS 12 or later, and iOS apps require iOS 15 or later. Custom build templates keep building, because Flutter 3.47 raises the deployment targets during the build; apply the same `Podfile` and Xcode project changes to skip that step. See the [macOS 12 and iOS 15 minimum](/docs/updates/breaking-changes/v1-1-0/minimum-macos-12-ios-15) guide. -* Fix the desktop window flashing endlessly after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913)) by @ndonkoHenri. +* Fix the desktop window flashing endlessly after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913), [#6945](https://github.com/flet-dev/flet/pull/6945)) by @ndonkoHenri. ### Documentation diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index f84b05ddef..b584e58cd7 100644 --- a/packages/flet/CHANGELOG.md +++ b/packages/flet/CHANGELOG.md @@ -4,7 +4,7 @@ * **Breaking:** `parseTheme()` now builds themes from a seed with neutral surfaces by default (`#ffffff` / `#121212` and a neutral grey ramp for surface containers, `onSurface`, `outline` and inverse surfaces); the seed still drives accent colors. A theme's `surfaces: "tonal"` restores the seed-tinted Material 3 surfaces. Adds the `ThemeSurfaces` enum, `parseThemeSurfaces()` and `neutralizeSurfaces()` ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Import Material and Cupertino from the `material_ui` and `cupertino_ui` packages instead of `package:flutter/material.dart` and `package:flutter/cupertino.dart`. Helpers that take or return Material or Cupertino types, such as `parseColor()`, `parseTextStyle()` and `parseButtonStyle()`, now use those packages' types, and `flet.dart` no longer re-exports `Icons` and `CupertinoIcons`. Requires Flutter 3.47 or later. * Add `markdownStyleSheetFromTheme()`, which builds the default `MarkdownStyleSheet` from a `material_ui` theme. `MarkdownStyleSheet.fromTheme()` from `flutter_markdown_plus` only accepts the Flutter SDK's `ThemeData`. -* Fix `FletBackend` running two competing reconnects when a connection reports its disconnect twice, such as a socket reset after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913)) by @ndonkoHenri. +* Fix `FletBackend` running two competing reconnects when a connection reports its disconnect twice, such as a socket reset after a `flet run` hot reload ([#6913](https://github.com/flet-dev/flet/issues/6913), [#6945](https://github.com/flet-dev/flet/pull/6945)) by @ndonkoHenri. ## 1.0.4 From c2cf7be2a3bde125d530192ca0a6b11e7a5d4f59 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 10 Oct 2026 21:11:49 +0200 Subject: [PATCH 4/4] Add regression tests for single disconnects and one pending reconnect socket_disconnect_test.dart runs FletSocketBackendChannel and FletBackend against a local TCP server: a connection reset and an orderly close each report one disconnect, and the backend reconnects exactly once after a reset. backend_reconnect_test.dart uses an injected channel: a disconnect reported twice reconnects once, and a late disconnect from a replaced channel doesn't reconnect while the current channel still does. Each test fails without its part of the fix. --- .../transport/backend_reconnect_test.dart | 82 ++++++++++ .../transport/socket_disconnect_test.dart | 147 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 packages/flet/test/transport/backend_reconnect_test.dart create mode 100644 packages/flet/test/transport/socket_disconnect_test.dart diff --git a/packages/flet/test/transport/backend_reconnect_test.dart b/packages/flet/test/transport/backend_reconnect_test.dart new file mode 100644 index 0000000000..907cdd9e06 --- /dev/null +++ b/packages/flet/test/transport/backend_reconnect_test.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:flet/flet.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A transport that connects at once and lets the test report disconnects. +class _Channel implements FletBackendChannel { + final FletBackendChannelOnDisconnectCallback onDisconnect; + + _Channel(this.onDisconnect); + + @override + Future connect() async {} + + @override + bool get isLocalConnection => true; + + @override + int get defaultReconnectIntervalMs => 10; + + @override + void send(Uint8List packet) {} + + @override + void disconnect() {} +} + +/// Returns a backend whose every connection attempt adds its channel to +/// [channels]. +FletBackend _backend(List<_Channel> channels) => FletBackend( + pageUri: Uri.parse("mock"), + assetsDir: "", + extensions: [], + multiView: false, + channelBuilder: ({ + required FletBackendChannelOnPacketCallback onPacket, + required FletBackendChannelOnDisconnectCallback onDisconnect, + }) { + final channel = _Channel(onDisconnect); + channels.add(channel); + return channel; + }, + ); + +Future _settle() => Future.delayed(const Duration(milliseconds: 100)); + +void main() { + test('a disconnect reported twice by a channel reconnects once', () async { + final channels = <_Channel>[]; + final backend = _backend(channels); + await backend.connect(); + + channels[0].onDisconnect(); + channels[0].onDisconnect(); + await _settle(); + + // The first connection plus exactly one reconnect. + expect(channels, hasLength(2)); + + backend.dispose(); + }); + + test('a disconnect from a replaced channel does not reconnect', () async { + final channels = <_Channel>[]; + final backend = _backend(channels); + await backend.connect(); + channels[0].onDisconnect(); + await _settle(); + expect(channels, hasLength(2)); + + channels[0].onDisconnect(); + await _settle(); + expect(channels, hasLength(2)); + + // The current channel still reconnects. + channels[1].onDisconnect(); + await _settle(); + expect(channels, hasLength(3)); + + backend.dispose(); + }); +} diff --git a/packages/flet/test/transport/socket_disconnect_test.dart b/packages/flet/test/transport/socket_disconnect_test.dart new file mode 100644 index 0000000000..a4d80e18c1 --- /dev/null +++ b/packages/flet/test/transport/socket_disconnect_test.dart @@ -0,0 +1,147 @@ +@TestOn('vm') +library; + +import 'dart:async'; + +import 'package:flet/flet.dart'; +import 'package:flet/src/transport/flet_backend_channel_socket.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Aborts [socket] with a TCP reset (RST) instead of an orderly FIN. +/// +/// This is how Windows closes the connection of a terminated process that +/// still holds unread data, e.g. a Python app killed by a `flet run` hot +/// reload. `SO_LINGER` with a zero timeout makes the close abortive. +void _reset(Socket socket) { + final soLinger = Platform.isLinux ? 13 : 0x0080; + final linger = Platform.isWindows + ? (ByteData(4) + ..setUint16(0, 1, Endian.host) + ..setUint16(2, 0, Endian.host)) + : (ByteData(8) + ..setInt32(0, 1, Endian.host) + ..setInt32(4, 0, Endian.host)); + socket.setRawOption(RawSocketOption( + RawSocketOption.levelSocket, soLinger, linger.buffer.asUint8List())); + socket.destroy(); +} + +/// Collects `debugPrint` output while [body] runs. +Future> _capturePrints(Future Function() body) async { + final lines = []; + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) lines.add(message); + }; + try { + await body(); + } finally { + debugPrint = original; + } + return lines; +} + +void main() { + group('FletSocketBackendChannel', () { + late ServerSocket server; + + setUp(() async { + server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + }); + + tearDown(() async { + await server.close(); + }); + + /// Connects a channel, lets [close] end the accepted server-side socket + /// once the client is connected, and returns how many times the channel + /// reported a disconnect. + Future disconnectsAfter(void Function(Socket socket) close, + {List? prints}) async { + final accepted = Completer(); + server.listen(accepted.complete); + var disconnects = 0; + final channel = FletSocketBackendChannel( + address: "tcp://127.0.0.1:${server.port}", + onDisconnect: () => disconnects++, + onPacket: (_) {}); + final lines = await _capturePrints(() async { + await channel.connect(); + close(await accepted.future); + await Future.delayed(const Duration(milliseconds: 300)); + }); + prints?.addAll(lines); + channel.disconnect(); + return disconnects; + } + + test('reports a connection reset as a single disconnect', () async { + final prints = []; + final disconnects = await disconnectsAfter(_reset, prints: prints); + + // The reset reaches the channel as a socket error, not a plain close. + expect(prints, contains(startsWith("Error: "))); + expect(disconnects, 1); + }); + + test('reports an orderly close as a single disconnect', () async { + final disconnects = await disconnectsAfter((socket) => socket.destroy()); + + expect(disconnects, 1); + }); + }); + + group('FletBackend over a TCP socket', () { + test('reconnects once after a connection reset', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + var connections = 0; + Socket? active; + server.listen((socket) { + connections++; + final isFirst = connections == 1; + // Like FletSocketServer, a new client replaces the active one. + active?.destroy(); + active = socket; + socket.listen((_) { + // Reset the first connection once its client has registered. + if (isFirst && identical(active, socket)) { + active = null; + _reset(socket); + } + }, onError: (_) {}); + }); + + // A reset also fails the client socket's `done` future, which nothing + // listens to; keep those errors out of the test zone. + final otherErrors = []; + late FletBackend backend; + await _capturePrints(() async { + final connected = Completer(); + runZonedGuarded(() { + backend = FletBackend( + pageUri: Uri.parse("tcp://127.0.0.1:${server.port}"), + assetsDir: "", + extensions: [], + multiView: false, + reconnectIntervalMs: 50); + backend.connect().whenComplete(connected.complete); + }, (error, stack) { + if (error is! SocketException) otherErrors.add(error); + }); + await connected.future; + await Future.delayed(const Duration(seconds: 1)); + }); + + // The reset connection plus exactly one reconnect. Two overlapping + // reconnects keep replacing each other on the server, so the count + // would keep growing for as long as the client runs. + expect(connections, 2); + expect(otherErrors, isEmpty); + + await server.close(); + backend.dispose(); + active?.destroy(); + }); + }); +}