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>
This commit is contained in:
2026-08-26 19:08:38 +09:00
16 changed files with 943 additions and 601 deletions
+44 -12
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.
+238 -151
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()
* and only unblocks on EOF. Joining before this would deadlock. */
close_outfile(state);
if (state->reader_started) {
pthread_join(state->reader_thread, NULL); pthread_join(state->reader_thread, NULL);
state->reader_started = 0;
}
/* Flush and close output */ /* Restore default signal handlers before g_sigend_jmp_buf goes stale. */
fflush(state->outfile); iperf_catch_sigend(SIG_DFL);
fclose(state->outfile);
/* Clean up iperf */ retire_test();
iperf_free_test(g_test);
g_test = NULL;
/* Free argv */
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,39 +478,30 @@ 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);
int requested = 0;
if (g_test && !g_test->done) {
g_stop = 1; g_stop = 1;
g_test->done = 1; g_test->done = 1;
iperf_set_send_state(g_test, IPERF_DONE); iperf_set_send_state(g_test, IPERF_DONE);
@@ -433,6 +510,11 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest(
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,10 +522,12 @@ 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");
if (msg) {
(*env)->CallVoidMethod(env, callback, mid_output, msg); (*env)->CallVoidMethod(env, callback, mid_output, msg);
(*env)->DeleteLocalRef(env, 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,61 +45,86 @@ 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) {
channel = null // 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
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 {
_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; _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.
+160 -72
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,111 +31,198 @@ 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,
decoration: const InputDecoration(
labelText: 'Port',
hintText: '5201',
),
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(height: 16),
// Raw output text box
Expanded( Expanded(
child: TextField(
controller: _portController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Port'),
),
),
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),
Consumer<TestRunViewModel>(
builder: (context, vm, _) {
if (vm.isRunning) {
return OutlinedButton.icon(
onPressed: vm.stopTest,
icon: const Icon(Icons.stop),
label: const Text('STOP'),
);
}
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>( child: Consumer<TestRunViewModel>(
builder: (context, vm, _) { builder: (context, vm, _) {
final output = vm.result?.rawOutput ?? ''; _scrollToBottom();
return TextField( return SingleChildScrollView(
controller: TextEditingController(text: output), controller: _outputScrollController,
decoration: const InputDecoration( child: SelectableText(
labelText: 'Raw Output', vm.output.isEmpty
border: OutlineInputBorder(), ? 'Output will appear here.'
: vm.output,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
), ),
maxLines: null,
readOnly: true,
); );
}, },
), ),
), ),
),
], ],
), ),
), ),
@@ -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++;
_isRunning = true;
try {
if (onRun != null) return await onRun!(config);
return const Iperf3Result(rawOutput: '', exitCode: 0); 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)