fix(app): stream native output and release run state on every exit path

_isRunning was never cleared on the timeout path, so a single timed-out run
locked the app out of starting another until it was restarted. It is now
cleared in a finally block covering all three exits.

Output was buffered inside the service and only handed over once the run
finished, so the UI showed nothing until the end despite the native layer
streaming line by line. The service republishes each line on outputLines and
the view model appends to a transcript as they arrive.

The channel handler was re-registered on every run and dereferenced
_runCompleter with `!`, which threw once a late callback arrived after the
field had been nulled. It is registered once in the constructor and reads the
completer through a local.

Also drops the duplicated argument list in favour of
TestConfiguration.toCliArgs(), so the flags the UI sends have one definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:03:22 +09:00
parent f410a7e0d3
commit 3b01b0c4a6
3 changed files with 300 additions and 84 deletions
+112 -67
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -11,13 +10,38 @@ 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 communicates with the native layer via a MethodChannel.
/// Output is streamed live to the Flutter UI through callbacks.
/// 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.
class Iperf3Service {
Iperf3Service() {
_channel.setMethodCallHandler(_handleNativeCall);
}
static const MethodChannel _channel =
MethodChannel('com.novoyuuparosk.iperf3gui/iperf');
/// Collect output lines during a test run.
/// Exit code reported when the native layer signalled an error.
static const int exitCodeNativeError = 1;
/// Exit code reported when another test is already in flight.
static const int exitCodeBusy = -2;
/// Exit code reported when the test could not be started at all.
static const int exitCodeStartFailed = -1;
/// 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();
/// 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;
/// Transcript of the current (or most recent) run.
final List<String> _outputBuffer = [];
/// Completer that resolves when the native iperf3 test finishes.
@@ -25,10 +49,11 @@ class Iperf3Service {
/// Whether iperf3 is currently running.
bool _isRunning = false;
/// Whether iperf3 is currently running.
bool get isRunning => _isRunning;
/// Whether the native layer reported an error during this run.
bool _sawError = false;
/// Cancel any running test.
Future<void> stop() async {
if (!_isRunning) return;
@@ -39,104 +64,124 @@ class Iperf3Service {
}
}
/// Release the stream controller. Call from the owner's dispose().
Future<void> dispose() async {
_channel.setMethodCallHandler(null);
await _outputController.close();
}
/// Run iperf3 with the given configuration.
///
/// Returns an [Iperf3Result] with stdout/stderr and exit code.
/// Returns an [Iperf3Result] with the full output and an exit code.
Future<Iperf3Result> run(TestConfiguration config) async {
if (_isRunning) {
return Iperf3Result(
return const Iperf3Result(
rawOutput: 'Another test is already running.',
exitCode: -2,
exitCode: exitCodeBusy,
);
}
_isRunning = true;
_sawError = false;
_outputBuffer.clear();
final args = <String>[
'-c', config.server,
'-p', config.port.toString(),
'-t', config.duration.inSeconds.toString(),
'-i', config.interval.toString(),
'-P', config.parallelStreams.toString(),
];
final args = config.toCliArgs();
debugPrint('Iperf3Service: running: ${args.join(" ")}');
// Set up the completer to await completion
_runCompleter = Completer<Iperf3Result>();
final completer = Completer<Iperf3Result>();
_runCompleter = completer;
// Listen for events from native layer
_channel.setMethodCallHandler((call) async {
if (!_runCompleter!.isCompleted) {
switch (call.method) {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_outputBuffer.add(output);
debugPrint('Iperf3Service output: ${output.trim()}');
}
break;
case 'onError':
final error = call.arguments['error'] as String?;
if (error != null) {
debugPrint('Iperf3Service error: $error');
_outputBuffer.add('[ERROR] $error');
// Don't complete yet — the test might still be running
}
break;
case 'onComplete':
debugPrint('Iperf3Service: test completed');
_isRunning = false;
_runCompleter!.complete(
Iperf3Result(
rawOutput: _outputBuffer.join(''),
exitCode: 0,
),
);
break;
}
}
});
// Start the test
try {
await _channel.invokeMethod('runIperf', {'args': args});
} catch (e, stackTrace) {
debugPrint('Iperf3Service exception: $e');
debugPrint('Iperf3Service stack: $stackTrace');
_isRunning = false;
if (!_runCompleter!.isCompleted) {
_runCompleter!.completeError(e);
}
_finishRun();
return Iperf3Result(
rawOutput: 'Error starting iperf3: $e',
exitCode: -1,
exitCode: exitCodeStartFailed,
);
}
// Wait for the native layer to signal completion (with a timeout)
// Wait for the native layer to signal completion. The grace period covers
// DNS resolution and the 30s native connect timeout.
try {
final result = await _runCompleter!.future.timeout(
config.duration + const Duration(seconds: 30),
return await completer.future.timeout(
config.duration + const Duration(seconds: 45),
onTimeout: () {
debugPrint('Iperf3Service: timeout, stopping test');
stop();
// Ask the native layer to wind down, then release local state so a
// stuck run can never lock the UI out of starting another test.
_channel.invokeMethod('stopIperf').catchError((Object e) {
debugPrint('Iperf3Service: error stopping after timeout: $e');
return null;
});
_emit('[ERROR] Timed out waiting for iperf3 to finish.');
return Iperf3Result(
rawOutput: 'Test timed out after ${config.duration.inSeconds}s',
exitCode: -3,
rawOutput: _transcript(),
exitCode: exitCodeTimeout,
);
},
);
return result;
} catch (e) {
debugPrint('Iperf3Service: error waiting for completion: $e');
return Iperf3Result(
rawOutput: 'Test failed: $e',
exitCode: -1,
exitCode: exitCodeStartFailed,
);
} finally {
_runCompleter = null;
_finishRun();
}
}
/// Handle a callback pushed up from the Kotlin/JNI layer.
Future<void> _handleNativeCall(MethodCall call) async {
switch (call.method) {
case 'onOutput':
final output = call.arguments['output'] as String?;
if (output != null) {
_emit(output);
}
case 'onError':
final error = call.arguments['error'] as String?;
if (error != null) {
// Not terminal on its own — iperf3 reports the failure reason and
// then completes, so wait for onComplete before resolving.
_sawError = true;
_emit('[ERROR] $error');
}
case 'onComplete':
debugPrint('Iperf3Service: test completed');
final completer = _runCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete(
Iperf3Result(
rawOutput: _transcript(),
exitCode: _sawError ? exitCodeNativeError : 0,
),
);
}
default:
debugPrint('Iperf3Service: unhandled native call ${call.method}');
}
}
void _emit(String line) {
_outputBuffer.add(line);
if (!_outputController.isClosed) {
_outputController.add(line);
}
debugPrint('Iperf3Service output: ${line.trimRight()}');
}
/// Native output already carries its own newlines; keep it verbatim except
/// for lines we synthesised ourselves.
String _transcript() => _outputBuffer
.map((line) => line.endsWith('\n') ? line : '$line\n')
.join();
void _finishRun() {
_isRunning = false;
_runCompleter = null;
}
}