Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/flet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 16 additions & 3 deletions packages/flet/lib/src/flet_backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -294,21 +296,29 @@ class FletBackend extends ChangeNotifier {

Future<void> 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();
Comment thread
ndonkoHenri marked this conversation as resolved.
}

try {
final builder = _channelBuilder;
if (builder != null) {
// Embedder-supplied transport (e.g. serious_python's in-process FFI
// 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();
Expand Down Expand Up @@ -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;
}

Expand All @@ -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();
Expand Down
21 changes: 17 additions & 4 deletions packages/flet/lib/src/transport/flet_backend_channel_socket.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Comment thread
ndonkoHenri marked this conversation as resolved.
}

void _onBytes(Uint8List chunk) {
_inboundBuffer.add(chunk);
// Parse as many complete packets as the buffer currently holds.
Expand Down
82 changes: 82 additions & 0 deletions packages/flet/test/transport/backend_reconnect_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> _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();
});
}
147 changes: 147 additions & 0 deletions packages/flet/test/transport/socket_disconnect_test.dart
Original file line number Diff line number Diff line change
@@ -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<List<String>> _capturePrints(Future<void> Function() body) async {
final lines = <String>[];
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<int> disconnectsAfter(void Function(Socket socket) close,
{List<String>? prints}) async {
final accepted = Completer<Socket>();
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 = <String>[];
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 = <Object>[];
late FletBackend backend;
await _capturePrints(() async {
final connected = Completer<void>();
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();
});
});
}
Loading