fix: report a user-requested stop as stopped rather than failed

Stopping a test shuts down the control socket, so iperf_run_client() returns
-1 with "unable to send control message ... Broken pipe". That is the
expected consequence of the stop, not a fault, but the bridge read the
negative return as a failure — so pressing STOP left the UI showing
"Error (exit 1)" above a broken-pipe message.

The bridge now consults its own g_stop flag before classifying the outcome,
and the distinction travels up as Iperf3Result.stopped so the status line can
say "Stopped". A stop is reported with exit code 0: the run ended the way the
user asked it to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:03:59 +09:00
parent fb6ec3af37
commit 02a8ba9c18
7 changed files with 63 additions and 3 deletions
+7 -1
View File
@@ -251,8 +251,14 @@ static void iperf_cleanup_and_signal(JNIEnv *env,
retire_test(); retire_test();
free_argv(argv, argc); free_argv(argv, argc);
/* A user-requested stop shuts down the control socket, so
* iperf_run_client() returns -1 with "Broken pipe" — expected, not a
* failure. Check g_stop so that path is reported as a stop, not an error. */
int user_stopped = stopped || g_stop;
g_stop = 0;
/* Signal result to Java */ /* Signal result to Java */
if (stopped) { if (user_stopped) {
signal_output(env, state, "[iPerf JNI] Test was stopped by user.\n"); signal_output(env, state, "[iPerf JNI] Test was stopped by user.\n");
} else if (ret < 0) { } else if (ret < 0) {
char errbuf[256]; char errbuf[256];
+9 -1
View File
@@ -54,9 +54,13 @@ class Iperf3Service {
/// Whether the native layer reported an error during this run. /// Whether the native layer reported an error during this run.
bool _sawError = false; bool _sawError = false;
/// Whether [stop] was called for the current run.
bool _stopRequested = false;
/// Cancel any running test. /// Cancel any running test.
Future<void> stop() async { Future<void> stop() async {
if (!_isRunning) return; if (!_isRunning) return;
_stopRequested = true;
try { try {
await _channel.invokeMethod('stopIperf'); await _channel.invokeMethod('stopIperf');
} catch (e) { } catch (e) {
@@ -83,6 +87,7 @@ class Iperf3Service {
_isRunning = true; _isRunning = true;
_sawError = false; _sawError = false;
_stopRequested = false;
_outputBuffer.clear(); _outputBuffer.clear();
final args = config.toCliArgs(); final args = config.toCliArgs();
@@ -157,7 +162,10 @@ class Iperf3Service {
completer.complete( completer.complete(
Iperf3Result( Iperf3Result(
rawOutput: _transcript(), rawOutput: _transcript(),
exitCode: _sawError ? exitCodeNativeError : 0, // A user-requested stop is a normal outcome, not a failure.
exitCode:
(_sawError && !_stopRequested) ? exitCodeNativeError : 0,
stopped: _stopRequested,
), ),
); );
} }
+5
View File
@@ -3,8 +3,13 @@ class Iperf3Result {
final String rawOutput; final String rawOutput;
final int exitCode; final int exitCode;
/// Whether the run ended because the user asked it to stop, rather than
/// finishing its configured duration or failing.
final bool stopped;
const Iperf3Result({ const Iperf3Result({
required this.rawOutput, required this.rawOutput,
required this.exitCode, required this.exitCode,
this.stopped = false,
}); });
} }
@@ -166,7 +166,10 @@ class _TestRunContentState extends State<_TestRunContent> {
final (String label, Color color) = switch (vm.state) { final (String label, Color color) = switch (vm.state) {
TestRunState.idle => ('Ready', scheme.onSurfaceVariant), TestRunState.idle => ('Ready', scheme.onSurfaceVariant),
TestRunState.running => ('Running…', scheme.primary), TestRunState.running => ('Running…', scheme.primary),
TestRunState.completed => ('Test complete', scheme.primary), TestRunState.completed => (
vm.wasStopped ? 'Stopped' : 'Test complete',
vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary,
),
TestRunState.error => ( TestRunState.error => (
'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}', 'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}',
scheme.error, scheme.error,
@@ -39,6 +39,9 @@ class TestRunViewModel extends ChangeNotifier {
/// Whether a test is currently in flight. /// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running; bool get isRunning => _state == TestRunState.running;
/// Whether the most recent finished run ended because the user stopped it.
bool get wasStopped => _result?.stopped ?? false;
/// 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 (_state == TestRunState.running) return;
@@ -11,5 +11,15 @@ void main() {
expect(result.rawOutput, equals('Test complete')); expect(result.rawOutput, equals('Test complete'));
expect(result.exitCode, equals(0)); expect(result.exitCode, equals(0));
}); });
test('stopped defaults to false', () {
const result = Iperf3Result(rawOutput: 'x', exitCode: 0);
expect(result.stopped, isFalse);
});
test('stopped is preserved when set', () {
const result = Iperf3Result(rawOutput: 'x', exitCode: 0, stopped: true);
expect(result.stopped, isTrue);
});
}); });
} }
@@ -120,6 +120,31 @@ void main() {
await run; await run;
}); });
test('wasStopped reflects a user-requested stop', () async {
final mock = _MockIperf3Service(
onRun: (_) async => const Iperf3Result(
rawOutput: 'stopped early',
exitCode: 0,
stopped: true,
),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
// A user stop is a normal outcome: completed, not error.
expect(vm.state, equals(TestRunState.completed));
expect(vm.wasStopped, isTrue);
});
test('wasStopped is false for a run that finished on its own', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
const Iperf3Result(rawOutput: 'done', exitCode: 0),
);
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.wasStopped, isFalse);
});
test('a second runTest while running is ignored', () async { test('a second runTest while running is ignored', () async {
final mock = _MockIperf3Service(); final mock = _MockIperf3Service();
final gate = Completer<Iperf3Result>(); final gate = Completer<Iperf3Result>();