feat(app): parse json-stream reports into structured events

The native layer now emits one JSON object per report, so the service parses
each into a sealed Iperf3Event — start, interval, end, error — and publishes
those instead of opaque lines. Interval samples carry bytes, bits per second,
retransmits and per-stream socket/cwnd/rtt detail; the end event carries sent
and received totals and CPU utilisation. None of that was available while the
output was scraped text.

Anything unrecognised — non-JSON, malformed JSON, an event name a future
iperf3 adds — becomes an Iperf3LogLine rather than being dropped, so
unexpected output still reaches the user.

Failures arrive as an `error` event rather than a non-zero return from
iperf_run_client, so that event is what now marks a run as failed. The
result's rawOutput keeps the JSON lines verbatim, since that is what the
native layer actually produced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:35:10 +09:00
parent 598a2d99c9
commit 7d8d06e2e5
3 changed files with 375 additions and 26 deletions
+30 -26
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
@@ -10,9 +11,11 @@ import 'package:iperf3gui/domain/models/test_configuration.dart';
/// On Android, iperf3 is compiled into the app as a shared library (.so).
/// This avoids SELinux restrictions on executing ELF binaries from assets.
///
/// The service talks to the native layer over a MethodChannel. Native output
/// arrives line by line on [outputLines] while the test runs; [run] resolves
/// with the accumulated transcript once the native layer reports completion.
/// The service talks to the native layer over a MethodChannel. The test runs
/// in `--json-stream` mode, so each native report arrives as one JSON object;
/// they are parsed and published on [events] while the test runs. [run]
/// resolves once the native layer reports completion, carrying the raw JSON
/// lines as the result's output.
class Iperf3Service {
Iperf3Service() {
_channel.setMethodCallHandler(_handleNativeCall);
@@ -33,16 +36,15 @@ class Iperf3Service {
/// Exit code reported when the native layer never signalled completion.
static const int exitCodeTimeout = -3;
/// Lines emitted by the native layer during the current run.
final StreamController<String> _outputController =
StreamController<String>.broadcast();
/// Events parsed from the native layer during the current run.
final StreamController<Iperf3Event> _eventController =
StreamController<Iperf3Event>.broadcast();
/// Live output, one line per event. Use this to render progress; [run]'s
/// result carries the same text joined together.
Stream<String> get outputLines => _outputController.stream;
/// Live reports, one event per native report. Use this to render progress.
Stream<Iperf3Event> get events => _eventController.stream;
/// Transcript of the current (or most recent) run.
final List<String> _outputBuffer = [];
/// Raw JSON lines received during the current (or most recent) run.
final List<String> _rawLines = [];
/// Completer that resolves when the native iperf3 test finishes.
Completer<Iperf3Result>? _runCompleter;
@@ -71,7 +73,7 @@ class Iperf3Service {
/// Release the stream controller. Call from the owner's dispose().
Future<void> dispose() async {
_channel.setMethodCallHandler(null);
await _outputController.close();
await _eventController.close();
}
/// Run iperf3 with the given configuration.
@@ -88,7 +90,7 @@ class Iperf3Service {
_isRunning = true;
_sawError = false;
_stopRequested = false;
_outputBuffer.clear();
_rawLines.clear();
final args = config.toCliArgs();
debugPrint('Iperf3Service: running: ${args.join(" ")}');
@@ -121,9 +123,9 @@ class Iperf3Service {
debugPrint('Iperf3Service: error stopping after timeout: $e');
return null;
});
_emit('[ERROR] Timed out waiting for iperf3 to finish.');
_publish(const Iperf3Error('Timed out waiting for iperf3 to finish.'));
return Iperf3Result(
rawOutput: _transcript(),
rawOutput: _rawOutput(),
exitCode: exitCodeTimeout,
);
},
@@ -145,7 +147,12 @@ class Iperf3Service {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_emit(output);
_rawLines.add(output);
final event = Iperf3Event.parse(output);
// iperf3 reports a failed test as an `error` event rather than a
// non-zero return, so this is where most failures surface.
if (event is Iperf3Error) _sawError = true;
_publish(event);
}
case 'onError':
final error = call.arguments['error'] as String?;
@@ -153,7 +160,7 @@ class Iperf3Service {
// Not terminal on its own — iperf3 reports the failure reason and
// then completes, so wait for onComplete before resolving.
_sawError = true;
_emit('[ERROR] $error');
_publish(Iperf3Error(error));
}
case 'onComplete':
debugPrint('Iperf3Service: test completed');
@@ -161,7 +168,7 @@ class Iperf3Service {
if (completer != null && !completer.isCompleted) {
completer.complete(
Iperf3Result(
rawOutput: _transcript(),
rawOutput: _rawOutput(),
// A user-requested stop is a normal outcome, not a failure.
exitCode:
(_sawError && !_stopRequested) ? exitCodeNativeError : 0,
@@ -174,17 +181,14 @@ class Iperf3Service {
}
}
void _emit(String line) {
_outputBuffer.add(line);
if (!_outputController.isClosed) {
_outputController.add(line);
void _publish(Iperf3Event event) {
if (!_eventController.isClosed) {
_eventController.add(event);
}
debugPrint('Iperf3Service output: ${line.trimRight()}');
}
/// Native output already carries its own newlines; keep it verbatim except
/// for lines we synthesised ourselves.
String _transcript() => _outputBuffer
/// The raw JSON stream as received, one event per line.
String _rawOutput() => _rawLines
.map((line) => line.endsWith('\n') ? line : '$line\n')
.join();