Merge branch 'refactor/json-stream-events'
CI / Analyze & Test (push) Failing after 34s

Replace the stdout pipe with iperf3's json_callback hook and consume the
resulting --json-stream events as structured data: interval samples with
retransmits and per-stream detail, and an end summary with sent/received
totals and CPU utilisation.

Deletes the pipe, the reader thread and the join-ordering and ownership
constraints that came with them.

Verified on an XQ-AS72 against a local iperf3 server: 4 streams at 889
Mbits/sec, matching the server's own 887 Mbits/sec receiver figure. Across
four consecutive runs thread count returned to baseline and fd count returned
exactly to baseline, with no ANR or native abort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:35:55 +09:00
10 changed files with 904 additions and 224 deletions
+67 -153
View File
@@ -5,16 +5,25 @@
* the Flutter app to run iperf3 tests natively inside the app process
* (no Process.start needed, no SELinux restrictions).
*
* OUTPUT NOTE:
* The test runs in --json-stream mode with a json_callback installed, so
* iperf3 hands each event ("start", "interval", "end", "error") straight to
* us as a JSON string and never touches test->outfile. That is what the hook
* exists for -- iperf.h calls it "allow user apps to receive the JSON
* strings, instead of writing them to the output file".
*
* An earlier version piped test->outfile to a reader thread instead. That
* required closing the pipe's write end before joining the reader (which
* blocks in fgets() until EOF) and single ownership of the shared state
* struct. Both were gotten wrong and deadlocked every test. Reading events
* from the callback removes the pipe, the reader thread, and that whole class
* of ordering bug.
*
* THREADING NOTE:
* runIperfLive() BLOCKS for the whole duration of the test. Kotlin must
* call it from a worker thread, never from the Flutter platform thread.
*
* PIPE / SHUTDOWN NOTE:
* iperf's output goes to a pipe drained by a reader thread that blocks in
* fgets(). The reader only sees EOF once the write end is closed, so the
* write end MUST be closed before pthread_join() — joining first deadlocks.
* The state struct is owned solely by the JNI caller, which frees it after
* joining the reader; the reader must never free it.
* json_callback fires on whichever thread iperf3 reports from, so it resolves
* a JNIEnv for the calling thread rather than reusing a stored one.
*
* SIGNAL HANDLING NOTE:
* iperf3's run() function (called from iperf_run_client()) uses
@@ -61,21 +70,22 @@ static pthread_mutex_t g_test_mutex = PTHREAD_MUTEX_INITIALIZER;
static volatile int g_stop = 0;
static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX";
/* Per-call state shared between the JNI caller and the reader thread.
* We store the callback object (global ref) and method IDs.
/* Per-call state, reachable from the json_callback.
* Callbacks are invoked via CallVoidMethod, NOT as function pointers. */
struct iperf_run_state {
jobject callback_ref; /* Global ref to the callback object */
jmethodID mid_output; /* Method ID for onOutput(String) */
jmethodID mid_error; /* Method ID for onError(String) */
jmethodID mid_complete; /* Method ID for onComplete() */
FILE *outfile; /* Pipe write end; NULL once closed */
int pipe_fd[2];
JavaVM *jvm;
pthread_t reader_thread;
int reader_started; /* Only join if pthread_create succeeded */
};
/* iperf3's json_callback signature carries no user-data pointer, so the
* current run's state has to be reachable from a global. Only one test runs
* at a time (enforced in runIperfLive), and this is published before
* iperf_run_client() and cleared after it returns. */
static struct iperf_run_state *g_current_state = NULL;
/* Helper: invoke the output callback via CallVoidMethod */
static void signal_output(JNIEnv *env, struct iperf_run_state *state,
const char *msg) {
@@ -108,50 +118,36 @@ static void signal_complete(JNIEnv *env, struct iperf_run_state *state) {
}
/* ------------------------------------------------------------------ */
/* Output reader thread — reads from iperf's output pipe and forwards */
/* each line to the Java callback via JNI. */
/* json_callback — receives one JSON event string per report from */
/* iperf3 and forwards it to Java verbatim. Parsing happens in Dart. */
/* */
/* Exits when the write end of the pipe is closed (EOF). Does NOT own */
/* `state`: the JNI caller frees it after joining this thread. */
/* Runs on iperf3's reporting thread. In the client that is the thread */
/* that called iperf_run_client() (already attached, since Kotlin's */
/* executor thread is a Java thread), but GetEnv is checked rather */
/* than assumed, with an attach as fallback. */
/* ------------------------------------------------------------------ */
static void *reader_thread_func(void *arg) {
struct iperf_run_state *state = (struct iperf_run_state *) arg;
JNIEnv *env;
int ret;
static void json_event_callback(struct iperf_test *test, char *json) {
struct iperf_run_state *state = g_current_state;
if (!state || !json) return;
/* Attach this native thread to the Java VM */
ret = (*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL);
if (ret != JNI_OK) {
LOGE("AttachCurrentThread failed: %d", ret);
/* Still drain the pipe so writers never block on a full pipe. */
char drain[1024];
while (read(state->pipe_fd[0], drain, sizeof(drain)) > 0) { }
close(state->pipe_fd[0]);
return NULL;
}
JNIEnv *env = NULL;
int attached = 0;
char buf[1024];
FILE *fp = fdopen(state->pipe_fd[0], "r");
if (fp) {
while (fgets(buf, sizeof(buf), fp) != NULL) {
/* Skip empty lines */
if (buf[0] == '\n' || buf[0] == '\0')
continue;
jstring line = (*env)->NewStringUTF(env, buf);
if (line != NULL) {
(*env)->CallVoidMethod(env, state->callback_ref,
state->mid_output, line);
(*env)->DeleteLocalRef(env, line);
}
if ((*state->jvm)->GetEnv(state->jvm, (void **) &env, JNI_VERSION_1_6)
!= JNI_OK) {
if ((*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL)
!= JNI_OK) {
LOGE("json_event_callback: could not reach a JNIEnv");
return;
}
fclose(fp); /* also closes pipe_fd[0] */
} else {
close(state->pipe_fd[0]);
attached = 1;
}
/* Detach this native thread */
(*state->jvm)->DetachCurrentThread(state->jvm);
return NULL;
signal_output(env, state, json);
if (attached) {
(*state->jvm)->DetachCurrentThread(state->jvm);
}
}
/* ------------------------------------------------------------------ */
@@ -198,30 +194,12 @@ static void free_argv(char **argv, int argc) {
free(argv);
}
/* ------------------------------------------------------------------ */
/* Close the pipe write end. Idempotent — both the normal and the */
/* longjmp path call it, and the second call must be a no-op. */
/* */
/* Closing this is what makes the reader thread see EOF and exit, so */
/* it must happen before any pthread_join(). */
/* ------------------------------------------------------------------ */
static void close_outfile(struct iperf_run_state *state) {
if (state && state->outfile) {
fflush(state->outfile);
fclose(state->outfile);
state->outfile = NULL;
}
}
/* Retire the global test pointer and free it, under the mutex. */
static void retire_test(void) {
pthread_mutex_lock(&g_test_mutex);
struct iperf_test *test = g_test;
g_test = NULL;
if (test) {
/* outfile is a closed FILE* by now — point it somewhere harmless so
* nothing in the teardown path can write to a dead stream. */
test->outfile = stdout;
iperf_free_test(test);
}
pthread_mutex_unlock(&g_test_mutex);
@@ -236,14 +214,8 @@ static void iperf_cleanup_and_signal(JNIEnv *env,
struct iperf_run_state *state,
char **argv, int argc,
int ret, int stopped) {
/* Close the write end FIRST: the reader thread is blocked in fgets()
* and only unblocks on EOF. Joining before this would deadlock. */
close_outfile(state);
if (state->reader_started) {
pthread_join(state->reader_thread, NULL);
state->reader_started = 0;
}
/* No more events can be delivered once the run is over. */
g_current_state = NULL;
/* Restore default signal handlers before g_sigend_jmp_buf goes stale. */
iperf_catch_sigend(SIG_DFL);
@@ -259,15 +231,11 @@ static void iperf_cleanup_and_signal(JNIEnv *env,
/* Signal result to Java */
if (user_stopped) {
signal_output(env, state, "[iPerf JNI] Test was stopped by user.\n");
signal_output(env, state, "{\"event\":\"jni_stopped\",\"data\":{}}");
} else if (ret < 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Test failed: %s\n",
iperf_strerror(i_errno));
snprintf(errbuf, sizeof(errbuf), "%s", iperf_strerror(i_errno));
signal_error(env, state, errbuf);
} else {
signal_output(env, state, "[iPerf JNI] Test completed successfully.\n");
}
/* Signal completion to Java */
@@ -325,20 +293,20 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
if (mid_complete) (*env)->CallVoidMethod(env, callback, mid_complete); \
} while (0)
/* Refuse to start a second concurrent test — the globals below hold
/* Refuse to start a second concurrent test — the globals above hold
* exactly one test's worth of state. */
pthread_mutex_lock(&g_test_mutex);
int busy = (g_test != NULL);
pthread_mutex_unlock(&g_test_mutex);
if (busy) {
FAIL_EARLY("[iPerf JNI] A test is already running.");
FAIL_EARLY("A test is already running.");
return;
}
/* Create the iperf test object */
struct iperf_test *test = iperf_new_test();
if (!test) {
FAIL_EARLY("[iPerf JNI] Failed to create iperf test");
FAIL_EARLY("Failed to create iperf test");
return;
}
@@ -352,7 +320,7 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
char **argv = build_argv(env, arguments, &total_argc);
if (!argv) {
retire_test();
FAIL_EARLY("[iPerf JNI] Failed to allocate argv");
FAIL_EARLY("Failed to allocate argv");
return;
}
@@ -365,19 +333,7 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
if (iperf_parse_arguments(test, total_argc, argv) < 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Argument parse error: %s", iperf_strerror(i_errno));
free_argv(argv, total_argc);
retire_test();
FAIL_EARLY(errbuf);
return;
}
/* Create a pipe to capture iperf's output */
int pipefd[2];
if (pipe(pipefd) != 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Failed to create output pipe: %s", strerror(errno));
"Argument parse error: %s", iperf_strerror(i_errno));
free_argv(argv, total_argc);
retire_test();
FAIL_EARLY(errbuf);
@@ -389,25 +345,11 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
calloc(1, sizeof(struct iperf_run_state));
if (!state) {
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
retire_test();
FAIL_EARLY("[iPerf JNI] Failed to allocate run state");
FAIL_EARLY("Failed to allocate run state");
return;
}
FILE *outfile = fdopen(pipefd[1], "w");
if (!outfile) {
free(state);
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
retire_test();
FAIL_EARLY("[iPerf JNI] Failed to open output stream");
return;
}
/* Get JavaVM for reader-thread attachment */
JavaVM *jvm;
(*env)->GetJavaVM(env, &jvm);
@@ -415,14 +357,16 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
state->mid_error = mid_error;
state->mid_complete = mid_complete;
state->callback_ref = (*env)->NewGlobalRef(env, callback);
state->outfile = outfile;
state->pipe_fd[0] = pipefd[0];
state->pipe_fd[1] = pipefd[1];
state->jvm = jvm;
/* Redirect iperf's output to our pipe */
test->outfile = state->outfile;
setvbuf(test->outfile, NULL, _IOLBF, 0); /* Line-buffered */
/* Deliver results as a stream of JSON events straight to
* json_event_callback, rather than as text through test->outfile.
* --json-stream sets both of these flags, so setting json_stream alone
* would leave iperf3 printing human-readable text as well. */
iperf_set_test_json_output(test, 1);
iperf_set_test_json_stream(test, 1);
iperf_set_test_json_callback(test, json_event_callback);
g_current_state = state;
/* Set temp directory for stream buffers (mkstemp in iperf_new_stream).
* On Android, /data/local/tmp is NOT writable from the app sandbox —
@@ -430,22 +374,6 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
* the strdup internally. */
iperf_set_test_template(test, g_tmp_template);
/* Spawn reader thread to forward iperf output to Java. Started before
* the test so early output is not lost, and before setjmp so the
* longjmp path can rely on reader_started being accurate. */
if (pthread_create(&state->reader_thread, NULL,
reader_thread_func, state) != 0) {
close_outfile(state);
close(pipefd[0]);
free_argv(argv, total_argc);
retire_test();
(*env)->DeleteGlobalRef(env, state->callback_ref);
free(state);
FAIL_EARLY("[iPerf JNI] Failed to start output reader thread");
return;
}
state->reader_started = 1;
#undef FAIL_EARLY
/*
@@ -462,8 +390,6 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */
iperf_catch_sigend(sigend_handler);
signal_output(env, state, "[iPerf JNI] Starting iPerf3 client...\n");
g_stop = 0;
int ret = iperf_run_client(test);
LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s",
@@ -478,8 +404,8 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
} else {
/*
* We got here via longjmp() from sigend_handler.
* iperf_cleanup_and_signal() closes the pipe, joins the reader and
* frees everything, so there is nothing to unwind by hand here.
* iperf_cleanup_and_signal() frees everything, so there is nothing
* to unwind by hand here.
*/
LOGI("Test interrupted by signal %d", g_signed_sig);
g_stop = 1;
@@ -515,19 +441,7 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
pthread_mutex_unlock(&g_test_mutex);
if (!requested) return;
/* Notify Java */
jclass callback_cls = (*env)->GetObjectClass(env, callback);
jmethodID mid_output = (*env)->GetMethodID(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
if (mid_output) {
jstring msg = (*env)->NewStringUTF(env,
"[iPerf JNI] Requested graceful stop.\n");
if (msg) {
(*env)->CallVoidMethod(env, callback, mid_output, msg);
(*env)->DeleteLocalRef(env, msg);
}
}
LOGI("Graceful stop requested");
}
/* ------------------------------------------------------------------ */
+47 -11
View File
@@ -42,8 +42,9 @@ User taps GO
→ Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once
→ JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments()
→ iperf_run_client(test) [blocks on the worker thread]
→ Reader thread pipes output → CallVoidMethod → main-thread hop → Dart onOutput
→ Dart streams each line to the UI via Iperf3Service.outputLines
→ iperf3 json_callback → one JSON event per report → CallVoidMethod
→ main-thread hop → Dart onOutput → Iperf3Event.parse()
→ Iperf3Service.events → view model: transcript + interval series + summary
→ On completion: Dart Completer resolves with Iperf3Result
```
@@ -61,16 +62,51 @@ to the main looper — stalled every callback until the test was already over.
### Callback Mechanism
The JNI bridge uses `CallVoidMethod` on stored `jmethodID`s to invoke the Kotlin `IperfCallback` interface, NOT function pointer casts. Direct function pointer casts caused SIGBUS crashes on ARM due to JNINativeInterface method ID layout differences.
### Output Streaming
iperf3 output is redirected via pipe to a background reader thread that attaches
to the Java VM via `AttachCurrentThread` and invokes callbacks via
`CallVoidMethod`. Dart re-publishes each line on `Iperf3Service.outputLines`, so
the transcript grows while the test runs.
### Output via json_callback, not a pipe
The test runs with `json_output` and `json_stream` set and a `json_callback`
installed, so iperf3 hands each report to the bridge as a JSON string and never
writes to `test->outfile`. `iperf.h` describes the hook as "allow user apps to
receive the JSON strings, instead of writing them to the output file" — this is
its intended use.
The reader blocks in `fgets()` and only unblocks on EOF, so the pipe's write end
must be closed **before** `pthread_join()` — joining first deadlocks the worker
thread. The JNI caller solely owns the run-state struct and frees it after the
join; the reader must never free it.
An earlier design piped `test->outfile` to a reader thread. That imposed two
constraints, both of which were gotten wrong and deadlocked every test: the
pipe's write end had to be closed *before* joining a reader blocked in
`fgets()`, and exactly one side could own the shared state struct. Reading from
the callback removes the pipe, the reader thread and both constraints. Don't
reintroduce it.
`json_callback` carries no user-data pointer, so the run state is reached
through a global published before `iperf_run_client()` and cleared afterwards;
only one test may run at a time, which `runIperfLive` enforces. The callback
fires on iperf3's reporting thread, so it resolves a `JNIEnv` for the calling
thread rather than reusing a stored one.
Dart parses each event into a sealed `Iperf3Event`; unrecognised output becomes
an `Iperf3LogLine` rather than being discarded. The UI rebuilds iperf3's text
layout from the events and keeps the interval series as data.
### Embedding a CLI in a long-lived process
iperf3's `main()` runs one test and exits, so anything it leaves behind is
reclaimed by the OS. Linked into an app process that runs tests repeatedly for
hours, every such leftover becomes this app's problem. What that costs in
practice:
- **Signal handlers are process-global.** `iperf_catch_sigend()` and
`signal(SIGPIPE, SIG_IGN)` change behaviour for the whole process, Flutter and
ART included. Handlers are restored to `SIG_DFL` when a test ends.
- **`g_sigend_jmp_buf` outlives its frame.** Harmless in a process about to
exit; a jump into a dead stack frame otherwise. Same fix as above.
- **Globals mean one test at a time.** `i_errno` and the bridge's own statics
hold a single test's worth of state, so concurrent runs are refused.
What iperf3 itself handles correctly, verified rather than assumed: per-stream
threads are joined (`iperf_client_api.c`), stream buffer fds are closed, and the
`mkstemp` temp file is `unlink`ed immediately after creation
(`iperf_api.c:4652`), so nothing accumulates in the cache dir. Measured across
four consecutive 4-stream runs on device, thread count returned to baseline
(33 → 40 mid-run → 34) and fd count returned exactly to baseline (175 → 183 →
175).
### Stop Handling
A stop shuts down the control socket, so `iperf_run_client()` returns -1 with
+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();
+238
View File
@@ -0,0 +1,238 @@
import 'dart:convert';
/// One report from iperf3's `--json-stream` output.
///
/// iperf3 emits `{"event": "<name>", "data": {...}}` per report; the events a
/// client run produces are `start`, `interval`, `end` and `error`. The bridge
/// adds `jni_stopped` when a run ended because the user stopped it.
sealed class Iperf3Event {
const Iperf3Event();
/// Parse one line of `--json-stream` output.
///
/// Returns an [Iperf3LogLine] for anything that is not a recognised JSON
/// event, so unexpected output is surfaced rather than dropped.
static Iperf3Event parse(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return const Iperf3LogLine('');
if (!trimmed.startsWith('{')) return Iperf3LogLine(trimmed);
final Object? decoded;
try {
decoded = jsonDecode(trimmed);
} on FormatException {
return Iperf3LogLine(trimmed);
}
if (decoded is! Map<String, dynamic>) return Iperf3LogLine(trimmed);
final event = decoded['event'];
final data = decoded['data'];
if (event is! String) return Iperf3LogLine(trimmed);
final map = data is Map<String, dynamic> ? data : const <String, dynamic>{};
return switch (event) {
'start' => Iperf3Start.fromJson(map),
'interval' => Iperf3Interval.fromJson(map),
'end' => Iperf3End.fromJson(map),
'error' => Iperf3Error(_stringOf(data) ?? 'unknown error'),
'jni_stopped' => const Iperf3Stopped(),
_ => Iperf3LogLine(trimmed),
};
}
static String? _stringOf(Object? value) {
if (value is String) return value;
if (value is Map<String, dynamic>) {
final error = value['error'];
if (error is String) return error;
}
return null;
}
}
/// Connection details, emitted once at the start of a test.
class Iperf3Start extends Iperf3Event {
final String? targetHost;
final int? targetPort;
final int? streamCount;
final int? durationSeconds;
final String? protocol;
const Iperf3Start({
this.targetHost,
this.targetPort,
this.streamCount,
this.durationSeconds,
this.protocol,
});
factory Iperf3Start.fromJson(Map<String, dynamic> data) {
final connecting = data['connecting_to'];
final testStart = data['test_start'];
return Iperf3Start(
targetHost: connecting is Map ? connecting['host'] as String? : null,
targetPort: connecting is Map ? _asInt(connecting['port']) : null,
streamCount: testStart is Map ? _asInt(testStart['num_streams']) : null,
durationSeconds: testStart is Map ? _asInt(testStart['duration']) : null,
protocol: testStart is Map ? testStart['protocol'] as String? : null,
);
}
}
/// One reporting interval, summed across all streams.
class Iperf3Interval extends Iperf3Event {
final double start;
final double end;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
final bool omitted;
/// Per-stream figures, when more than one stream is running.
final List<Iperf3StreamInterval> streams;
const Iperf3Interval({
required this.start,
required this.end,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
this.omitted = false,
this.streams = const [],
});
factory Iperf3Interval.fromJson(Map<String, dynamic> data) {
// Non-bidirectional runs use "sum"; bidir adds "sum_bidir_reverse".
final sum = data['sum'] ?? data['sum_bidir_reverse'];
final sumMap = sum is Map<String, dynamic> ? sum : const <String, dynamic>{};
final rawStreams = data['streams'];
return Iperf3Interval(
start: _asDouble(sumMap['start']) ?? 0,
end: _asDouble(sumMap['end']) ?? 0,
bytes: _asInt(sumMap['bytes']) ?? 0,
bitsPerSecond: _asDouble(sumMap['bits_per_second']) ?? 0,
retransmits: _asInt(sumMap['retransmits']),
omitted: sumMap['omitted'] == true,
streams: rawStreams is List
? rawStreams
.whereType<Map<String, dynamic>>()
.map(Iperf3StreamInterval.fromJson)
.toList(growable: false)
: const [],
);
}
}
/// One stream's contribution to an interval.
class Iperf3StreamInterval {
final int? socket;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
final int? sendCwnd;
final int? rtt;
const Iperf3StreamInterval({
this.socket,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
this.sendCwnd,
this.rtt,
});
factory Iperf3StreamInterval.fromJson(Map<String, dynamic> data) {
return Iperf3StreamInterval(
socket: _asInt(data['socket']),
bytes: _asInt(data['bytes']) ?? 0,
bitsPerSecond: _asDouble(data['bits_per_second']) ?? 0,
retransmits: _asInt(data['retransmits']),
sendCwnd: _asInt(data['snd_cwnd']),
rtt: _asInt(data['rtt']),
);
}
}
/// Totals for the whole test, emitted once at the end.
class Iperf3End extends Iperf3Event {
final Iperf3Total? sent;
final Iperf3Total? received;
final double? hostCpuPercent;
final double? remoteCpuPercent;
const Iperf3End({
this.sent,
this.received,
this.hostCpuPercent,
this.remoteCpuPercent,
});
factory Iperf3End.fromJson(Map<String, dynamic> data) {
final cpu = data['cpu_utilization_percent'];
return Iperf3End(
sent: Iperf3Total.fromJson(
data['sum_sent'] ?? data['sum_sent_bidir_reverse']),
received: Iperf3Total.fromJson(
data['sum_received'] ?? data['sum_received_bidir_reverse']),
hostCpuPercent: cpu is Map ? _asDouble(cpu['host_total']) : null,
remoteCpuPercent: cpu is Map ? _asDouble(cpu['remote_total']) : null,
);
}
}
/// A sent or received total from the `end` event.
class Iperf3Total {
final double seconds;
final int bytes;
final double bitsPerSecond;
final int? retransmits;
const Iperf3Total({
required this.seconds,
required this.bytes,
required this.bitsPerSecond,
this.retransmits,
});
static Iperf3Total? fromJson(Object? data) {
if (data is! Map<String, dynamic>) return null;
return Iperf3Total(
seconds: _asDouble(data['seconds']) ?? 0,
bytes: _asInt(data['bytes']) ?? 0,
bitsPerSecond: _asDouble(data['bits_per_second']) ?? 0,
retransmits: _asInt(data['retransmits']),
);
}
}
/// iperf3 reported an error for this test.
class Iperf3Error extends Iperf3Event {
final String message;
const Iperf3Error(this.message);
}
/// The run ended because the user stopped it.
class Iperf3Stopped extends Iperf3Event {
const Iperf3Stopped();
}
/// Output that was not a recognised JSON event.
class Iperf3LogLine extends Iperf3Event {
final String text;
const Iperf3LogLine(this.text);
}
int? _asInt(Object? value) => switch (value) {
int v => v,
double v => v.round(),
String v => int.tryParse(v),
_ => null,
};
double? _asDouble(Object? value) => switch (value) {
double v => v,
int v => v.toDouble(),
String v => double.tryParse(v),
_ => null,
};
@@ -0,0 +1,95 @@
import 'package:iperf3gui/domain/models/iperf3_event.dart';
/// Renders parsed iperf3 events as the kind of line iperf3 itself would print.
///
/// The app consumes `--json-stream`, so the familiar text layout has to be
/// rebuilt here. Keeping it in one pure function makes it testable and leaves
/// the structured events untouched for anything else that wants them.
String? formatIperf3Event(Iperf3Event event) {
return switch (event) {
Iperf3Start e => _formatStart(e),
Iperf3Interval e => _formatInterval(e),
Iperf3End e => _formatEnd(e),
Iperf3Error e => 'iperf3 error: ${e.message}',
Iperf3Stopped _ => 'Stopped by user.',
Iperf3LogLine e => e.text.isEmpty ? null : e.text,
};
}
String _formatStart(Iperf3Start e) {
final parts = <String>[];
if (e.targetHost != null) {
final port = e.targetPort != null ? ', port ${e.targetPort}' : '';
parts.add('Connecting to host ${e.targetHost}$port');
}
final detail = <String>[
if (e.protocol != null) e.protocol!,
if (e.streamCount != null)
'${e.streamCount} stream${e.streamCount == 1 ? '' : 's'}',
if (e.durationSeconds != null) '${e.durationSeconds}s',
];
if (detail.isNotEmpty) parts.add(detail.join(' · '));
return parts.isEmpty ? '' : parts.join('\n');
}
String _formatInterval(Iperf3Interval e) {
final window = '${_seconds(e.start)}-${_seconds(e.end)} sec';
final line = StringBuffer('[SUM] $window '
'${formatBytes(e.bytes)} ${formatBitrate(e.bitsPerSecond)}');
if (e.retransmits != null) {
line.write(' ${e.retransmits} retr');
}
if (e.omitted) line.write(' (omitted)');
return line.toString();
}
String _formatEnd(Iperf3End e) {
final lines = <String>['- ' * 20];
final sent = e.sent;
final received = e.received;
if (sent != null) {
lines.add('[SUM] 0.00-${_seconds(sent.seconds)} sec '
'${formatBytes(sent.bytes)} ${formatBitrate(sent.bitsPerSecond)}'
'${sent.retransmits != null ? ' ${sent.retransmits} retr' : ''}'
' sender');
}
if (received != null) {
lines.add('[SUM] 0.00-${_seconds(received.seconds)} sec '
'${formatBytes(received.bytes)} '
'${formatBitrate(received.bitsPerSecond)} receiver');
}
if (e.hostCpuPercent != null) {
lines.add('CPU: local ${e.hostCpuPercent!.toStringAsFixed(1)}%'
'${e.remoteCpuPercent != null ? ', remote ${e.remoteCpuPercent!.toStringAsFixed(1)}%' : ''}');
}
return lines.join('\n');
}
String _seconds(double value) => value.toStringAsFixed(2);
/// Byte totals the way iperf3 prints them — `93.8 MBytes`.
String formatBytes(int bytes) {
const units = ['Bytes', 'KBytes', 'MBytes', 'GBytes', 'TBytes'];
var value = bytes.toDouble();
var unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
return '${value.toStringAsFixed(precision)} ${units[unit]}';
}
/// Bitrates the way iperf3 prints them — `786 Mbits/sec`. Decimal units, since
/// network rates are powers of ten while transfer totals are powers of two.
String formatBitrate(double bitsPerSecond) {
const units = ['bits/sec', 'Kbits/sec', 'Mbits/sec', 'Gbits/sec'];
var value = bitsPerSecond;
var unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit++;
}
final precision = value >= 100 ? 0 : (value >= 10 ? 1 : 2);
return '${value.toStringAsFixed(precision)} ${units[unit]}';
}
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
/// Main test run screen.
@@ -193,6 +194,35 @@ class _TestRunContentState extends State<_TestRunContent> {
),
const SizedBox(height: 12),
// Live throughput figures, derived from the parsed interval series
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
final intervals = vm.intervals;
if (intervals.isEmpty) return const SizedBox.shrink();
final rates =
intervals.map((i) => i.bitsPerSecond).toList(growable: false);
final latest = rates.last;
final peak = rates.reduce((a, b) => a > b ? a : b);
final mean = rates.reduce((a, b) => a + b) / rates.length;
final summary = vm.summary?.sent;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_Stat(
label: summary != null ? 'total' : 'now',
value: formatBitrate(summary?.bitsPerSecond ?? latest),
),
_Stat(label: 'avg', value: formatBitrate(mean)),
_Stat(label: 'peak', value: formatBitrate(peak)),
_Stat(label: 'samples', value: '${rates.length}'),
],
),
);
},
),
// Live output
Expanded(
child: Container(
@@ -229,3 +259,32 @@ class _TestRunContentState extends State<_TestRunContent> {
);
}
}
/// One figure in the throughput row.
class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
Text(
value,
style: theme.textTheme.titleSmall
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
),
],
);
}
}
@@ -2,24 +2,33 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:iperf3gui/data/services/iperf3_service.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';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
/// State of the test run.
enum TestRunState { idle, running, completed, error }
/// 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.
/// Subscribes to [Iperf3Service.events] so the transcript and the interval
/// series both grow while the test runs, rather than appearing at the end.
class TestRunViewModel extends ChangeNotifier {
final Iperf3Service iperf3Service;
/// How often to notify listeners while output is arriving. iperf3 can report
/// several times a second across many streams, and each rebuild re-renders
/// the whole transcript — so repaints are coalesced instead of one per event.
static const Duration displayInterval = Duration(milliseconds: 250);
TestRunViewModel({required this.iperf3Service}) {
_outputSubscription = iperf3Service.outputLines.listen(_onOutputLine);
_eventSubscription = iperf3Service.events.listen(_onEvent);
}
StreamSubscription<String>? _outputSubscription;
StreamSubscription<Iperf3Event>? _eventSubscription;
Timer? _repaintTimer;
bool _dirty = false;
TestRunState _state = TestRunState.idle;
TestRunState get state => _state;
@@ -30,11 +39,26 @@ class TestRunViewModel extends ChangeNotifier {
TestConfiguration? _currentConfig;
TestConfiguration? get currentConfig => _currentConfig;
final StringBuffer _liveOutput = StringBuffer();
final StringBuffer _transcript = 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();
/// Human-readable output accumulated so far.
String get output => _transcript.toString();
final List<Iperf3Interval> _intervals = [];
/// Per-interval throughput samples, in arrival order. Structured, so a chart
/// can be driven from this without re-parsing text.
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
Iperf3Start? _start;
/// Connection details reported at the start of the run.
Iperf3Start? get start => _start;
Iperf3End? _summary;
/// Totals reported at the end of the run.
Iperf3End? get summary => _summary;
/// Whether a test is currently in flight.
bool get isRunning => _state == TestRunState.running;
@@ -47,35 +71,32 @@ class TestRunViewModel extends ChangeNotifier {
if (_state == TestRunState.running) return;
if (!config.isValid) {
_liveOutput
..clear()
..writeln('Invalid configuration: check the server address and port.');
_resetRunData();
_transcript
.writeln('Invalid configuration: check the server address and port.');
_state = TestRunState.error;
_result = null;
notifyListeners();
return;
}
_resetRunData();
_state = TestRunState.running;
_currentConfig = config;
_result = null;
_liveOutput.clear();
notifyListeners();
try {
final result = await iperf3Service.run(config);
_result = result;
// Prefer the streamed transcript — it is what the user watched build up.
// Fall back to the service's copy if nothing streamed through.
if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) {
_liveOutput.write(result.rawOutput);
if (_transcript.isEmpty && result.rawOutput.isNotEmpty) {
_transcript.write(result.rawOutput);
}
_state =
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
} catch (e) {
_liveOutput.writeln('Test failed: $e');
_transcript.writeln('Test failed: $e');
_state = TestRunState.error;
} finally {
_stopRepaintTimer();
notifyListeners();
}
}
@@ -88,21 +109,63 @@ class TestRunViewModel extends ChangeNotifier {
/// Reset to idle state.
void reset() {
_resetRunData();
_state = TestRunState.idle;
_result = null;
_currentConfig = null;
_liveOutput.clear();
notifyListeners();
}
void _onOutputLine(String line) {
_liveOutput.write(line.endsWith('\n') ? line : '$line\n');
notifyListeners();
void _resetRunData() {
_result = null;
_transcript.clear();
_intervals.clear();
_start = null;
_summary = null;
_dirty = false;
}
void _onEvent(Iperf3Event event) {
switch (event) {
case Iperf3Start e:
_start = e;
case Iperf3Interval e:
// Omitted intervals are warm-up (-O) and are not part of the series.
if (!e.omitted) _intervals.add(e);
case Iperf3End e:
_summary = e;
case Iperf3Error _:
case Iperf3Stopped _:
case Iperf3LogLine _:
break;
}
final line = formatIperf3Event(event);
if (line != null && line.isNotEmpty) {
_transcript.writeln(line);
}
_scheduleRepaint();
}
/// Coalesce repaints: mark dirty and let a timer flush at [displayInterval].
void _scheduleRepaint() {
_dirty = true;
_repaintTimer ??= Timer.periodic(displayInterval, (_) {
if (!_dirty) return;
_dirty = false;
notifyListeners();
});
}
void _stopRepaintTimer() {
_repaintTimer?.cancel();
_repaintTimer = null;
_dirty = false;
}
@override
void dispose() {
_outputSubscription?.cancel();
_stopRepaintTimer();
_eventSubscription?.cancel();
iperf3Service.dispose();
super.dispose();
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
void main() {
group('Iperf3Event.parse', () {
test('parses a start event', () {
const line = '{"event":"start","data":{'
'"connecting_to":{"host":"192.168.2.182","port":5201},'
'"test_start":{"protocol":"TCP","num_streams":4,"duration":10}}}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Start>());
final start = event as Iperf3Start;
expect(start.targetHost, equals('192.168.2.182'));
expect(start.targetPort, equals(5201));
expect(start.streamCount, equals(4));
expect(start.durationSeconds, equals(10));
expect(start.protocol, equals('TCP'));
});
test('parses an interval event with per-stream detail', () {
const line = '{"event":"interval","data":{'
'"streams":[{"socket":143,"bytes":49283072,'
'"bits_per_second":394264576.0,"retransmits":0,"snd_cwnd":2023424,'
'"rtt":3521}],'
'"sum":{"start":0.0,"end":1.000151,"seconds":1.000151,'
'"bytes":98566144,"bits_per_second":788410352.0,"retransmits":0,'
'"omitted":false,"sender":true}}}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Interval>());
final interval = event as Iperf3Interval;
expect(interval.bytes, equals(98566144));
expect(interval.bitsPerSecond, closeTo(788410352.0, 0.1));
expect(interval.retransmits, equals(0));
expect(interval.omitted, isFalse);
expect(interval.streams, hasLength(1));
expect(interval.streams.single.socket, equals(143));
expect(interval.streams.single.rtt, equals(3521));
});
test('marks omitted intervals', () {
const line = '{"event":"interval","data":{"streams":[],'
'"sum":{"start":0.0,"end":1.0,"bytes":1,"bits_per_second":8.0,'
'"omitted":true}}}';
final interval = Iperf3Event.parse(line) as Iperf3Interval;
expect(interval.omitted, isTrue);
});
test('parses an end event with sent and received totals', () {
const line = '{"event":"end","data":{'
'"sum_sent":{"seconds":10.0,"bytes":987654321,'
'"bits_per_second":790123456.0,"retransmits":7},'
'"sum_received":{"seconds":10.01,"bytes":987000000,'
'"bits_per_second":789000000.0},'
'"cpu_utilization_percent":{"host_total":12.5,"remote_total":3.25}}}';
final end = Iperf3Event.parse(line) as Iperf3End;
expect(end.sent!.bytes, equals(987654321));
expect(end.sent!.retransmits, equals(7));
expect(end.received!.bitsPerSecond, closeTo(789000000.0, 0.1));
expect(end.hostCpuPercent, closeTo(12.5, 0.01));
expect(end.remoteCpuPercent, closeTo(3.25, 0.01));
});
test('falls back to bidir-reverse keys when present', () {
const line = '{"event":"interval","data":{"streams":[],'
'"sum_bidir_reverse":{"start":0.0,"end":1.0,"bytes":42,'
'"bits_per_second":336.0}}}';
final interval = Iperf3Event.parse(line) as Iperf3Interval;
expect(interval.bytes, equals(42));
});
test('parses an error event', () {
const line = '{"event":"error","data":"unable to connect to server"}';
final event = Iperf3Event.parse(line);
expect(event, isA<Iperf3Error>());
expect((event as Iperf3Error).message,
equals('unable to connect to server'));
});
test('parses the bridge stop marker', () {
final event = Iperf3Event.parse('{"event":"jni_stopped","data":{}}');
expect(event, isA<Iperf3Stopped>());
});
test('surfaces non-JSON output as a log line rather than dropping it', () {
final event = Iperf3Event.parse('iperf3: something unexpected');
expect(event, isA<Iperf3LogLine>());
expect((event as Iperf3LogLine).text,
equals('iperf3: something unexpected'));
});
test('surfaces malformed JSON as a log line', () {
final event = Iperf3Event.parse('{"event":"interval",');
expect(event, isA<Iperf3LogLine>());
});
test('treats an unknown event name as a log line', () {
final event = Iperf3Event.parse('{"event":"future_thing","data":{}}');
expect(event, isA<Iperf3LogLine>());
});
test('tolerates a missing data object', () {
final event = Iperf3Event.parse('{"event":"interval"}');
expect(event, isA<Iperf3Interval>());
expect((event as Iperf3Interval).bytes, equals(0));
});
});
}
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
void main() {
group('formatBytes', () {
test('scales in binary units the way iperf3 prints totals', () {
expect(formatBytes(0), equals('0.00 Bytes'));
expect(formatBytes(1024), equals('1.00 KBytes'));
expect(formatBytes(98566144), equals('94.0 MBytes'));
expect(formatBytes(1073741824), equals('1.00 GBytes'));
});
});
group('formatBitrate', () {
test('scales in decimal units, as network rates are quoted', () {
expect(formatBitrate(0), equals('0.00 bits/sec'));
expect(formatBitrate(8000), equals('8.00 Kbits/sec'));
expect(formatBitrate(790000000), equals('790 Mbits/sec'));
expect(formatBitrate(2500000000), equals('2.50 Gbits/sec'));
});
});
group('formatIperf3Event', () {
test('renders a start event', () {
const event = Iperf3Start(
targetHost: '192.168.2.182',
targetPort: 5201,
protocol: 'TCP',
streamCount: 1,
durationSeconds: 10,
);
final text = formatIperf3Event(event)!;
expect(text, contains('Connecting to host 192.168.2.182, port 5201'));
expect(text, contains('TCP'));
expect(text, contains('1 stream'));
});
test('renders an interval with retransmits', () {
const event = Iperf3Interval(
start: 0,
end: 1,
bytes: 98566144,
bitsPerSecond: 788410352,
retransmits: 3,
);
final text = formatIperf3Event(event)!;
expect(text, contains('0.00-1.00 sec'));
expect(text, contains('94.0 MBytes'));
expect(text, contains('788 Mbits/sec'));
expect(text, contains('3 retr'));
});
test('marks an omitted interval', () {
const event = Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 8, omitted: true);
expect(formatIperf3Event(event), contains('omitted'));
});
test('renders sender and receiver lines from an end event', () {
const event = Iperf3End(
sent: Iperf3Total(
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000,
retransmits: 7),
received: Iperf3Total(
seconds: 10.01, bytes: 987000000, bitsPerSecond: 789000000),
hostCpuPercent: 12.5,
);
final text = formatIperf3Event(event)!;
expect(text, contains('sender'));
expect(text, contains('receiver'));
expect(text, contains('7 retr'));
expect(text, contains('CPU: local 12.5%'));
});
test('renders errors and stops', () {
expect(formatIperf3Event(const Iperf3Error('boom')),
equals('iperf3 error: boom'));
expect(formatIperf3Event(const Iperf3Stopped()),
equals('Stopped by user.'));
});
test('drops an empty log line', () {
expect(formatIperf3Event(const Iperf3LogLine('')), isNull);
});
});
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:iperf3gui/data/services/iperf3_service.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';
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
@@ -56,11 +57,16 @@ void main() {
expect(vm.result!.exitCode, equals(1));
});
test('streamed lines accumulate into output during the run', () async {
test('streamed events 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');
mock.emit(const Iperf3Start(targetHost: '192.168.1.1', targetPort: 5201));
mock.emit(const Iperf3Interval(
start: 0,
end: 1,
bytes: 11744051,
bitsPerSecond: 94100000,
));
// Let the broadcast stream deliver before the run resolves.
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: 'ignored', exitCode: 0);
@@ -69,12 +75,83 @@ void main() {
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('Connecting to host 192.168.1.1'));
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('intervals are collected as structured samples', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(2));
expect(vm.intervals[1].bitsPerSecond, equals(16000));
});
test('omitted warm-up intervals are excluded from the series', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1, bitsPerSecond: 1, omitted: true));
mock.emit(const Iperf3Interval(
start: 1, end: 2, bytes: 2000, bitsPerSecond: 16000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1));
expect(vm.intervals.single.bitsPerSecond, equals(16000));
});
test('end event is retained as the summary', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3End(
sent: Iperf3Total(
seconds: 10, bytes: 987654321, bitsPerSecond: 790000000),
));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.summary?.sent?.bitsPerSecond, equals(790000000));
expect(vm.output, contains('790 Mbits/sec'));
});
test('a new run clears the previous interval series', () async {
final mock = _MockIperf3Service();
mock.onRun = (_) async {
mock.emit(const Iperf3Interval(
start: 0, end: 1, bytes: 1000, bitsPerSecond: 8000));
await Future<void>.delayed(Duration.zero);
return const Iperf3Result(rawOutput: '', exitCode: 0);
};
final vm = TestRunViewModel(iperf3Service: mock);
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1));
await vm.runTest(const TestConfiguration(server: '192.168.1.1'));
expect(vm.intervals, hasLength(1), reason: 'not appended to the old run');
});
test('falls back to result output when nothing streamed', () async {
final mock = _MockIperf3Service(
onRun: (_) async =>
@@ -166,19 +243,19 @@ class _MockIperf3Service implements Iperf3Service {
Future<Iperf3Result> Function(TestConfiguration)? onRun;
final StreamController<String> _controller =
StreamController<String>.broadcast();
final StreamController<Iperf3Event> _controller =
StreamController<Iperf3Event>.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);
/// Push an event as if the native layer had reported it.
void emit(Iperf3Event event) => _controller.add(event);
@override
Stream<String> get outputLines => _controller.stream;
Stream<Iperf3Event> get events => _controller.stream;
@override
bool get isRunning => _isRunning;