186. Asynchrony: frame stream, timeouts, reconnection, isolate
A device sends bytes whenever it likes, does not always answer and disconnects at the worst possible moment. The app must still neither hang nor lie. This section is about the four things that make this possible: the stream, the timeout, the retry and the isolate.
Everything is tested by a run: the package _verify/dart186 and seven tests in
dart test, plus a parsing benchmark on 200000 frames.
Terms used in the documentation
Term Meaning Future, async/await a future value; waiting without blocking Stream, async* , yield a stream of values; a generator function broadcast stream a stream with several listeners backpressure holding back: the consumer is slower than the source timeout the time limit for waiting exponential backoff a retry with a growing pause reconnect connecting again isolate a separate thread with its own memory event loop the loop that dispatches events
186.1 A byte stream becomes a frame stream
A port, a socket and BLE deliver bytes in chunks of arbitrary length: a frame may arrive in parts, or two may arrive at once. The parser from section 185 already knows how to survive this — all that remains is to wrap it in a stream:
Stream<FrameEvent> frames(Stream<List<int>> bytes) async* {
final parser = FrameParser();
await for (final chunk in bytes) {
for (final b in chunk) {
final e = parser.feed(b);
if (e is! FrameNone) yield e;
}
}
}
Three lines give exactly what is needed: the state lives between chunks, and only events go out. Tested with both extreme cases: a frame cut into three pieces is assembled into one; two frames in one chunk give two events.
The byte source is not named: it may be a serial port, a socket or a BLE characteristic — more on them later in this part. Parsing does not depend on it — and so it is tested without the device.
186.2 An error is an event, not an exception
A broken CRC and garbage on the wire are not an exceptional situation but a normal
mode of operation: noise, a disconnect, a device restart. If you throw an
exception, the stream closes and the link has to be brought up again.
So the parser delivers FrameError as an event, and the stream lives
on:
| What we fed in | What we got |
|---|---|
| garbage, a frame with a broken CRC and a correct frame | one error event and one frame |
Exceptions are left for what really is an exception: the port disappeared, a permission was not granted, the device was unplugged.
186.3 Request-response: timeout and retry with a growing pause
A command without a timeout is a hung app. A timeout is set always:
final answer = _events.firstWhere((e) => e is FrameReady).timeout(timeout);
sent++;
_out(encodeFrame(payload));
The subscription is made before sending: otherwise a fast answer manages to arrive before anyone started waiting for it — a classic race that reproduces once in a hundred runs and takes a week to debug.
The second condition is that the answer is recognised by its number. firstWhere((e) => e is FrameReady) takes
the first frame that comes along: in the experiment the device answered every request, but after the timeout, and
the second attempt counted the answer to the first as its own — 2 sent, the answer from attempt 1. Any
telemetry frame will also "answer" any command. So every request carries
a counter, the device returns it in the answer, and the app waits for
firstWhere((e) => e is FrameReady && seqOf(e) == seq), where seqOf extracts the number from
the frame. And the event stream must be a broadcast stream — in Link it is made so by
events.asBroadcastStream(): with an ordinary stream the second firstWhere on a retry
throws "Bad state: Stream has already been listened to".
Retries go with a growing pause: 1, 2, 4 timeouts. In the test the device answers only on the third attempt:
| What we count | Value |
|---|---|
| requests sent | 3 |
| retries | 2 |
| answer received | yes |
The growing pause is not about politeness: if the device is rebooting, frequent retries just eat its input buffer and keep it from coming up. In ten seconds of silence an app with a constant pause of 20 ms sends 500 requests, and one that doubles up to a ceiling of 2 s sends 10.
186.4 Reconnection: link state as part of the protocol
A dropped link is not a program error but an event. A frame stream that
survives it looks like this: open the source, deliver frames, on
close or failure report offline, wait and open again —
the pause doubles up to a limit.
yield LinkState.connecting;
try {
final source = await open();
yield LinkState.online;
wait = pause; // связь была — пауза снова короткая
yield* frames(source);
yield LinkState.offline;
} catch (_) {
yield LinkState.offline;
}
In the test the first attempt to open fails and the second succeeds; out goes
exactly the sequence of states the screen expects:
connecting → offline → connecting → online → offline, with a
parsed frame between them.
Resetting the pause after a successful connection is mandatory, otherwise after one bad day the app will wait two seconds at a time even with a live device.
186.5 Isolate: parsing must not touch the screen
Dart is single-threaded: while your code is computing, nothing is drawn. How much this matters shows in a benchmark — 200000 frames, 2200000 bytes:
| What we count | Value |
|---|---|
| frames parsed | 200000 |
| parsing speed | more than 1000000 frames per second |
| ticks of a 1 ms timer during parsing in the main thread | no more than 2 |
| ticks during parsing in an isolate | more than 10 |
Parsing itself is fast — it is not the bottleneck. But even these tens of milliseconds are visible to the eye: while parsing runs in the main thread, a 1 ms timer almost never ticks, which means an animation frame is skipped. The same parsing in an isolate does not touch the main thread:
final int parsed = await Isolate.run(() => parseAll(bytes));
The web has no isolates: Isolate.run does not build there, and compute runs
the work in the same thread. There the batch is cut into pieces and control is given back
between them: await Future<void>.delayed(Duration.zero).
The rule is simple: what goes into an isolate is work done on a batch — parsing a log, thinning a chart, verifying a firmware update. The stream of live frames from the device is usually left in the main thread: passing data between isolates is not free either.
186.6 Pitfalls
No timeout. The device is silent — the app waits forever, the button does not respond, the user restarts.
Subscribing after sending. A fast answer is lost, and the request goes into a timeout exactly when everything works.
A retry counted someone else's answer. The answer is awaited by frame type, not by request number: a late answer to the first attempt goes to the second one.
Retries without a growing pause. A rebooting device gets a flood of requests and does not manage to come up.
The pause is not reset after success. The link has long been up, but the app still thinks for two seconds.
A parse error breaks the stream. One burst of noise — and the link is "lost" until the app is restarted.
Heavy parsing in the main thread. The screen freezes for tens of milliseconds; on a weak phone, for hundreds.
186.7 Summary
- A byte stream becomes an event stream in three lines, and the parser state lives between chunks: a frame in three pieces is assembled, two frames in one chunk give two events.
- A parse error is a
FrameErrorevent, not an exception: the stream survives noise and keeps working. - Every command has a timeout, the subscription is made before sending, retries go with a growing pause: an answer on the third attempt means 3 requests and 2 retries.
- Reconnection reports the state outside and resets the pause after success.
- Parsing 200000 frames runs faster than a million frames per second, but in the main thread it steals animation frames: the 1 ms timer does not tick; in an isolate it does.
Next: section 187 "A device screen in Flutter: widgets, state, commands, tests".