Compare commits

...

8 Commits

Author SHA1 Message Date
mikkeli d448d5f885 Merge branch 'fix/iperf3-test-execution'
CI / Analyze & Test (push) Failing after 34s
Make iperf3 tests actually run on device: fix the end-of-test deadlock and
memory ownership in the JNI bridge, move the blocking test run off the
Flutter platform thread, stream output to the UI as it arrives, and report a
user-requested stop as a stop rather than a failure.

Verified end to end on an XQ-AS72 against a local iperf3 server: 790 Mbits/sec
over a full 10s run, mid-run stop, and back-to-back runs, with no ANR or
native abort in logcat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:08:38 +09:00
mikkeli 0dc747edef docs: describe the JNI architecture the app actually uses
The root README still documented the abandoned approach: iperf3 3.21 binaries
bundled in assets and run through a Process runner, and a lib/ layout that no
longer matched. It now covers the JNI design, the current feature set and how
to test against a local iperf3 server, and says plainly that the binaries
still sitting in assets/iperf3/ are undeclared in pubspec.yaml and therefore
never reach the APK.

docs/README.md gains the threading, pipe-shutdown and stop-handling
constraints that the fixes on this branch depend on, so the next change does
not have to rediscover why the join order and the worker thread matter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:59 +09:00
mikkeli 6f809913bd chore: drop leftovers from the pre-JNI binary-execution approach
iperf3_binary_path_provider.dart held no code, only a comment saying the
extract-and-exec approach it belonged to had been replaced. path_provider was
a dependency of that same approach and no longer has a single usage; removing
it regenerates the desktop plugin registrants.

Also drops an unused import in main.dart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:59 +09:00
mikkeli 02a8ba9c18 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>
2026-08-26 19:03:59 +09:00
mikkeli fb6ec3af37 feat(ui): show live output, add a stop button and duration/stream inputs
The screen could only start a 10s single-stream test and read the result
afterwards. It now renders the transcript as it arrives in a scrolling
monospace view that follows the newest line, exposes the duration and
parallel-stream settings TestConfiguration already carried, can stop a run in
progress, and copies the transcript to the clipboard.

The output field previously built a TextEditingController inside build(), so
every rebuild — one per output line now that output streams — discarded and
replaced it. Replaced with SelectableText, which needs no controller.

Seeds the inputs from TestConfiguration's own defaults rather than a second
hardcoded server address, so there is one default to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
mikkeli 3b01b0c4a6 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>
2026-08-26 19:03:22 +09:00
mikkeli f410a7e0d3 fix(android): run iperf3 tests off the Flutter platform thread
runIperfLive() blocks for the full duration of a test, but the MethodChannel
handler called it directly on the platform thread. That froze the UI for the
whole run, and because output reaches Dart by posting to the main looper, it
also meant no line could be delivered until the test was already over — the
live output the bridge streams had nowhere to go. Tests now run on a
dedicated single-thread executor and the channel call returns immediately.

MainThreadCallback's constructor properties were named onOutput/onError/
onComplete, the same names as the interface methods overriding them, so
`mainHandler.post { onOutput(line) }` resolved to the member function rather
than the lambda and re-posted itself instead of reaching the channel. Renamed
to emit* so the call cannot bind to the wrong declaration.

A failed System.loadLibrary now surfaces as a NATIVE_LIB_MISSING channel
error rather than leaving every later call to hang, and detaching from the
engine stops an in-flight test whose callbacks would fire into a dead
channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
mikkeli 2bba647a7b fix(jni): close the output pipe before joining the reader thread
Every test hung on the thread that started it. iperf_cleanup_and_signal()
called pthread_join() on the reader thread before closing the pipe's write
end, but the reader blocks in fgets() until EOF and EOF only arrives once
that write end is closed — two lines further down, after the join. Neither
side could ever make progress.

The reader also freed the shared state struct on exit while the caller went
on to read state->outfile and state->callback_ref and free it a second time,
so any path that got past the join corrupted the heap. Ownership now sits
solely with the JNI caller, which frees the struct after the join.

On the longjmp path outfile was fclose'd twice, once inline and once inside
the cleanup helper, so close_outfile() is idempotent and the hand-rolled
unwinding is gone. Signal handlers are restored to SIG_DFL once a test ends,
because leaving them armed pointed a later SIGTERM at a jmp_buf whose stack
frame no longer existed.

g_test is now guarded by a mutex and a second concurrent run is refused: a
stop request arrives on the platform thread while the test runs elsewhere,
which would otherwise race the worker retiring the pointer. Startup failures
report through onError/onComplete instead of returning silently, so Dart
resolves its completer rather than waiting out a timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:03:22 +09:00
16 changed files with 943 additions and 601 deletions
+45 -13
View File
@@ -1,13 +1,16 @@
# iperf3-gui # iperf3-gui
A Flutter-based Android GUI wrapper for iperf3, the popular network performance measurement tool. A Flutter-based Android GUI wrapper for iperf3, the popular network performance
measurement tool.
## Features ## Features
- Client-mode network throughput testing - Client-mode network throughput testing
- Simple UI: enter server address, port, and press GO - Configurable server, port, duration and parallel streams
- Raw iperf3 output display - Live iperf3 output, streamed line by line while the test runs
- Bundled iperf3 v3.21 binaries for arm64-v8a and armeabi-v7a - Stop a running test early
- Copy the transcript to the clipboard
- iperf3 3.19 compiled into the app as a native library — no external binary
## Architecture ## Architecture
@@ -15,33 +18,62 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance
lib/ lib/
├── domain/ ├── domain/
│ ├── models/ # TestConfiguration, Iperf3Result │ ├── models/ # TestConfiguration, Iperf3Result
│ └── services/ # Iperf3Service (Process runner) ├── data/
│ └── services/ # Iperf3Service (MethodChannel → JNI)
├── ui/ ├── ui/
│ └── features/ │ └── features/
│ └── test_run/ # TestRunScreen, TestRunViewModel │ └── test_run/ # TestRunScreen, TestRunViewModel
├── main.dart # App entry point └── main.dart # App entry point
└── pubspec.yaml # Dependencies: provider, path_provider
android/app/src/main/
├── kotlin/…/IperfRunner.kt # MethodChannel handler, runs tests off the platform thread
└── cpp/iperf_jni.c # JNI bridge into the iperf3 API
``` ```
See [docs/README.md](docs/README.md) for the data flow, the native API surface
and the design decisions behind them.
## Why JNI instead of executing a binary
Android SELinux in Enforcing mode blocks `execve()` from the `untrusted_app`
domain, so shipping an iperf3 ELF in assets and running it via `Process.start()`
fails with "Permission denied". iperf3 is therefore compiled as a shared library
(`libiperf3jni.so`) and called in-process over JNI.
`assets/iperf3/` still holds the pre-compiled binaries from that earlier
approach. They are not declared in `pubspec.yaml`, so they are not packaged into
the APK, and nothing in the app reads them.
## Building ## Building
Prerequisites: Prerequisites:
- Flutter 3.47+ - Flutter 3.47+
- Android SDK with platform android-36 - Android SDK with platform android-36
- JDK 17 - JDK 17
- NDK r28+ and CMake 3.22.1 (for the native build)
```bash ```bash
flutter create . --org novoyuuparosk --project-name iperf3gui
flutter pub get flutter pub get
flutter analyze flutter analyze
flutter test
flutter build apk --debug flutter build apk --debug
adb install -r build/app/outputs/flutter-apk/app-debug.apk
``` ```
### iperf3 Binaries ## Testing against a local server
Pre-compiled binaries are sourced from [davidBar-On/android-iperf3](https://github.com/davidBar-On/android-iperf3) ```bash
and bundled in `assets/iperf3/` (arm64-v8a and armeabi-v7a for Android 28+). iperf3 -s -p 5201 # on a machine on the same network
ip -4 addr show # note its address
```
Enter that address in the app, then press GO.
## Dependencies
- Dart: `provider`
- Native: iperf3 3.19 + cJSON, built by CMake for `armeabi-v7a` and `arm64-v8a`
## License ## License
MIT — bundled iperf3 v3.21 is BSD-3-Clause licensed. MIT — bundled iperf3 3.19 is BSD-3-Clause licensed.
+246 -159
View File
@@ -5,12 +5,25 @@
* the Flutter app to run iperf3 tests natively inside the app process * the Flutter app to run iperf3 tests natively inside the app process
* (no Process.start needed, no SELinux restrictions). * (no Process.start needed, no SELinux restrictions).
* *
* 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.
*
* SIGNAL HANDLING NOTE: * SIGNAL HANDLING NOTE:
* iperf3's run() function (called from iperf_run_client()) uses * iperf3's run() function (called from iperf_run_client()) uses
* setjmp/longjmp for graceful signal handling. The JNI bridge must * setjmp/longjmp for graceful signal handling. The JNI bridge must
* replicate this setup — otherwise iperf_catch_sigend() installs * replicate this setup — otherwise iperf_catch_sigend() installs
* handlers that longjmp to an unset buffer, causing undefined * handlers that longjmp to an unset buffer, causing undefined
* behaviour (typically a SIGSEGV or hang). * behaviour (typically a SIGSEGV or hang). Handlers are restored to
* SIG_DFL once the test finishes so a later signal cannot longjmp into
* a stack frame that no longer exists.
*/ */
#include <stdio.h> #include <stdio.h>
@@ -23,6 +36,7 @@
#include <android/log.h> #include <android/log.h>
#include <jni.h> #include <jni.h>
#include <pthread.h> #include <pthread.h>
#include <sys/socket.h>
#include "iperf.h" #include "iperf.h"
#include "iperf_api.h" #include "iperf_api.h"
@@ -33,31 +47,39 @@
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
/* Global state for controlling the iperf test */ /* ------------------------------------------------------------------ */
/* Global state for controlling the iperf test. */
/* */
/* g_test is written by the worker thread running the test and read by */
/* the platform thread in forceStopIperfTest()/isTestRunning(), so all */
/* access is serialised through g_test_mutex. The mutex is never held */
/* across iperf_run_client() — only around pointer publish/retire and */
/* the (short) stop request. */
/* ------------------------------------------------------------------ */
static struct iperf_test *g_test = NULL; static struct iperf_test *g_test = NULL;
static pthread_mutex_t g_test_mutex = PTHREAD_MUTEX_INITIALIZER;
static volatile int g_stop = 0; static volatile int g_stop = 0;
static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX"; static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX";
/* Per-call state to pass between JNI and the reader thread. /* Per-call state shared between the JNI caller and the reader thread.
* We store the callback object (global ref) and method IDs. * We store the callback object (global ref) and method IDs.
* Callbacks are invoked via CallVoidMethod, NOT as function pointers. */ * Callbacks are invoked via CallVoidMethod, NOT as function pointers. */
struct iperf_run_state { struct iperf_run_state {
JNIEnv *env;
jobject callback_ref; /* Global ref to the callback object */ jobject callback_ref; /* Global ref to the callback object */
jmethodID mid_output; /* Method ID for onOutput(String) */ jmethodID mid_output; /* Method ID for onOutput(String) */
jmethodID mid_error; /* Method ID for onError(String) */ jmethodID mid_error; /* Method ID for onError(String) */
jmethodID mid_complete; /* Method ID for onComplete() */ jmethodID mid_complete; /* Method ID for onComplete() */
FILE *outfile; FILE *outfile; /* Pipe write end; NULL once closed */
int pipe_fd[2]; int pipe_fd[2];
JavaVM *jvm; JavaVM *jvm;
pthread_t reader_thread; pthread_t reader_thread;
int reader_started; /* Only join if pthread_create succeeded */
}; };
/* Helper: invoke the output callback via CallVoidMethod */ /* Helper: invoke the output callback via CallVoidMethod */
static void signal_output(struct iperf_run_state *state, const char *msg) { static void signal_output(JNIEnv *env, struct iperf_run_state *state,
if (!state || !msg || !msg[0]) return; const char *msg) {
JNIEnv *env = state->env; if (!env || !state || !msg || !msg[0] || !state->mid_output) return;
if (!env || !state->mid_output) return;
jstring jline = (*env)->NewStringUTF(env, msg); jstring jline = (*env)->NewStringUTF(env, msg);
if (jline) { if (jline) {
@@ -67,10 +89,9 @@ static void signal_output(struct iperf_run_state *state, const char *msg) {
} }
/* Helper: invoke the error callback via CallVoidMethod */ /* Helper: invoke the error callback via CallVoidMethod */
static void signal_error(struct iperf_run_state *state, const char *msg) { static void signal_error(JNIEnv *env, struct iperf_run_state *state,
if (!state || !msg) return; const char *msg) {
JNIEnv *env = state->env; if (!env || !state || !msg || !state->mid_error) return;
if (!env || !state->mid_error) return;
jstring jmsg = (*env)->NewStringUTF(env, msg); jstring jmsg = (*env)->NewStringUTF(env, msg);
if (jmsg) { if (jmsg) {
@@ -80,10 +101,8 @@ static void signal_error(struct iperf_run_state *state, const char *msg) {
} }
/* Helper: invoke the complete callback via CallVoidMethod */ /* Helper: invoke the complete callback via CallVoidMethod */
static void signal_complete(struct iperf_run_state *state) { static void signal_complete(JNIEnv *env, struct iperf_run_state *state) {
if (!state || !state->mid_complete) return; if (!env || !state || !state->mid_complete) return;
JNIEnv *env = state->env;
if (!env) return;
(*env)->CallVoidMethod(env, state->callback_ref, state->mid_complete); (*env)->CallVoidMethod(env, state->callback_ref, state->mid_complete);
} }
@@ -91,6 +110,9 @@ static void signal_complete(struct iperf_run_state *state) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Output reader thread — reads from iperf's output pipe and forwards */ /* Output reader thread — reads from iperf's output pipe and forwards */
/* each line to the Java callback via JNI. */ /* each line to the Java callback via JNI. */
/* */
/* Exits when the write end of the pipe is closed (EOF). Does NOT own */
/* `state`: the JNI caller frees it after joining this thread. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static void *reader_thread_func(void *arg) { static void *reader_thread_func(void *arg) {
struct iperf_run_state *state = (struct iperf_run_state *) arg; struct iperf_run_state *state = (struct iperf_run_state *) arg;
@@ -101,6 +123,10 @@ static void *reader_thread_func(void *arg) {
ret = (*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL); ret = (*state->jvm)->AttachCurrentThread(state->jvm, &env, NULL);
if (ret != JNI_OK) { if (ret != JNI_OK) {
LOGE("AttachCurrentThread failed: %d", ret); 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; return NULL;
} }
@@ -118,49 +144,52 @@ static void *reader_thread_func(void *arg) {
(*env)->DeleteLocalRef(env, line); (*env)->DeleteLocalRef(env, line);
} }
} }
fclose(fp); fclose(fp); /* also closes pipe_fd[0] */
} else {
close(state->pipe_fd[0]);
} }
/* Detach this native thread */ /* Detach this native thread */
(*state->jvm)->DetachCurrentThread(state->jvm); (*state->jvm)->DetachCurrentThread(state->jvm);
/* Free the state struct — caller owns it */
free(state);
return NULL; return NULL;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Convert a Java String[] into a native char** argv array. */ /* Build a native argv from a Java String[], prepending "iperf3". */
/* */
/* iperf_parse_arguments() expects argv[0] to be the program name; */
/* passing the Dart args directly would make argv[0] "-c", which */
/* iperf3 misreads as the program name rather than the client flag. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static char **java_string_array_to_argv(JNIEnv *env, jobjectArray str_array, static char **build_argv(JNIEnv *env, jobjectArray str_array, int *out_argc) {
int *out_argc) { int user_argc = (*env)->GetArrayLength(env, str_array);
int argc = (*env)->GetArrayLength(env, str_array); if (user_argc < 0) user_argc = 0;
if (argc <= 0) {
int argc = user_argc + 1; /* +1 for argv[0] */
char **argv = malloc((argc + 1) * sizeof(char *));
if (!argv) {
*out_argc = 0; *out_argc = 0;
return NULL; return NULL;
} }
char **argv = malloc((argc + 1) * sizeof(char *)); argv[0] = strdup("iperf3");
if (!argv) return NULL; for (int i = 0; i < user_argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, str_array, i);
for (int i = 0; i < argc; i++) { const char *cstr = jstr ? (*env)->GetStringUTFChars(env, jstr, NULL) : NULL;
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env,
str_array, i);
const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
if (cstr) { if (cstr) {
argv[i] = strdup(cstr); argv[i + 1] = strdup(cstr);
(*env)->ReleaseStringUTFChars(env, jstr, cstr); (*env)->ReleaseStringUTFChars(env, jstr, cstr);
} else { } else {
argv[i] = strdup(""); argv[i + 1] = strdup("");
} }
(*env)->DeleteLocalRef(env, jstr); if (jstr) (*env)->DeleteLocalRef(env, jstr);
} }
argv[argc] = NULL; /* NULL-terminated for iperf */ argv[argc] = NULL; /* NULL-terminated for iperf */
*out_argc = argc; *out_argc = argc;
return argv; return argv;
} }
/* Free an argv array returned by java_string_array_to_argv() */ /* Free an argv array returned by build_argv() */
static void free_argv(char **argv, int argc) { static void free_argv(char **argv, int argc) {
if (!argv) return; if (!argv) return;
for (int i = 0; i < argc; i++) { for (int i = 0; i < argc; i++) {
@@ -170,42 +199,79 @@ static void free_argv(char **argv, int argc) {
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Internal: clean up after iperf_run_client returns (normal path). */ /* Close the pipe write end. Idempotent — both the normal and the */
/* Must be called only on the normal return path (NOT after longjmp). */ /* 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);
}
/* ------------------------------------------------------------------ */
/* Internal: clean up after iperf_run_client returns and report the */
/* outcome to Java. Called from both the normal and the longjmp path. */
/* Frees `state`; do not touch it afterwards. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
static void iperf_cleanup_and_signal(JNIEnv *env, static void iperf_cleanup_and_signal(JNIEnv *env,
struct iperf_run_state *state, struct iperf_run_state *state,
char **argv, int argc, char **argv, int argc,
int ret, int stopped) { int ret, int stopped) {
/* Wait for reader thread to finish */ /* Close the write end FIRST: the reader thread is blocked in fgets()
pthread_join(state->reader_thread, NULL); * and only unblocks on EOF. Joining before this would deadlock. */
close_outfile(state);
/* Flush and close output */ if (state->reader_started) {
fflush(state->outfile); pthread_join(state->reader_thread, NULL);
fclose(state->outfile); state->reader_started = 0;
}
/* Clean up iperf */ /* Restore default signal handlers before g_sigend_jmp_buf goes stale. */
iperf_free_test(g_test); iperf_catch_sigend(SIG_DFL);
g_test = NULL;
/* Free argv */ 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(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];
snprintf(errbuf, sizeof(errbuf), snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Test failed: %s\n", "[iPerf JNI] Test failed: %s\n",
iperf_strerror(i_errno)); iperf_strerror(i_errno));
signal_error(state, errbuf); signal_error(env, state, errbuf);
} else { } else {
signal_output(state, "[iPerf JNI] Test completed successfully.\n"); signal_output(env, state, "[iPerf JNI] Test completed successfully.\n");
} }
/* Signal completion to Java */ /* Signal completion to Java */
signal_complete(state); signal_complete(env, state);
/* Free global ref and state struct */ /* Free global ref and state struct */
(*env)->DeleteGlobalRef(env, state->callback_ref); (*env)->DeleteGlobalRef(env, state->callback_ref);
@@ -228,80 +294,93 @@ static void sigend_handler(int sig) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* JNI: Start an iperf3 test with the given arguments. */ /* JNI: Start an iperf3 test with the given arguments. */
/* Signature: void runIperfLive(String[] args, IperfCallback cb) */ /* Signature: void runIperfLive(String[] args, IperfCallback cb) */
/* */
/* BLOCKS until the test finishes — call from a worker thread. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
JNIEnv *env, jobject thiz, JNIEnv *env, jobject thiz,
jobjectArray arguments, jobject callback) { jobjectArray arguments, jobject callback) {
/* Resolve callback method IDs up front so failures can be reported. */
jclass callback_cls = (*env)->GetObjectClass(env, callback);
jmethodID mid_output = (*env)->GetMethodID(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
jmethodID mid_error = (*env)->GetMethodID(
env, callback_cls, "onError", "(Ljava/lang/String;)V");
jmethodID mid_complete = (*env)->GetMethodID(
env, callback_cls, "onComplete", "()V");
/* Report a startup failure directly on the callback object and finish,
* so Dart's completer always resolves instead of waiting for a timeout. */
#define FAIL_EARLY(msg) do { \
LOGE("%s", msg); \
if (mid_error) { \
jstring jm = (*env)->NewStringUTF(env, msg); \
if (jm) { \
(*env)->CallVoidMethod(env, callback, mid_error, jm); \
(*env)->DeleteLocalRef(env, jm); \
} \
} \
if (mid_complete) (*env)->CallVoidMethod(env, callback, mid_complete); \
} while (0)
/* Refuse to start a second concurrent test — the globals below 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.");
return;
}
/* Create the iperf test object */ /* Create the iperf test object */
struct iperf_test *test = iperf_new_test(); struct iperf_test *test = iperf_new_test();
if (!test) { if (!test) {
const char *err = "Failed to create iperf test"; FAIL_EARLY("[iPerf JNI] Failed to create iperf test");
LOGE("%s", err);
return; return;
} }
iperf_defaults(test); iperf_defaults(test);
g_test = test;
/* pthread_mutex_lock(&g_test_mutex);
* Convert Java String[] -> char** argv, prepending "iperf3" as argv[0]. g_test = test;
* iperf_parse_arguments() expects argv[0] to be the program name; pthread_mutex_unlock(&g_test_mutex);
* passing args directly from Dart means argv[0] would be "-c",
* which iperf3 misinterprets as the program name, not the client flag. int total_argc = 0;
*/ char **argv = build_argv(env, arguments, &total_argc);
int dart_argc = (*env)->GetArrayLength(env, arguments);
int total_argc = dart_argc + 1; /* +1 for "iperf3" */
char **argv = malloc((total_argc + 1) * sizeof(char *));
if (!argv) { if (!argv) {
const char *err = "Failed to allocate argv"; retire_test();
LOGE("%s", err); FAIL_EARLY("[iPerf JNI] Failed to allocate argv");
iperf_free_test(test);
g_test = NULL;
return; return;
} }
argv[0] = strdup("iperf3");
for (int i = 0; i < dart_argc; i++) {
jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, arguments, i);
const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
if (cstr) {
argv[i + 1] = strdup(cstr);
(*env)->ReleaseStringUTFChars(env, jstr, cstr);
} else {
argv[i + 1] = strdup("");
}
(*env)->DeleteLocalRef(env, jstr);
}
argv[total_argc] = NULL; /* NULL-terminated for iperf */
/* Set a reasonable connect timeout (30s) to avoid hanging on DNS resolution. /* Set a reasonable connect timeout (30s) to avoid hanging on DNS
* Default iperf3 connect timeout is 120s which is far too long. */ * resolution. Default iperf3 connect timeout is 120s, far too long for a
* phone UI. Set before parsing so an explicit --connect-timeout wins. */
test->settings->connect_timeout = 30000; test->settings->connect_timeout = 30000;
/* Parse arguments */ /* Parse arguments */
if (iperf_parse_arguments(test, total_argc, argv) < 0) { if (iperf_parse_arguments(test, total_argc, argv) < 0) {
char errbuf[256]; char errbuf[256];
snprintf(errbuf, sizeof(errbuf), snprintf(errbuf, sizeof(errbuf),
"Argument parse error: %s", iperf_strerror(i_errno)); "[iPerf JNI] Argument parse error: %s", iperf_strerror(i_errno));
LOGE("Parse error: %s", errbuf);
free_argv(argv, total_argc); free_argv(argv, total_argc);
iperf_free_test(test); retire_test();
g_test = NULL; FAIL_EARLY(errbuf);
return; return;
} }
/* Create a pipe to capture iperf's stdout/stderr */ /* Create a pipe to capture iperf's output */
int pipefd[2]; int pipefd[2];
if (pipe(pipefd) != 0) { if (pipe(pipefd) != 0) {
char errbuf[256];
snprintf(errbuf, sizeof(errbuf),
"[iPerf JNI] Failed to create output pipe: %s", strerror(errno));
free_argv(argv, total_argc); free_argv(argv, total_argc);
const char *err = "Failed to create output pipe"; retire_test();
LOGE("pipe() failed: %s", strerror(errno)); FAIL_EARLY(errbuf);
signal_error((struct iperf_run_state *) 0, err);
iperf_free_test(test);
g_test = NULL;
return; return;
} }
@@ -312,28 +391,33 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
free_argv(argv, total_argc); free_argv(argv, total_argc);
close(pipefd[0]); close(pipefd[0]);
close(pipefd[1]); close(pipefd[1]);
iperf_free_test(test); retire_test();
g_test = NULL; FAIL_EARLY("[iPerf JNI] Failed to allocate run state");
return; return;
} }
/* Resolve callback method IDs (do this in the calling thread) */ FILE *outfile = fdopen(pipefd[1], "w");
jclass callback_cls = (*env)->GetObjectClass(env, callback); if (!outfile) {
state->mid_output = (*env)->GetMethodID( free(state);
env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); free_argv(argv, total_argc);
state->mid_error = (*env)->GetMethodID( close(pipefd[0]);
env, callback_cls, "onError", "(Ljava/lang/String;)V"); close(pipefd[1]);
state->mid_complete = (*env)->GetMethodID( retire_test();
env, callback_cls, "onComplete", "()V"); FAIL_EARLY("[iPerf JNI] Failed to open output stream");
return;
}
/* Get JavaVM for thread attachment */ /* Get JavaVM for reader-thread attachment */
JavaVM *jvm; JavaVM *jvm;
(*env)->GetJavaVM(env, &jvm); (*env)->GetJavaVM(env, &jvm);
state->env = env; state->mid_output = mid_output;
state->mid_error = mid_error;
state->mid_complete = mid_complete;
state->callback_ref = (*env)->NewGlobalRef(env, callback); state->callback_ref = (*env)->NewGlobalRef(env, callback);
state->outfile = fdopen(pipefd[1], "w"); state->outfile = outfile;
state->pipe_fd[0] = pipefd[0]; state->pipe_fd[0] = pipefd[0];
state->pipe_fd[1] = pipefd[1];
state->jvm = jvm; state->jvm = jvm;
/* Redirect iperf's output to our pipe */ /* Redirect iperf's output to our pipe */
@@ -346,42 +430,44 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
* the strdup internally. */ * the strdup internally. */
iperf_set_test_template(test, g_tmp_template); 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
/* /*
* Set up signal handling BEFORE calling iperf_run_client(). * Set up signal handling BEFORE calling iperf_run_client().
* iperf3's run() function uses setjmp/longjmp for signal handling. * iperf3's run() function uses setjmp/longjmp for signal handling.
* - setjmp returns 0 on first call (normal path) * - setjmp returns 0 on first call (normal path)
* - setjmp returns non-zero when called via longjmp (signal path) * - setjmp returns non-zero when called via longjmp (signal path)
* *
* Both paths share the same `state`, `argv`, `argc` variables, * `state` and `argv` are assigned before setjmp and never reassigned
* so the stack frame is preserved across the longjmp. * afterwards, so both paths observe the same values.
*/ */
if (setjmp(g_sigend_jmp_buf) == 0) { if (setjmp(g_sigend_jmp_buf) == 0) {
/* Normal path — run the test */ /* Normal path — run the test */
signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */ signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */
iperf_catch_sigend(sigend_handler); iperf_catch_sigend(sigend_handler);
/* Notify Java that the test is starting */ signal_output(env, state, "[iPerf JNI] Starting iPerf3 client...\n");
signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n");
/* Spawn reader thread to forward iperf output to Java */
if (pthread_create(&state->reader_thread, NULL,
reader_thread_func, state) != 0) {
free_argv(argv, total_argc);
close(pipefd[0]);
close(pipefd[1]);
const char *err = "Failed to start output reader thread";
LOGE("%s", err);
signal_error(state, err);
iperf_free_test(test);
g_test = NULL;
free(state);
return;
}
/* Run the test */
g_stop = 0; g_stop = 0;
int ret = iperf_run_client(test); int ret = iperf_run_client(test);
LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s ctrl_sck=%d", ret, i_errno, iperf_strerror(i_errno), test->ctrl_sck); LOGI("iperf_run_client returned: %d i_errno=%d strerror=%s",
ret, i_errno, iperf_strerror(i_errno));
/* /*
* We get here after iperf_run_client returns normally. * We get here after iperf_run_client returns normally.
@@ -392,47 +478,43 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive(
} else { } else {
/* /*
* We got here via longjmp() from sigend_handler. * We got here via longjmp() from sigend_handler.
* Clean up resources allocated before the setjmp. * iperf_cleanup_and_signal() closes the pipe, joins the reader and
* NOTE: state, argv, argc are still valid on the stack. * frees everything, so there is nothing to unwind by hand here.
*/ */
LOGI("Test interrupted by signal %d", g_signed_sig); LOGI("Test interrupted by signal %d", g_signed_sig);
/* Stop flag for cleanup */
g_stop = 1; g_stop = 1;
iperf_cleanup_and_signal(env, state, argv, total_argc, 0, 1);
/* Close the output file (the pipe write end) */
if (state->outfile) {
fclose(state->outfile);
}
/* Free argv (argv is still valid across longjmp since it's on stack) */
free_argv(argv, total_argc);
/* Run the cleanup with stopped=true.
* Note: argv is NULL, argc=0 so free_argv is a no-op.
* The state struct will be freed inside this function. */
iperf_cleanup_and_signal(env, state, NULL, 0, 0, 1);
} }
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* JNI: Gracefully stop a running iperf3 test. */ /* JNI: Gracefully stop a running iperf3 test. */
/* Signature: void forceStopIperfTest(IperfCallback cb) */ /* Signature: void forceStopIperfTest(IperfCallback cb) */
/* */
/* Runs on the platform thread while the test runs on a worker thread, */
/* so the whole body holds g_test_mutex: that keeps the worker from */
/* freeing g_test underneath us. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
JNIEnv *env, jobject thiz, jobject callback) { JNIEnv *env, jobject thiz, jobject callback) {
if (!g_test || g_test->done) return;
/* Set the stop flag */ pthread_mutex_lock(&g_test_mutex);
g_stop = 1; int requested = 0;
g_test->done = 1; if (g_test && !g_test->done) {
iperf_set_send_state(g_test, IPERF_DONE); g_stop = 1;
g_test->done = 1;
iperf_set_send_state(g_test, IPERF_DONE);
/* Try to unblock select() on the control socket */ /* Try to unblock select() on the control socket */
if (g_test->ctrl_sck >= 0) { if (g_test->ctrl_sck >= 0) {
shutdown(g_test->ctrl_sck, SHUT_RDWR); shutdown(g_test->ctrl_sck, SHUT_RDWR);
}
requested = 1;
} }
pthread_mutex_unlock(&g_test_mutex);
if (!requested) return;
/* Notify Java */ /* Notify Java */
jclass callback_cls = (*env)->GetObjectClass(env, callback); jclass callback_cls = (*env)->GetObjectClass(env, callback);
@@ -440,9 +522,11 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); env, callback_cls, "onOutput", "(Ljava/lang/String;)V");
if (mid_output) { if (mid_output) {
jstring msg = (*env)->NewStringUTF(env, jstring msg = (*env)->NewStringUTF(env,
"[iPerf JNI] Requested graceful stop."); "[iPerf JNI] Requested graceful stop.\n");
(*env)->CallVoidMethod(env, callback, mid_output, msg); if (msg) {
(*env)->DeleteLocalRef(env, msg); (*env)->CallVoidMethod(env, callback, mid_output, msg);
(*env)->DeleteLocalRef(env, msg);
}
} }
} }
@@ -453,7 +537,10 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
JNIEXPORT jboolean JNICALL JNIEXPORT jboolean JNICALL
Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env, Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env,
jobject thiz) { jobject thiz) {
return (g_test != NULL && !g_test->done); pthread_mutex_lock(&g_test_mutex);
jboolean running = (g_test != NULL && !g_test->done);
pthread_mutex_unlock(&g_test_mutex);
return running;
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -4,24 +4,38 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors
/** /**
* Kotlin binding layer for native iperf3 via JNI. * Kotlin binding layer for native iperf3 via JNI.
* *
* iperf3 runs natively inside the app process — no Process.start() needed, * iperf3 runs natively inside the app process — no Process.start() needed,
* no SELinux restrictions. Output is streamed live to the Flutter UI via * no SELinux restrictions.
* the IperfCallback interface, with all callbacks posted to the main thread. *
* Threading: runIperfLive() blocks for the entire duration of the test, so it
* runs on a dedicated worker thread. Running it on the platform thread would
* freeze the UI and — because output is delivered by posting to the main
* looper — stall every callback until the test was already over.
*/ */
object IperfRunner : FlutterPlugin { object IperfRunner : FlutterPlugin {
private const val CHANNEL = "com.novoyuuparosk.iperf3gui/iperf"
private var channel: MethodChannel? = null private var channel: MethodChannel? = null
private var appContext: android.content.Context? = null private var appContext: android.content.Context? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
/** Serialises test runs: one test at a time, off the platform thread. */
private val runExecutor =
Executors.newSingleThreadExecutor { r -> Thread(r, "iperf3-run") }
private var nativeLibraryLoaded = false
init { init {
try { try {
android.util.Log.i("IperfRunner", "Loading native library...") android.util.Log.i("IperfRunner", "Loading native library...")
System.loadLibrary("iperf3jni") System.loadLibrary("iperf3jni")
nativeLibraryLoaded = true
android.util.Log.i("IperfRunner", "Native library loaded successfully") android.util.Log.i("IperfRunner", "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}") android.util.Log.e("IperfRunner", "Failed to load native library: ${e.message}")
@@ -31,62 +45,87 @@ object IperfRunner : FlutterPlugin {
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext appContext = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf") channel = MethodChannel(binding.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler { call, result -> channel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"runIperf" -> { "runIperf" -> handleRunIperf(call.argument<List<String>>("args"), result)
val args = call.argument<List<String>>("args")?.toTypedArray() "stopIperf" -> handleStopIperf(result)
if (args != null) { "isTestRunning" -> result.success(nativeLibraryLoaded && isTestRunning())
// Set temp dir to app's cache dir — /data/local/tmp is NOT writable
appContext?.cacheDir?.absolutePath?.let { cacheDir ->
setTempDir(cacheDir)
}
// Wrap callbacks so they always run on the main thread.
// The native reader thread cannot call invokeMethod directly.
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
runIperfLive(args, safeCallback)
result.success(null)
} else {
result.error("INVALID_ARGS", "args must be a String array", null)
}
}
"stopIperf" -> {
val safeCallback = MainThreadCallback(
onOutput = { line ->
channel?.invokeMethod("onOutput", mapOf("output" to line))
},
onError = { error ->
channel?.invokeMethod("onError", mapOf("error" to error))
},
onComplete = {
channel?.invokeMethod("onComplete", emptyMap<String, Any>())
}
)
forceStopIperfTest(safeCallback)
result.success(null)
}
"isTestRunning" -> {
result.success(isTestRunning())
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Stop any in-flight test: its callbacks would otherwise fire into a
// dead channel, and the worker thread would keep the socket open.
if (nativeLibraryLoaded && isTestRunning()) {
forceStopIperfTest(NoopCallback)
}
channel?.setMethodCallHandler(null)
channel = null channel = null
appContext = null
} }
private fun handleRunIperf(args: List<String>?, result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.error(
"NATIVE_LIB_MISSING",
"libiperf3jni.so failed to load — cannot run iperf3",
null,
)
return
}
if (args == null) {
result.error("INVALID_ARGS", "args must be a String array", null)
return
}
if (isTestRunning()) {
result.error("ALREADY_RUNNING", "A test is already running", null)
return
}
// Set temp dir to the app's cache dir — mkstemp() in iperf_new_stream()
// cannot write to /data/local/tmp from inside the app sandbox.
appContext?.cacheDir?.absolutePath?.let { setTempDir(it) }
val argv = args.toTypedArray()
val callback = mainThreadCallback()
// Hand back control immediately; progress arrives via the callback.
result.success(null)
runExecutor.execute {
try {
runIperfLive(argv, callback)
} catch (t: Throwable) {
android.util.Log.e("IperfRunner", "runIperfLive threw", t)
callback.onError("Native error: ${t.message}")
callback.onComplete()
}
}
}
private fun handleStopIperf(result: MethodChannel.Result) {
if (!nativeLibraryLoaded) {
result.success(null)
return
}
forceStopIperfTest(mainThreadCallback())
result.success(null)
}
/**
* Build a callback that forwards native output to Dart, hopping to the
* main thread first: MethodChannel.invokeMethod must not be called from
* the native reader thread.
*/
private fun mainThreadCallback(): IperfCallback = MainThreadCallback(
emitOutput = { line -> channel?.invokeMethod("onOutput", mapOf("output" to line)) },
emitError = { error -> channel?.invokeMethod("onError", mapOf("error" to error)) },
emitComplete = { channel?.invokeMethod("onComplete", emptyMap<String, Any>()) },
)
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
// Native method bindings // Native method bindings
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
@@ -107,23 +146,35 @@ object IperfRunner : FlutterPlugin {
} }
/** /**
* Thread-safe wrapper: posts all callbacks to the main thread. * Posts every callback to the main thread.
* This is required because the native reader thread cannot safely *
* call MethodChannel.invokeMethod from a background thread. * The lambdas are named `emit*` rather than `on*` on purpose: a constructor
* property with the same name as the overriding method would make
* `onOutput(line)` resolve back to this class's own method — an endless
* post-and-re-post loop that never reaches the channel.
*/ */
private class MainThreadCallback( private class MainThreadCallback(
private val onOutput: (String) -> Unit, private val emitOutput: (String) -> Unit,
private val onError: (String) -> Unit, private val emitError: (String) -> Unit,
private val onComplete: () -> Unit private val emitComplete: () -> Unit,
) : IperfCallback { ) : IperfCallback {
override fun onOutput(line: String) { override fun onOutput(line: String) {
mainHandler.post { onOutput(line) } mainHandler.post { emitOutput(line) }
} }
override fun onError(error: String) { override fun onError(error: String) {
mainHandler.post { onError(error) } mainHandler.post { emitError(error) }
} }
override fun onComplete() { override fun onComplete() {
mainHandler.post { onComplete() } mainHandler.post { emitComplete() }
} }
} }
}
/** Used when a stop is requested with no channel left to report to. */
private object NoopCallback : IperfCallback {
override fun onOutput(line: String) = Unit
override fun onError(error: String) = Unit
override fun onComplete() = Unit
}
}
+31 -9
View File
@@ -11,9 +11,9 @@ lib/ # Flutter/Dart application layer
│ ├── models/ │ ├── models/
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
│ │ └── iperf3_result.dart # Raw output + exit code │ │ └── iperf3_result.dart # Raw output + exit code
├── data/
│ └── services/ │ └── services/
│ ├── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) │ └── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer)
│ └── iperf3_binary_path_provider.dart # Deprecated (kept for reference)
└── ui/ # Presentation layer └── ui/ # Presentation layer
└── features/ └── features/
└── test_run/ └── test_run/
@@ -39,10 +39,11 @@ android/app/src/main/
User taps GO User taps GO
→ Dart TestRunViewModel.runTest(config) → Dart TestRunViewModel.runTest(config)
→ Iperf3Service.run(config) via MethodChannel → Iperf3Service.run(config) via MethodChannel
→ Kotlin IperfRunner.runIperfLive(args, callback) → Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once
→ JNI C: java_string_array_to_argv() → iperf_parse_arguments() → JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments()
→ iperf_run_client(test) → iperf_run_client(test) [blocks on the worker thread]
→ Reader thread pipes stdout → CallVoidMethod → Dart onOutput callbacks → Reader thread pipes output → CallVoidMethod → main-thread hop → Dart onOutput
→ Dart streams each line to the UI via Iperf3Service.outputLines
→ On completion: Dart Completer resolves with Iperf3Result → On completion: Dart Completer resolves with Iperf3Result
``` ```
@@ -51,11 +52,31 @@ User taps GO
### JNI Over Process Execution ### JNI Over Process Execution
Android SELinux Enforcing mode blocks `execve()` from the `untrusted_app` domain, making Process.start() of external binaries fail with Permission denied. Compiling iperf3 as a shared library linked into the app process eliminates this restriction entirely — iperf3 code runs in-process with no domain transitions needed. Android SELinux Enforcing mode blocks `execve()` from the `untrusted_app` domain, making Process.start() of external binaries fail with Permission denied. Compiling iperf3 as a shared library linked into the app process eliminates this restriction entirely — iperf3 code runs in-process with no domain transitions needed.
### Threading
`runIperfLive()` blocks for the whole test, so Kotlin submits it to a dedicated
single-thread executor and hands control straight back to Dart. Running it on the
Flutter platform thread froze the UI and — because output is delivered by posting
to the main looper — stalled every callback until the test was already over.
### Callback Mechanism ### 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. 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 ### 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`. The reader thread frees the state struct on exit; the JNI caller owns the callback object's lifetime. 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.
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.
### Stop Handling
A stop shuts down the control socket, so `iperf_run_client()` returns -1 with
"Broken pipe". That is the expected outcome of a stop, not a failure, so the
bridge checks its own `g_stop` flag and reports a stop rather than an error; the
flag travels up to the UI as `Iperf3Result.stopped`.
### Async Pattern ### Async Pattern
Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChannel callback handler resolves the completer on `onComplete`, with a timeout fallback that calls `stopIperf` on the native layer. Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChannel callback handler resolves the completer on `onComplete`, with a timeout fallback that calls `stopIperf` on the native layer.
@@ -72,9 +93,10 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
| Kotlin Method | JNI Signature | Purpose | | Kotlin Method | JNI Signature | Purpose |
|---|---|---| |---|---|---|
| `runIperfLive(String[], IperfCallback)` | `runIperfLive` | Start test with args and callback | | `runIperfLive(String[], IperfCallback)` | `runIperfLive` | Run a test to completion — blocks, call off the platform thread |
| `forceStopIperfTest(IperfCallback)` | `forceStopIperfTest` | Graceful stop via shutdown() | | `forceStopIperfTest(IperfCallback)` | `forceStopIperfTest` | Graceful stop via shutdown() |
| `isTestRunning()` | `isTestRunning` | Check if test is in progress | | `isTestRunning()` | `isTestRunning` | Check if test is in progress |
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir |
## Building ## Building
@@ -93,7 +115,7 @@ adb install build/app/outputs/flutter-apk/app-debug.apk
## Dependencies ## Dependencies
- Dart: `provider`, `path_provider` - Dart: `provider`
- Android: CMake external native build - Android: CMake external native build
- Native: iperf3 3.19, cJSON (single file) - Native: iperf3 3.19, cJSON (single file)
@@ -1,9 +0,0 @@
// This file is kept as a stub — iperf3 is now compiled as a native
// shared library (libiperf3jni.so) via JNI.
// The old binary-path-provider pattern (extract ELF from assets → chmod → execve)
// was replaced by the JNI approach after SELinux blocked binary execution.
// See .hermes/plans/ for details on the migration.
// The Iperf3Service now uses MethodChannel to communicate with Kotlin/JNI:
// Dart → MethodChannel → Kotlin → JNI → iperf3 (same process)
// No file extraction, no chmod, no Process.start needed.
+120 -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,13 +49,18 @@ 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;
/// 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) {
@@ -39,104 +68,128 @@ 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;
_stopRequested = 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(),
// A user-requested stop is a normal outcome, not a failure.
exitCode:
(_sawError && !_stopRequested) ? exitCodeNativeError : 0,
stopped: _stopRequested,
),
);
}
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;
}
} }
+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,
}); });
} }
-1
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart';
/// Application entry point. /// Application entry point.
+161 -73
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.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/test_configuration.dart';
@@ -6,8 +7,8 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
/// Main test run screen. /// Main test run screen.
/// ///
/// iperf3 is now compiled as a native shared library (libiperf3jni.so) /// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed
/// and accessed via JNI. No binary extraction or chmod needed. /// via JNI, so there is no binary extraction or chmod step.
class TestRunScreen extends StatelessWidget { class TestRunScreen extends StatelessWidget {
const TestRunScreen({super.key}); const TestRunScreen({super.key});
@@ -30,109 +31,196 @@ class _TestRunContent extends StatefulWidget {
} }
class _TestRunContentState extends State<_TestRunContent> { class _TestRunContentState extends State<_TestRunContent> {
final _serverController = TextEditingController(text: 'speedtest.uztelecom.uz'); static const _defaults = TestConfiguration();
final _portController = TextEditingController(text: '5201');
final _serverController =
TextEditingController(text: _defaults.server);
final _portController =
TextEditingController(text: _defaults.port.toString());
final _durationController =
TextEditingController(text: _defaults.duration.inSeconds.toString());
final _streamsController =
TextEditingController(text: _defaults.parallelStreams.toString());
final _outputScrollController = ScrollController();
@override @override
void dispose() { void dispose() {
_serverController.dispose(); _serverController.dispose();
_portController.dispose(); _portController.dispose();
_durationController.dispose();
_streamsController.dispose();
_outputScrollController.dispose();
super.dispose(); super.dispose();
} }
TestConfiguration _readConfiguration() {
return TestConfiguration(
server: _serverController.text.trim(),
port: int.tryParse(_portController.text) ?? _defaults.port,
duration: Duration(
seconds: int.tryParse(_durationController.text) ??
_defaults.duration.inSeconds,
),
parallelStreams:
int.tryParse(_streamsController.text) ?? _defaults.parallelStreams,
);
}
/// Keep the newest output in view, but only once the frame that added it has
/// been laid out.
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_outputScrollController.hasClients) return;
_outputScrollController
.jumpTo(_outputScrollController.position.maxScrollExtent);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('iperf3-gui'), title: const Text('iperf3-gui'),
actions: [
Consumer<TestRunViewModel>(
builder: (context, vm, _) => IconButton(
tooltip: 'Copy output',
icon: const Icon(Icons.copy_all),
onPressed: vm.output.isEmpty
? null
: () {
Clipboard.setData(ClipboardData(text: vm.output));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Output copied')),
);
},
),
),
],
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Server input
TextField( TextField(
controller: _serverController, controller: _serverController,
autocorrect: false,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Server', labelText: 'Server',
hintText: 'e.g. iperf.es.net or 192.168.1.1', hintText: 'e.g. iperf.es.net or 192.168.1.1',
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// Port input Row(
TextField( children: [
controller: _portController, Expanded(
decoration: const InputDecoration( child: TextField(
labelText: 'Port', controller: _portController,
hintText: '5201', keyboardType: TextInputType.number,
), decoration: const InputDecoration(labelText: 'Port'),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Go button
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
return ElevatedButton(
onPressed: vm.state == TestRunState.running
? null
: () {
final config = TestConfiguration(
server: _serverController.text,
port: int.tryParse(_portController.text) ?? 5201,
);
vm.runTest(config);
},
child: vm.state == TestRunState.running
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('GO'),
);
},
),
const SizedBox(height: 16),
// Status indicator
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
final statusText = switch (vm.state) {
TestRunState.idle => '',
TestRunState.running => 'Running...',
TestRunState.completed => 'Test complete',
TestRunState.error => 'Error',
};
return Text(
statusText,
style: TextStyle(
color: vm.state == TestRunState.error
? Colors.red
: Colors.grey,
), ),
); ),
}, const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _durationController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Seconds'),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _streamsController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Streams'),
),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Raw output text box Consumer<TestRunViewModel>(
Expanded( builder: (context, vm, _) {
child: Consumer<TestRunViewModel>( if (vm.isRunning) {
builder: (context, vm, _) { return OutlinedButton.icon(
final output = vm.result?.rawOutput ?? ''; onPressed: vm.stopTest,
return TextField( icon: const Icon(Icons.stop),
controller: TextEditingController(text: output), label: const Text('STOP'),
decoration: const InputDecoration(
labelText: 'Raw Output',
border: OutlineInputBorder(),
),
maxLines: null,
readOnly: true,
); );
}, }
return ElevatedButton(
onPressed: () => vm.runTest(_readConfiguration()),
child: const Text('GO'),
);
},
),
const SizedBox(height: 12),
// Status line
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
final scheme = Theme.of(context).colorScheme;
final (String label, Color color) = switch (vm.state) {
TestRunState.idle => ('Ready', scheme.onSurfaceVariant),
TestRunState.running => ('Running…', scheme.primary),
TestRunState.completed => (
vm.wasStopped ? 'Stopped' : 'Test complete',
vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary,
),
TestRunState.error => (
'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}',
scheme.error,
),
};
return Row(
children: [
if (vm.isRunning)
const Padding(
padding: EdgeInsets.only(right: 8),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
Text(label, style: TextStyle(color: color)),
],
);
},
),
const SizedBox(height: 12),
// Live output
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(4),
),
child: Consumer<TestRunViewModel>(
builder: (context, vm, _) {
_scrollToBottom();
return SingleChildScrollView(
controller: _outputScrollController,
child: SelectableText(
vm.output.isEmpty
? 'Output will appear here.'
: vm.output,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
);
},
),
), ),
), ),
], ],
@@ -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,28 @@ 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;
/// 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 (!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 +59,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
View File
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
+1 -193
View File
@@ -1,14 +1,6 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,14 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -57,14 +41,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -73,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -99,38 +67,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -163,14 +99,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -203,22 +131,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -227,70 +139,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -299,22 +147,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -368,14 +200,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -392,22 +216,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.3.0" version: "15.3.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.13.1 <4.0.0" dart: ">=3.13.1 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.18.0-18.0.pre.54"
-1
View File
@@ -11,7 +11,6 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path_provider: ^2.1.4
provider: ^6.1.2 provider: ^6.1.2
dev_dependencies: dev_dependencies:
@@ -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);
});
}); });
} }
@@ -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,165 @@ 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('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 {
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();
} }
} }
-1
View File
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)