3b01b0c4a6
_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>
188 lines
6.1 KiB
Dart
188 lines
6.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:iperf3gui/domain/models/iperf3_result.dart';
|
|
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
|
|
|
/// Service that runs iperf3 via JNI (native library).
|
|
///
|
|
/// 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.
|
|
class Iperf3Service {
|
|
Iperf3Service() {
|
|
_channel.setMethodCallHandler(_handleNativeCall);
|
|
}
|
|
|
|
static const MethodChannel _channel =
|
|
MethodChannel('com.novoyuuparosk.iperf3gui/iperf');
|
|
|
|
/// 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.
|
|
Completer<Iperf3Result>? _runCompleter;
|
|
|
|
/// Whether iperf3 is currently running.
|
|
bool _isRunning = false;
|
|
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;
|
|
try {
|
|
await _channel.invokeMethod('stopIperf');
|
|
} catch (e) {
|
|
debugPrint('Iperf3Service: error stopping: $e');
|
|
}
|
|
}
|
|
|
|
/// 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 the full output and an exit code.
|
|
Future<Iperf3Result> run(TestConfiguration config) async {
|
|
if (_isRunning) {
|
|
return const Iperf3Result(
|
|
rawOutput: 'Another test is already running.',
|
|
exitCode: exitCodeBusy,
|
|
);
|
|
}
|
|
|
|
_isRunning = true;
|
|
_sawError = false;
|
|
_outputBuffer.clear();
|
|
|
|
final args = config.toCliArgs();
|
|
debugPrint('Iperf3Service: running: ${args.join(" ")}');
|
|
|
|
final completer = Completer<Iperf3Result>();
|
|
_runCompleter = completer;
|
|
|
|
try {
|
|
await _channel.invokeMethod('runIperf', {'args': args});
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Iperf3Service exception: $e');
|
|
debugPrint('Iperf3Service stack: $stackTrace');
|
|
_finishRun();
|
|
return Iperf3Result(
|
|
rawOutput: 'Error starting iperf3: $e',
|
|
exitCode: exitCodeStartFailed,
|
|
);
|
|
}
|
|
|
|
// Wait for the native layer to signal completion. The grace period covers
|
|
// DNS resolution and the 30s native connect timeout.
|
|
try {
|
|
return await completer.future.timeout(
|
|
config.duration + const Duration(seconds: 45),
|
|
onTimeout: () {
|
|
debugPrint('Iperf3Service: timeout, stopping test');
|
|
// 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: _transcript(),
|
|
exitCode: exitCodeTimeout,
|
|
);
|
|
},
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Iperf3Service: error waiting for completion: $e');
|
|
return Iperf3Result(
|
|
rawOutput: 'Test failed: $e',
|
|
exitCode: exitCodeStartFailed,
|
|
);
|
|
} finally {
|
|
_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;
|
|
}
|
|
}
|