diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c26b09916..e0eeca214e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,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), [#6945](https://github.com/flet-dev/flet/pull/6945)) by @ndonkoHenri. * Fix `flet run -r` restarting the app whenever it writes to its working directory or temp directory, which also left `flet run -r --web` pages blank ([#6944](https://github.com/flet-dev/flet/pull/6944)) by @ndonkoHenri. * Fix `flet run --ignore-dirs` not ignoring directories on Linux when the project is opened through a symlink ([#6944](https://github.com/flet-dev/flet/pull/6944)) by @ndonkoHenri. * Fix the loading logo of web apps served by `flet run --web` or `flet publish` showing off-center after reloading the page in a mobile browser ([#6051](https://github.com/flet-dev/flet/issues/6051), [#6943](https://github.com/flet-dev/flet/pull/6943)) by @ndonkoHenri. diff --git a/packages/flet/CHANGELOG.md b/packages/flet/CHANGELOG.md index b0260b715c..b584e58cd7 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), [#6945](https://github.com/flet-dev/flet/pull/6945)) 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(); 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. 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(); + }); + }); +}