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:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.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). /// On Android, iperf3 is compiled into the app as a shared library (.so).
/// This avoids SELinux restrictions on executing ELF binaries from assets. /// This avoids SELinux restrictions on executing ELF binaries from assets.
/// ///
/// The service communicates with the native layer via a MethodChannel. /// The service talks to the native layer over a MethodChannel. Native output
/// Output is streamed live to the Flutter UI through callbacks. /// arrives line by line on [outputLines] while the test runs; [run] resolves
/// with the accumulated transcript once the native layer reports completion.
class Iperf3Service { class Iperf3Service {
Iperf3Service() {
_channel.setMethodCallHandler(_handleNativeCall);
}
static const MethodChannel _channel = static const MethodChannel _channel =
MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); 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 = []; final List<String> _outputBuffer = [];
/// Completer that resolves when the native iperf3 test finishes. /// Completer that resolves when the native iperf3 test finishes.
@@ -25,10 +49,11 @@ class Iperf3Service {
/// Whether iperf3 is currently running. /// Whether iperf3 is currently running.
bool _isRunning = false; bool _isRunning = false;
/// Whether iperf3 is currently running.
bool get isRunning => _isRunning; bool get isRunning => _isRunning;
/// Whether the native layer reported an error during this run.
bool _sawError = false;
/// Cancel any running test. /// Cancel any running test.
Future<void> stop() async { Future<void> stop() async {
if (!_isRunning) return; 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. /// 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 { Future<Iperf3Result> run(TestConfiguration config) async {
if (_isRunning) { if (_isRunning) {
return Iperf3Result( return const Iperf3Result(
rawOutput: 'Another test is already running.', rawOutput: 'Another test is already running.',
exitCode: -2, exitCode: exitCodeBusy,
); );
} }
_isRunning = true; _isRunning = true;
_sawError = false;
_outputBuffer.clear(); _outputBuffer.clear();
final args = <String>[ final args = config.toCliArgs();
'-c', config.server,
'-p', config.port.toString(),
'-t', config.duration.inSeconds.toString(),
'-i', config.interval.toString(),
'-P', config.parallelStreams.toString(),
];
debugPrint('Iperf3Service: running: ${args.join(" ")}'); debugPrint('Iperf3Service: running: ${args.join(" ")}');
// Set up the completer to await completion final completer = Completer<Iperf3Result>();
_runCompleter = 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 { try {
await _channel.invokeMethod('runIperf', {'args': args}); await _channel.invokeMethod('runIperf', {'args': args});
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('Iperf3Service exception: $e'); debugPrint('Iperf3Service exception: $e');
debugPrint('Iperf3Service stack: $stackTrace'); debugPrint('Iperf3Service stack: $stackTrace');
_isRunning = false; _finishRun();
if (!_runCompleter!.isCompleted) {
_runCompleter!.completeError(e);
}
return Iperf3Result( return Iperf3Result(
rawOutput: 'Error starting iperf3: $e', 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 { try {
final result = await _runCompleter!.future.timeout( return await completer.future.timeout(
config.duration + const Duration(seconds: 30), config.duration + const Duration(seconds: 45),
onTimeout: () { onTimeout: () {
debugPrint('Iperf3Service: timeout, stopping test'); 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( return Iperf3Result(
rawOutput: 'Test timed out after ${config.duration.inSeconds}s', rawOutput: _transcript(),
exitCode: -3, exitCode: exitCodeTimeout,
); );
}, },
); );
return result;
} catch (e) { } catch (e) {
debugPrint('Iperf3Service: error waiting for completion: $e'); debugPrint('Iperf3Service: error waiting for completion: $e');
return Iperf3Result( return Iperf3Result(
rawOutput: 'Test failed: $e', rawOutput: 'Test failed: $e',
exitCode: -1, exitCode: exitCodeStartFailed,
); );
} finally { } 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;
}
} }
@@ -1,15 +1,26 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
/// State of the test run. /// State of the test run.
enum TestRunState { idle, running, completed, error } enum TestRunState { idle, running, completed, error }
/// ViewModel for managing test run state. /// ViewModel for managing test run state.
///
/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the
/// test runs rather than appearing all at once at the end.
class TestRunViewModel extends ChangeNotifier { class TestRunViewModel extends ChangeNotifier {
final Iperf3Service iperf3Service; final Iperf3Service iperf3Service;
TestRunViewModel({required this.iperf3Service}) {
_outputSubscription = iperf3Service.outputLines.listen(_onOutputLine);
}
StreamSubscription<String>? _outputSubscription;
TestRunState _state = TestRunState.idle; TestRunState _state = TestRunState.idle;
TestRunState get state => _state; TestRunState get state => _state;
@@ -19,12 +30,25 @@ class TestRunViewModel extends ChangeNotifier {
TestConfiguration? _currentConfig; TestConfiguration? _currentConfig;
TestConfiguration? get currentConfig => _currentConfig; TestConfiguration? get currentConfig => _currentConfig;
TestRunViewModel({required this.iperf3Service}); final StringBuffer _liveOutput = StringBuffer();
/// Output accumulated so far. Grows line by line during a run, and holds the
/// finished transcript once the test completes.
String get output => _liveOutput.toString();
/// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running;
/// Start a test with the given configuration. /// Start a test with the given configuration.
Future<void> runTest(TestConfiguration config) async { Future<void> runTest(TestConfiguration config) async {
if (_state == TestRunState.running) return;
if (!config.isValid) { if (!config.isValid) {
_liveOutput
..clear()
..writeln('Invalid configuration: check the server address and port.');
_state = TestRunState.error; _state = TestRunState.error;
_result = null;
notifyListeners(); notifyListeners();
return; return;
} }
@@ -32,26 +56,51 @@ class TestRunViewModel extends ChangeNotifier {
_state = TestRunState.running; _state = TestRunState.running;
_currentConfig = config; _currentConfig = config;
_result = null; _result = null;
_liveOutput.clear();
notifyListeners(); notifyListeners();
try { try {
final result = await iperf3Service.run(config); final result = await iperf3Service.run(config);
_result = result; _result = result;
_state = result.exitCode == 0 // Prefer the streamed transcript — it is what the user watched build up.
? TestRunState.completed // Fall back to the service's copy if nothing streamed through.
: TestRunState.error; if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) {
_liveOutput.write(result.rawOutput);
}
_state =
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
} catch (e) { } catch (e) {
_liveOutput.writeln('Test failed: $e');
_state = TestRunState.error; _state = TestRunState.error;
} finally { } finally {
notifyListeners(); notifyListeners();
} }
} }
/// Request that a running test stop early.
Future<void> stopTest() async {
if (_state != TestRunState.running) return;
await iperf3Service.stop();
}
/// Reset to idle state. /// Reset to idle state.
void reset() { void reset() {
_state = TestRunState.idle; _state = TestRunState.idle;
_result = null; _result = null;
_currentConfig = null; _currentConfig = null;
_liveOutput.clear();
notifyListeners(); notifyListeners();
} }
void _onOutputLine(String line) {
_liveOutput.write(line.endsWith('\n') ? line : '$line\n');
notifyListeners();
}
@override
void dispose() {
_outputSubscription?.cancel();
iperf3Service.dispose();
super.dispose();
}
} }
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_result.dart'; import 'package:iperf3gui/domain/models/iperf3_result.dart';
@@ -7,29 +9,26 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
void main() { void main() {
group('TestRunViewModel', () { group('TestRunViewModel', () {
test('starts in idle state', () { test('starts in idle state', () {
final vm = TestRunViewModel( final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
iperf3Service: _MockIperf3Service(),
);
expect(vm.state, equals(TestRunState.idle)); expect(vm.state, equals(TestRunState.idle));
expect(vm.result, isNull); expect(vm.result, isNull);
expect(vm.currentConfig, isNull); expect(vm.currentConfig, isNull);
expect(vm.output, isEmpty);
}); });
test('can be reset', () { test('can be reset', () {
final vm = TestRunViewModel( final vm = TestRunViewModel(iperf3Service: _MockIperf3Service());
iperf3Service: _MockIperf3Service(),
);
vm.reset(); vm.reset();
expect(vm.state, equals(TestRunState.idle)); expect(vm.state, equals(TestRunState.idle));
}); });
test('invalid config sets error state without running', () async { test('invalid config sets error state without running', () async {
final vm = TestRunViewModel( final mock = _MockIperf3Service();
iperf3Service: _MockIperf3Service(), final vm = TestRunViewModel(iperf3Service: mock);
);
const config = TestConfiguration(server: ''); const config = TestConfiguration(server: '');
await vm.runTest(config); await vm.runTest(config);
expect(vm.state, equals(TestRunState.error)); expect(vm.state, equals(TestRunState.error));
expect(mock.runCallCount, equals(0));
}); });
test('runTest with valid config calls service', () async { test('runTest with valid config calls service', () async {
@@ -45,17 +44,140 @@ void main() {
expect(vm.state, equals(TestRunState.completed)); expect(vm.state, equals(TestRunState.completed));
expect(vm.result, isNotNull); expect(vm.result, isNotNull);
}); });
test('non-zero exit code lands in error state', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'boom', exitCode: 1),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.state, equals(TestRunState.error));
expect(vm.result!.exitCode, equals(1));
});
test('streamed lines accumulate into output during the run', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit('Connecting to host 192.168.1.1\n');
mock.emit('[ 5] 0.00-1.00 sec 11.2 MBytes 94.1 Mbits/sec\n');
// Let the broadcast stream deliver before the run resolves.
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('Connecting to host'));
expect(vm.output, contains('94.1 Mbits/sec'));
// The streamed transcript wins over the service's own copy.
expect(vm.output, isNot(contains('ignored')));
});
test('falls back to result output when nothing streamed', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'summary only', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('summary only'));
});
test('output is cleared when a new run starts', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'first', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, contains('first'));
mock.onRun = (_) async =>
const Iperf3Result(rawOutput: 'second', exitCode: 0);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.output, isNot(contains('first')));
expect(vm.output, contains('second'));
});
test('stopTest forwards to the service only while running', () async {
final mock = _MockIperf3Service();
final vm = TestRunViewModel(iperf3Service: mock);
await vm.stopTest();
expect(mock.stopCallCount, equals(0), reason: 'idle: nothing to stop');
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.isRunning, isTrue);
await vm.stopTest();
expect(mock.stopCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'stopped', exitCode: 0));
await run;
});
test('a second runTest while running is ignored', () async {
final mock = _MockIperf3Service();
final gate = Completer<Iperf3Result>();
mock.onRun = (_) => gate.future;
final vm = TestRunViewModel(iperf3Service: mock);
final run = vm.runTest(const TestConfiguration(server: '192.168.1.1'));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(mock.runCallCount, equals(1));
gate.complete(const Iperf3Result(rawOutput: 'done', exitCode: 0));
await run;
});
}); });
} }
class _MockIperf3Service implements Iperf3Service { class _MockIperf3Service implements Iperf3Service {
_MockIperf3Service({this.onRun}); _MockIperf3Service({this.onRun});
final Future<Iperf3Result> Function(TestConfiguration)? onRun; Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<String> _controller =
StreamController<String>.broadcast();
int runCallCount = 0;
int stopCallCount = 0;
bool disposed = false;
bool _isRunning = false;
/// Push a line as if the native layer had emitted it.
void emit(String line) => _controller.add(line);
@override
Stream<String> get outputLines => _controller.stream;
@override
bool get isRunning => _isRunning;
@override @override
Future<Iperf3Result> run(TestConfiguration config) async { Future<Iperf3Result> run(TestConfiguration config) async {
if (onRun != null) return onRun!(config); runCallCount++;
return const Iperf3Result(rawOutput: '', exitCode: 0); _isRunning = true;
try {
if (onRun != null) return await onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0);
} finally {
_isRunning = false;
}
}
@override
Future<void> stop() async {
stopCallCount++;
}
@override
Future<void> dispose() async {
disposed = true;
await _controller.close();
} }
} }