From 2bba647a7b9b23210a0e41ced3003f5ac9e01311 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:22 +0900 Subject: [PATCH 1/7] fix(jni): close the output pipe before joining the reader thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/app/src/main/cpp/iperf_jni.c | 397 ++++++++++++++++----------- 1 file changed, 239 insertions(+), 158 deletions(-) diff --git a/android/app/src/main/cpp/iperf_jni.c b/android/app/src/main/cpp/iperf_jni.c index a8f874b..cf4f9ea 100644 --- a/android/app/src/main/cpp/iperf_jni.c +++ b/android/app/src/main/cpp/iperf_jni.c @@ -5,12 +5,25 @@ * the Flutter app to run iperf3 tests natively inside the app process * (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: * iperf3's run() function (called from iperf_run_client()) uses * setjmp/longjmp for graceful signal handling. The JNI bridge must * replicate this setup — otherwise iperf_catch_sigend() installs * 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 @@ -23,6 +36,7 @@ #include #include #include +#include #include "iperf.h" #include "iperf_api.h" @@ -33,31 +47,39 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, 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 pthread_mutex_t g_test_mutex = PTHREAD_MUTEX_INITIALIZER; static volatile int g_stop = 0; static char g_tmp_template[1024] = "/data/local/tmp/iperf3.XXXXXX"; -/* Per-call state 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. * Callbacks are invoked via CallVoidMethod, NOT as function pointers. */ struct iperf_run_state { - JNIEnv *env; jobject callback_ref; /* Global ref to the callback object */ jmethodID mid_output; /* Method ID for onOutput(String) */ jmethodID mid_error; /* Method ID for onError(String) */ jmethodID mid_complete; /* Method ID for onComplete() */ - FILE *outfile; + FILE *outfile; /* Pipe write end; NULL once closed */ int pipe_fd[2]; JavaVM *jvm; pthread_t reader_thread; + int reader_started; /* Only join if pthread_create succeeded */ }; /* Helper: invoke the output callback via CallVoidMethod */ -static void signal_output(struct iperf_run_state *state, const char *msg) { - if (!state || !msg || !msg[0]) return; - JNIEnv *env = state->env; - if (!env || !state->mid_output) return; +static void signal_output(JNIEnv *env, struct iperf_run_state *state, + const char *msg) { + if (!env || !state || !msg || !msg[0] || !state->mid_output) return; jstring jline = (*env)->NewStringUTF(env, msg); 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 */ -static void signal_error(struct iperf_run_state *state, const char *msg) { - if (!state || !msg) return; - JNIEnv *env = state->env; - if (!env || !state->mid_error) return; +static void signal_error(JNIEnv *env, struct iperf_run_state *state, + const char *msg) { + if (!env || !state || !msg || !state->mid_error) return; jstring jmsg = (*env)->NewStringUTF(env, msg); 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 */ -static void signal_complete(struct iperf_run_state *state) { - if (!state || !state->mid_complete) return; - JNIEnv *env = state->env; - if (!env) return; +static void signal_complete(JNIEnv *env, struct iperf_run_state *state) { + if (!env || !state || !state->mid_complete) return; (*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 */ /* 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) { 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); if (ret != JNI_OK) { LOGE("AttachCurrentThread failed: %d", ret); + /* Still drain the pipe so writers never block on a full pipe. */ + char drain[1024]; + while (read(state->pipe_fd[0], drain, sizeof(drain)) > 0) { } + close(state->pipe_fd[0]); return NULL; } @@ -118,49 +144,52 @@ static void *reader_thread_func(void *arg) { (*env)->DeleteLocalRef(env, line); } } - fclose(fp); + fclose(fp); /* also closes pipe_fd[0] */ + } else { + close(state->pipe_fd[0]); } /* Detach this native thread */ (*state->jvm)->DetachCurrentThread(state->jvm); - - /* Free the state struct — caller owns it */ - free(state); 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, - int *out_argc) { - int argc = (*env)->GetArrayLength(env, str_array); - if (argc <= 0) { +static char **build_argv(JNIEnv *env, jobjectArray str_array, int *out_argc) { + int user_argc = (*env)->GetArrayLength(env, str_array); + if (user_argc < 0) user_argc = 0; + + int argc = user_argc + 1; /* +1 for argv[0] */ + char **argv = malloc((argc + 1) * sizeof(char *)); + if (!argv) { *out_argc = 0; return NULL; } - char **argv = malloc((argc + 1) * sizeof(char *)); - if (!argv) return NULL; - - for (int i = 0; i < argc; i++) { - jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, - str_array, i); - const char *cstr = (*env)->GetStringUTFChars(env, jstr, NULL); + argv[0] = strdup("iperf3"); + for (int i = 0; i < user_argc; i++) { + jstring jstr = (jstring) (*env)->GetObjectArrayElement(env, str_array, i); + const char *cstr = jstr ? (*env)->GetStringUTFChars(env, jstr, NULL) : NULL; if (cstr) { - argv[i] = strdup(cstr); + argv[i + 1] = strdup(cstr); (*env)->ReleaseStringUTFChars(env, jstr, cstr); } 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 */ *out_argc = argc; 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) { if (!argv) return; for (int i = 0; i < argc; i++) { @@ -170,42 +199,73 @@ static void free_argv(char **argv, int argc) { } /* ------------------------------------------------------------------ */ -/* Internal: clean up after iperf_run_client returns (normal path). */ -/* Must be called only on the normal return path (NOT after longjmp). */ +/* Close the pipe write end. Idempotent — both the normal and the */ +/* longjmp path call it, and the second call must be a no-op. */ +/* */ +/* Closing this is what makes the reader thread see EOF and exit, so */ +/* it must happen before any pthread_join(). */ +/* ------------------------------------------------------------------ */ +static void close_outfile(struct iperf_run_state *state) { + if (state && state->outfile) { + fflush(state->outfile); + fclose(state->outfile); + state->outfile = NULL; + } +} + +/* Retire the global test pointer and free it, under the mutex. */ +static void retire_test(void) { + pthread_mutex_lock(&g_test_mutex); + struct iperf_test *test = g_test; + g_test = NULL; + if (test) { + /* outfile is a closed FILE* by now — point it somewhere harmless so + * nothing in the teardown path can write to a dead stream. */ + test->outfile = stdout; + iperf_free_test(test); + } + pthread_mutex_unlock(&g_test_mutex); +} + +/* ------------------------------------------------------------------ */ +/* 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, struct iperf_run_state *state, char **argv, int argc, int ret, int stopped) { - /* Wait for reader thread to finish */ - pthread_join(state->reader_thread, NULL); + /* 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); - /* Flush and close output */ - fflush(state->outfile); - fclose(state->outfile); + if (state->reader_started) { + pthread_join(state->reader_thread, NULL); + state->reader_started = 0; + } - /* Clean up iperf */ - iperf_free_test(g_test); - g_test = NULL; + /* Restore default signal handlers before g_sigend_jmp_buf goes stale. */ + iperf_catch_sigend(SIG_DFL); - /* Free argv */ + retire_test(); free_argv(argv, argc); /* Signal result to Java */ if (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) { char errbuf[256]; snprintf(errbuf, sizeof(errbuf), "[iPerf JNI] Test failed: %s\n", iperf_strerror(i_errno)); - signal_error(state, errbuf); + signal_error(env, state, errbuf); } 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_complete(state); + signal_complete(env, state); /* Free global ref and state struct */ (*env)->DeleteGlobalRef(env, state->callback_ref); @@ -228,80 +288,93 @@ static void sigend_handler(int sig) { /* ------------------------------------------------------------------ */ /* JNI: Start an iperf3 test with the given arguments. */ /* Signature: void runIperfLive(String[] args, IperfCallback cb) */ +/* */ +/* BLOCKS until the test finishes — call from a worker thread. */ /* ------------------------------------------------------------------ */ JNIEXPORT void JNICALL Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( JNIEnv *env, jobject thiz, 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 */ struct iperf_test *test = iperf_new_test(); if (!test) { - const char *err = "Failed to create iperf test"; - LOGE("%s", err); + FAIL_EARLY("[iPerf JNI] Failed to create iperf test"); return; } iperf_defaults(test); - g_test = test; - /* - * Convert Java String[] -> char** argv, prepending "iperf3" as argv[0]. - * iperf_parse_arguments() expects argv[0] to be the program name; - * passing args directly from Dart means argv[0] would be "-c", - * which iperf3 misinterprets as the program name, not the client flag. - */ - int dart_argc = (*env)->GetArrayLength(env, arguments); - int total_argc = dart_argc + 1; /* +1 for "iperf3" */ - char **argv = malloc((total_argc + 1) * sizeof(char *)); + pthread_mutex_lock(&g_test_mutex); + g_test = test; + pthread_mutex_unlock(&g_test_mutex); + + int total_argc = 0; + char **argv = build_argv(env, arguments, &total_argc); if (!argv) { - const char *err = "Failed to allocate argv"; - LOGE("%s", err); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to allocate argv"); 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. - * Default iperf3 connect timeout is 120s which is far too long. */ + /* Set a reasonable connect timeout (30s) to avoid hanging on DNS + * 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; /* Parse arguments */ if (iperf_parse_arguments(test, total_argc, argv) < 0) { char errbuf[256]; snprintf(errbuf, sizeof(errbuf), - "Argument parse error: %s", iperf_strerror(i_errno)); - LOGE("Parse error: %s", errbuf); + "[iPerf JNI] Argument parse error: %s", iperf_strerror(i_errno)); free_argv(argv, total_argc); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY(errbuf); return; } - /* Create a pipe to capture iperf's stdout/stderr */ + /* Create a pipe to capture iperf's output */ int pipefd[2]; if (pipe(pipefd) != 0) { + char errbuf[256]; + snprintf(errbuf, sizeof(errbuf), + "[iPerf JNI] Failed to create output pipe: %s", strerror(errno)); free_argv(argv, total_argc); - const char *err = "Failed to create output pipe"; - LOGE("pipe() failed: %s", strerror(errno)); - - signal_error((struct iperf_run_state *) 0, err); - - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY(errbuf); return; } @@ -312,28 +385,33 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( free_argv(argv, total_argc); close(pipefd[0]); close(pipefd[1]); - iperf_free_test(test); - g_test = NULL; + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to allocate run state"); return; } - /* Resolve callback method IDs (do this in the calling thread) */ - jclass callback_cls = (*env)->GetObjectClass(env, callback); - state->mid_output = (*env)->GetMethodID( - env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); - state->mid_error = (*env)->GetMethodID( - env, callback_cls, "onError", "(Ljava/lang/String;)V"); - state->mid_complete = (*env)->GetMethodID( - env, callback_cls, "onComplete", "()V"); + FILE *outfile = fdopen(pipefd[1], "w"); + if (!outfile) { + free(state); + free_argv(argv, total_argc); + close(pipefd[0]); + close(pipefd[1]); + retire_test(); + FAIL_EARLY("[iPerf JNI] Failed to open output stream"); + return; + } - /* Get JavaVM for thread attachment */ + /* Get JavaVM for reader-thread attachment */ JavaVM *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->outfile = fdopen(pipefd[1], "w"); + state->outfile = outfile; state->pipe_fd[0] = pipefd[0]; + state->pipe_fd[1] = pipefd[1]; state->jvm = jvm; /* Redirect iperf's output to our pipe */ @@ -346,42 +424,44 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( * the strdup internally. */ iperf_set_test_template(test, g_tmp_template); + /* Spawn reader thread to forward iperf output to Java. Started before + * the test so early output is not lost, and before setjmp so the + * longjmp path can rely on reader_started being accurate. */ + if (pthread_create(&state->reader_thread, NULL, + reader_thread_func, state) != 0) { + close_outfile(state); + close(pipefd[0]); + free_argv(argv, total_argc); + retire_test(); + (*env)->DeleteGlobalRef(env, state->callback_ref); + free(state); + FAIL_EARLY("[iPerf JNI] Failed to start output reader thread"); + return; + } + state->reader_started = 1; + + #undef FAIL_EARLY + /* * Set up signal handling BEFORE calling iperf_run_client(). * iperf3's run() function uses setjmp/longjmp for signal handling. * - setjmp returns 0 on first call (normal path) * - setjmp returns non-zero when called via longjmp (signal path) * - * Both paths share the same `state`, `argv`, `argc` variables, - * so the stack frame is preserved across the longjmp. + * `state` and `argv` are assigned before setjmp and never reassigned + * afterwards, so both paths observe the same values. */ if (setjmp(g_sigend_jmp_buf) == 0) { /* Normal path — run the test */ signal(SIGPIPE, SIG_IGN); /* Ignore SIGPIPE for socket errors */ iperf_catch_sigend(sigend_handler); - /* Notify Java that the test is starting */ - signal_output(state, "[iPerf JNI] Starting iPerf3 client...\n"); + signal_output(env, 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; 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. @@ -392,47 +472,43 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_runIperfLive( } else { /* * We got here via longjmp() from sigend_handler. - * Clean up resources allocated before the setjmp. - * NOTE: state, argv, argc are still valid on the stack. + * iperf_cleanup_and_signal() closes the pipe, joins the reader and + * frees everything, so there is nothing to unwind by hand here. */ LOGI("Test interrupted by signal %d", g_signed_sig); - - /* Stop flag for cleanup */ g_stop = 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); + iperf_cleanup_and_signal(env, state, argv, total_argc, 0, 1); } } /* ------------------------------------------------------------------ */ /* JNI: Gracefully stop a running iperf3 test. */ /* 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 Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( JNIEnv *env, jobject thiz, jobject callback) { - if (!g_test || g_test->done) return; - /* Set the stop flag */ - g_stop = 1; - g_test->done = 1; - iperf_set_send_state(g_test, IPERF_DONE); + pthread_mutex_lock(&g_test_mutex); + int requested = 0; + if (g_test && !g_test->done) { + g_stop = 1; + g_test->done = 1; + iperf_set_send_state(g_test, IPERF_DONE); - /* Try to unblock select() on the control socket */ - if (g_test->ctrl_sck >= 0) { - shutdown(g_test->ctrl_sck, SHUT_RDWR); + /* Try to unblock select() on the control socket */ + if (g_test->ctrl_sck >= 0) { + shutdown(g_test->ctrl_sck, SHUT_RDWR); + } + requested = 1; } + pthread_mutex_unlock(&g_test_mutex); + + if (!requested) return; /* Notify Java */ jclass callback_cls = (*env)->GetObjectClass(env, callback); @@ -440,9 +516,11 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( env, callback_cls, "onOutput", "(Ljava/lang/String;)V"); if (mid_output) { jstring msg = (*env)->NewStringUTF(env, - "[iPerf JNI] Requested graceful stop."); - (*env)->CallVoidMethod(env, callback, mid_output, msg); - (*env)->DeleteLocalRef(env, msg); + "[iPerf JNI] Requested graceful stop.\n"); + if (msg) { + (*env)->CallVoidMethod(env, callback, mid_output, msg); + (*env)->DeleteLocalRef(env, msg); + } } } @@ -453,7 +531,10 @@ Java_com_novoyuuparosk_iperf3gui_IperfRunner_forceStopIperfTest( JNIEXPORT jboolean JNICALL Java_com_novoyuuparosk_iperf3gui_IperfRunner_isTestRunning(JNIEnv *env, 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; } /* ------------------------------------------------------------------ */ From f410a7e0d3aa2b1cc2387035f5268078ab66bc25 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:22 +0900 Subject: [PATCH 2/7] fix(android): run iperf3 tests off the Flutter platform thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../novoyuuparosk/iperf3gui/IperfRunner.kt | 165 ++++++++++++------ 1 file changed, 108 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt b/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt index 2e57025..988f81f 100644 --- a/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt +++ b/android/app/src/main/kotlin/com/novoyuuparosk/iperf3gui/IperfRunner.kt @@ -4,24 +4,38 @@ import android.os.Handler import android.os.Looper import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors /** * Kotlin binding layer for native iperf3 via JNI. * * iperf3 runs natively inside the app process — no Process.start() needed, - * no SELinux restrictions. Output is streamed live to the Flutter UI via - * the IperfCallback interface, with all callbacks posted to the main thread. + * no SELinux restrictions. + * + * 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 { + private const val CHANNEL = "com.novoyuuparosk.iperf3gui/iperf" + private var channel: MethodChannel? = null private var appContext: android.content.Context? = null 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 { try { android.util.Log.i("IperfRunner", "Loading native library...") System.loadLibrary("iperf3jni") + nativeLibraryLoaded = true android.util.Log.i("IperfRunner", "Native library loaded successfully") } catch (e: UnsatisfiedLinkError) { 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) { appContext = binding.applicationContext - channel = MethodChannel(binding.binaryMessenger, "com.novoyuuparosk.iperf3gui/iperf") + channel = MethodChannel(binding.binaryMessenger, CHANNEL) channel?.setMethodCallHandler { call, result -> when (call.method) { - "runIperf" -> { - val args = call.argument>("args")?.toTypedArray() - if (args != null) { - // 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()) - } - ) - 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()) - } - ) - forceStopIperfTest(safeCallback) - result.success(null) - } - "isTestRunning" -> { - result.success(isTestRunning()) - } + "runIperf" -> handleRunIperf(call.argument>("args"), result) + "stopIperf" -> handleStopIperf(result) + "isTestRunning" -> result.success(nativeLibraryLoaded && isTestRunning()) else -> result.notImplemented() } } } 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 + appContext = null } + private fun handleRunIperf(args: List?, 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()) }, + ) + // ───────────────────────────────────────────────────────────────── // Native method bindings // ───────────────────────────────────────────────────────────────── @@ -107,23 +146,35 @@ object IperfRunner : FlutterPlugin { } /** - * Thread-safe wrapper: posts all callbacks to the main thread. - * This is required because the native reader thread cannot safely - * call MethodChannel.invokeMethod from a background thread. + * Posts every callback to the main 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 val onOutput: (String) -> Unit, - private val onError: (String) -> Unit, - private val onComplete: () -> Unit + private val emitOutput: (String) -> Unit, + private val emitError: (String) -> Unit, + private val emitComplete: () -> Unit, ) : IperfCallback { override fun onOutput(line: String) { - mainHandler.post { onOutput(line) } + mainHandler.post { emitOutput(line) } } + override fun onError(error: String) { - mainHandler.post { onError(error) } + mainHandler.post { emitError(error) } } + override fun onComplete() { - mainHandler.post { onComplete() } + mainHandler.post { emitComplete() } } } -} \ No newline at end of file + + /** 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 + } +} From 3b01b0c4a6ac4f02c290ca98f0b89c6d3107e7ca Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:22 +0900 Subject: [PATCH 3/7] 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 --- lib/data/services/iperf3_service.dart | 179 +++++++++++------- .../test_run/test_run_view_model.dart | 59 +++++- .../test_run/test_run_view_model_test.dart | 146 ++++++++++++-- 3 files changed, 300 insertions(+), 84 deletions(-) diff --git a/lib/data/services/iperf3_service.dart b/lib/data/services/iperf3_service.dart index 2c22025..24e9e43 100644 --- a/lib/data/services/iperf3_service.dart +++ b/lib/data/services/iperf3_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.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). /// This avoids SELinux restrictions on executing ELF binaries from assets. /// -/// The service communicates with the native layer via a MethodChannel. -/// Output is streamed live to the Flutter UI through callbacks. +/// The service talks to the native layer over a MethodChannel. Native output +/// arrives line by line on [outputLines] while the test runs; [run] resolves +/// with the accumulated transcript once the native layer reports completion. class Iperf3Service { + Iperf3Service() { + _channel.setMethodCallHandler(_handleNativeCall); + } + static const MethodChannel _channel = MethodChannel('com.novoyuuparosk.iperf3gui/iperf'); - /// 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 _outputController = + StreamController.broadcast(); + + /// Live output, one line per event. Use this to render progress; [run]'s + /// result carries the same text joined together. + Stream get outputLines => _outputController.stream; + + /// Transcript of the current (or most recent) run. final List _outputBuffer = []; /// Completer that resolves when the native iperf3 test finishes. @@ -25,10 +49,11 @@ class Iperf3Service { /// Whether iperf3 is currently running. bool _isRunning = false; - - /// Whether iperf3 is currently running. bool get isRunning => _isRunning; + /// Whether the native layer reported an error during this run. + bool _sawError = false; + /// Cancel any running test. Future stop() async { if (!_isRunning) return; @@ -39,104 +64,124 @@ class Iperf3Service { } } + /// Release the stream controller. Call from the owner's dispose(). + Future dispose() async { + _channel.setMethodCallHandler(null); + await _outputController.close(); + } + /// 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 run(TestConfiguration config) async { if (_isRunning) { - return Iperf3Result( + return const Iperf3Result( rawOutput: 'Another test is already running.', - exitCode: -2, + exitCode: exitCodeBusy, ); } _isRunning = true; + _sawError = false; _outputBuffer.clear(); - final args = [ - '-c', config.server, - '-p', config.port.toString(), - '-t', config.duration.inSeconds.toString(), - '-i', config.interval.toString(), - '-P', config.parallelStreams.toString(), - ]; - + final args = config.toCliArgs(); debugPrint('Iperf3Service: running: ${args.join(" ")}'); - // Set up the completer to await completion - _runCompleter = Completer(); + final completer = Completer(); + _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 { await _channel.invokeMethod('runIperf', {'args': args}); } catch (e, stackTrace) { debugPrint('Iperf3Service exception: $e'); debugPrint('Iperf3Service stack: $stackTrace'); - _isRunning = false; - if (!_runCompleter!.isCompleted) { - _runCompleter!.completeError(e); - } + _finishRun(); return Iperf3Result( 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 { - final result = await _runCompleter!.future.timeout( - config.duration + const Duration(seconds: 30), + return await completer.future.timeout( + config.duration + const Duration(seconds: 45), onTimeout: () { 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( - rawOutput: 'Test timed out after ${config.duration.inSeconds}s', - exitCode: -3, + rawOutput: _transcript(), + exitCode: exitCodeTimeout, ); }, ); - return result; } catch (e) { debugPrint('Iperf3Service: error waiting for completion: $e'); return Iperf3Result( rawOutput: 'Test failed: $e', - exitCode: -1, + exitCode: exitCodeStartFailed, ); } finally { - _runCompleter = null; + _finishRun(); } } + + /// Handle a callback pushed up from the Kotlin/JNI layer. + Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'onOutput': + final output = call.arguments['output'] as String?; + if (output != null) { + _emit(output); + } + case 'onError': + final error = call.arguments['error'] as String?; + if (error != null) { + // Not terminal on its own — iperf3 reports the failure reason and + // then completes, so wait for onComplete before resolving. + _sawError = true; + _emit('[ERROR] $error'); + } + case 'onComplete': + debugPrint('Iperf3Service: test completed'); + final completer = _runCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete( + Iperf3Result( + rawOutput: _transcript(), + exitCode: _sawError ? exitCodeNativeError : 0, + ), + ); + } + default: + debugPrint('Iperf3Service: unhandled native call ${call.method}'); + } + } + + void _emit(String line) { + _outputBuffer.add(line); + if (!_outputController.isClosed) { + _outputController.add(line); + } + debugPrint('Iperf3Service output: ${line.trimRight()}'); + } + + /// Native output already carries its own newlines; keep it verbatim except + /// for lines we synthesised ourselves. + String _transcript() => _outputBuffer + .map((line) => line.endsWith('\n') ? line : '$line\n') + .join(); + + void _finishRun() { + _isRunning = false; + _runCompleter = null; + } } diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index 04f2aa0..d247b10 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -1,15 +1,26 @@ +import 'dart:async'; + import 'package:flutter/foundation.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/test_configuration.dart'; /// State of the test run. enum TestRunState { idle, running, completed, error } /// ViewModel for managing test run state. +/// +/// Subscribes to [Iperf3Service.outputLines] so the transcript grows while the +/// test runs rather than appearing all at once at the end. class TestRunViewModel extends ChangeNotifier { final Iperf3Service iperf3Service; + TestRunViewModel({required this.iperf3Service}) { + _outputSubscription = iperf3Service.outputLines.listen(_onOutputLine); + } + + StreamSubscription? _outputSubscription; + TestRunState _state = TestRunState.idle; TestRunState get state => _state; @@ -19,12 +30,25 @@ class TestRunViewModel extends ChangeNotifier { TestConfiguration? _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; /// Start a test with the given configuration. Future runTest(TestConfiguration config) async { + if (_state == TestRunState.running) return; + if (!config.isValid) { + _liveOutput + ..clear() + ..writeln('Invalid configuration: check the server address and port.'); _state = TestRunState.error; + _result = null; notifyListeners(); return; } @@ -32,26 +56,51 @@ class TestRunViewModel extends ChangeNotifier { _state = TestRunState.running; _currentConfig = config; _result = null; + _liveOutput.clear(); notifyListeners(); try { final result = await iperf3Service.run(config); _result = result; - _state = result.exitCode == 0 - ? TestRunState.completed - : TestRunState.error; + // Prefer the streamed transcript — it is what the user watched build up. + // Fall back to the service's copy if nothing streamed through. + if (_liveOutput.isEmpty && result.rawOutput.isNotEmpty) { + _liveOutput.write(result.rawOutput); + } + _state = + result.exitCode == 0 ? TestRunState.completed : TestRunState.error; } catch (e) { + _liveOutput.writeln('Test failed: $e'); _state = TestRunState.error; } finally { notifyListeners(); } } + /// Request that a running test stop early. + Future stopTest() async { + if (_state != TestRunState.running) return; + await iperf3Service.stop(); + } + /// Reset to idle state. void reset() { _state = TestRunState.idle; _result = null; _currentConfig = null; + _liveOutput.clear(); notifyListeners(); } + + void _onOutputLine(String line) { + _liveOutput.write(line.endsWith('\n') ? line : '$line\n'); + notifyListeners(); + } + + @override + void dispose() { + _outputSubscription?.cancel(); + iperf3Service.dispose(); + super.dispose(); + } } diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 46b7532..77088c3 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:iperf3gui/data/services/iperf3_service.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() { group('TestRunViewModel', () { test('starts in idle state', () { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final vm = TestRunViewModel(iperf3Service: _MockIperf3Service()); expect(vm.state, equals(TestRunState.idle)); expect(vm.result, isNull); expect(vm.currentConfig, isNull); + expect(vm.output, isEmpty); }); test('can be reset', () { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final vm = TestRunViewModel(iperf3Service: _MockIperf3Service()); vm.reset(); expect(vm.state, equals(TestRunState.idle)); }); test('invalid config sets error state without running', () async { - final vm = TestRunViewModel( - iperf3Service: _MockIperf3Service(), - ); + final mock = _MockIperf3Service(); + final vm = TestRunViewModel(iperf3Service: mock); const config = TestConfiguration(server: ''); await vm.runTest(config); expect(vm.state, equals(TestRunState.error)); + expect(mock.runCallCount, equals(0)); }); test('runTest with valid config calls service', () async { @@ -45,17 +44,140 @@ void main() { expect(vm.state, equals(TestRunState.completed)); 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.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(); + 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('a second runTest while running is ignored', () async { + final mock = _MockIperf3Service(); + final gate = Completer(); + 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 { _MockIperf3Service({this.onRun}); - final Future Function(TestConfiguration)? onRun; + Future Function(TestConfiguration)? onRun; + + final StreamController _controller = + StreamController.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 get outputLines => _controller.stream; + + @override + bool get isRunning => _isRunning; @override Future run(TestConfiguration config) async { - if (onRun != null) return onRun!(config); - return const Iperf3Result(rawOutput: '', exitCode: 0); + runCallCount++; + _isRunning = true; + try { + if (onRun != null) return await onRun!(config); + return const Iperf3Result(rawOutput: '', exitCode: 0); + } finally { + _isRunning = false; + } + } + + @override + Future stop() async { + stopCallCount++; + } + + @override + Future dispose() async { + disposed = true; + await _controller.close(); } } From fb6ec3af3766990721725d426ee83ff70b554ab4 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:22 +0900 Subject: [PATCH 4/7] feat(ui): show live output, add a stop button and duration/stream inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ui/features/test_run/test_run_screen.dart | 231 ++++++++++++------ 1 file changed, 158 insertions(+), 73 deletions(-) diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 715ca5f..56285ca 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; @@ -6,8 +7,8 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// -/// iperf3 is now compiled as a native shared library (libiperf3jni.so) -/// and accessed via JNI. No binary extraction or chmod needed. +/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed +/// via JNI, so there is no binary extraction or chmod step. class TestRunScreen extends StatelessWidget { const TestRunScreen({super.key}); @@ -30,109 +31,193 @@ class _TestRunContent extends StatefulWidget { } class _TestRunContentState extends State<_TestRunContent> { - final _serverController = TextEditingController(text: 'speedtest.uztelecom.uz'); - final _portController = TextEditingController(text: '5201'); + static const _defaults = TestConfiguration(); + + 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 void dispose() { _serverController.dispose(); _portController.dispose(); + _durationController.dispose(); + _streamsController.dispose(); + _outputScrollController.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 Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('iperf3-gui'), + actions: [ + Consumer( + 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( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Server input TextField( controller: _serverController, + autocorrect: false, decoration: const InputDecoration( labelText: 'Server', hintText: 'e.g. iperf.es.net or 192.168.1.1', ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), - // Port input - TextField( - controller: _portController, - decoration: const InputDecoration( - labelText: 'Port', - hintText: '5201', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - - // Go button - Consumer( - 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( - 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, + Row( + children: [ + 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), - // Raw output text box - Expanded( - child: Consumer( - builder: (context, vm, _) { - final output = vm.result?.rawOutput ?? ''; - return TextField( - controller: TextEditingController(text: output), - decoration: const InputDecoration( - labelText: 'Raw Output', - border: OutlineInputBorder(), - ), - maxLines: null, - readOnly: true, + Consumer( + 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( + 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 => ('Test complete', 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( + 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, + ), + ), + ); + }, + ), ), ), ], From 02a8ba9c1812fc7285b34070213f1cf9625c546e Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:59 +0900 Subject: [PATCH 5/7] fix: report a user-requested stop as stopped rather than failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- android/app/src/main/cpp/iperf_jni.c | 8 +++++- lib/data/services/iperf3_service.dart | 10 +++++++- lib/domain/models/iperf3_result.dart | 5 ++++ lib/ui/features/test_run/test_run_screen.dart | 5 +++- .../test_run/test_run_view_model.dart | 3 +++ test/domain/models/iperf3_result_test.dart | 10 ++++++++ .../test_run/test_run_view_model_test.dart | 25 +++++++++++++++++++ 7 files changed, 63 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/cpp/iperf_jni.c b/android/app/src/main/cpp/iperf_jni.c index cf4f9ea..5b4cfac 100644 --- a/android/app/src/main/cpp/iperf_jni.c +++ b/android/app/src/main/cpp/iperf_jni.c @@ -251,8 +251,14 @@ static void iperf_cleanup_and_signal(JNIEnv *env, retire_test(); 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 */ - if (stopped) { + if (user_stopped) { signal_output(env, state, "[iPerf JNI] Test was stopped by user.\n"); } else if (ret < 0) { char errbuf[256]; diff --git a/lib/data/services/iperf3_service.dart b/lib/data/services/iperf3_service.dart index 24e9e43..a3e6296 100644 --- a/lib/data/services/iperf3_service.dart +++ b/lib/data/services/iperf3_service.dart @@ -54,9 +54,13 @@ class Iperf3Service { /// 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. Future stop() async { if (!_isRunning) return; + _stopRequested = true; try { await _channel.invokeMethod('stopIperf'); } catch (e) { @@ -83,6 +87,7 @@ class Iperf3Service { _isRunning = true; _sawError = false; + _stopRequested = false; _outputBuffer.clear(); final args = config.toCliArgs(); @@ -157,7 +162,10 @@ class Iperf3Service { completer.complete( Iperf3Result( rawOutput: _transcript(), - exitCode: _sawError ? exitCodeNativeError : 0, + // A user-requested stop is a normal outcome, not a failure. + exitCode: + (_sawError && !_stopRequested) ? exitCodeNativeError : 0, + stopped: _stopRequested, ), ); } diff --git a/lib/domain/models/iperf3_result.dart b/lib/domain/models/iperf3_result.dart index 2c54feb..ef1b990 100644 --- a/lib/domain/models/iperf3_result.dart +++ b/lib/domain/models/iperf3_result.dart @@ -3,8 +3,13 @@ class Iperf3Result { final String rawOutput; 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({ required this.rawOutput, required this.exitCode, + this.stopped = false, }); } diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 56285ca..fb2a85d 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -166,7 +166,10 @@ class _TestRunContentState extends State<_TestRunContent> { final (String label, Color color) = switch (vm.state) { TestRunState.idle => ('Ready', scheme.onSurfaceVariant), TestRunState.running => ('Running…', scheme.primary), - TestRunState.completed => ('Test complete', scheme.primary), + TestRunState.completed => ( + vm.wasStopped ? 'Stopped' : 'Test complete', + vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary, + ), TestRunState.error => ( 'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}', scheme.error, diff --git a/lib/ui/features/test_run/test_run_view_model.dart b/lib/ui/features/test_run/test_run_view_model.dart index d247b10..ec267ec 100644 --- a/lib/ui/features/test_run/test_run_view_model.dart +++ b/lib/ui/features/test_run/test_run_view_model.dart @@ -39,6 +39,9 @@ class TestRunViewModel extends ChangeNotifier { /// 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. Future runTest(TestConfiguration config) async { if (_state == TestRunState.running) return; diff --git a/test/domain/models/iperf3_result_test.dart b/test/domain/models/iperf3_result_test.dart index 68567d7..89070e7 100644 --- a/test/domain/models/iperf3_result_test.dart +++ b/test/domain/models/iperf3_result_test.dart @@ -11,5 +11,15 @@ void main() { expect(result.rawOutput, equals('Test complete')); 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); + }); }); } diff --git a/test/ui/features/test_run/test_run_view_model_test.dart b/test/ui/features/test_run/test_run_view_model_test.dart index 77088c3..6f83d36 100644 --- a/test/ui/features/test_run/test_run_view_model_test.dart +++ b/test/ui/features/test_run/test_run_view_model_test.dart @@ -120,6 +120,31 @@ void main() { 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(); From 6f809913bd1d9cc00c75cc4f190efa4f99e843da Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:59 +0900 Subject: [PATCH 6/7] 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 --- .../services/iperf3_binary_path_provider.dart | 9 - lib/main.dart | 1 - linux/flutter/generated_plugins.cmake | 1 - pubspec.lock | 194 +----------------- pubspec.yaml | 1 - windows/flutter/generated_plugins.cmake | 1 - 6 files changed, 1 insertion(+), 206 deletions(-) delete mode 100644 lib/data/services/iperf3_binary_path_provider.dart diff --git a/lib/data/services/iperf3_binary_path_provider.dart b/lib/data/services/iperf3_binary_path_provider.dart deleted file mode 100644 index 05c5333..0000000 --- a/lib/data/services/iperf3_binary_path_provider.dart +++ /dev/null @@ -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. diff --git a/lib/main.dart b/lib/main.dart index 9a770a3..da2d2fb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/ui/features/test_run/test_run_screen.dart'; /// Application entry point. diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index be1ee3e..2e1de87 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock index 9b71df9..a4537c9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,14 +1,6 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" - source: hosted - version: "2.7.0" async: dependency: transitive description: @@ -41,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -57,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" - source: hosted - version: "3.0.7" fake_async: dependency: transitive description: @@ -73,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" - source: hosted - version: "2.2.0" flutter: dependency: "direct main" description: flutter @@ -99,38 +67,6 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -163,14 +99,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" - source: hosted - version: "1.3.0" matcher: dependency: transitive description: @@ -203,22 +131,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -227,70 +139,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -299,22 +147,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -368,14 +200,6 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -392,22 +216,6 @@ packages: url: "https://pub.dev" source: hosted 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: dart: ">=3.13.1 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index f12b5ee..3af8d77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,6 @@ dependencies: flutter: sdk: flutter - path_provider: ^2.1.4 provider: ^6.1.2 dev_dependencies: diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3ad69c6..b93c4c3 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 0dc747edefa37cc13c1f747bf4221f7f2001b66d Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:59 +0900 Subject: [PATCH 7/7] 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 --- README.md | 58 +++++++++++++++++++++++++++++++++++++++----------- docs/README.md | 40 ++++++++++++++++++++++++++-------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ae83ea0..dde45a0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # 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 - Client-mode network throughput testing -- Simple UI: enter server address, port, and press GO -- Raw iperf3 output display -- Bundled iperf3 v3.21 binaries for arm64-v8a and armeabi-v7a +- Configurable server, port, duration and parallel streams +- Live iperf3 output, streamed line by line while the test runs +- 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 @@ -15,33 +18,62 @@ A Flutter-based Android GUI wrapper for iperf3, the popular network performance lib/ ├── domain/ │ ├── models/ # TestConfiguration, Iperf3Result -│ └── services/ # Iperf3Service (Process runner) +├── data/ +│ └── services/ # Iperf3Service (MethodChannel → JNI) ├── ui/ │ └── features/ │ └── test_run/ # TestRunScreen, TestRunViewModel -├── main.dart # App entry point -└── pubspec.yaml # Dependencies: provider, path_provider +└── main.dart # App entry point + +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 Prerequisites: -- Flutter 3.47+ +- Flutter 3.47+ - Android SDK with platform android-36 - JDK 17 +- NDK r28+ and CMake 3.22.1 (for the native build) ```bash -flutter create . --org novoyuuparosk --project-name iperf3gui flutter pub get flutter analyze +flutter test 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) -and bundled in `assets/iperf3/` (arm64-v8a and armeabi-v7a for Android 28+). +```bash +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 -MIT — bundled iperf3 v3.21 is BSD-3-Clause licensed. +MIT — bundled iperf3 3.19 is BSD-3-Clause licensed. diff --git a/docs/README.md b/docs/README.md index a0bdc5d..321cfbc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,9 +11,9 @@ lib/ # Flutter/Dart application layer │ ├── models/ │ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams │ │ └── iperf3_result.dart # Raw output + exit code +├── data/ │ └── services/ -│ ├── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) -│ └── iperf3_binary_path_provider.dart # Deprecated (kept for reference) +│ └── iperf3_service.dart # Dart → MethodChannel bridge (async with Completer) └── ui/ # Presentation layer └── features/ └── test_run/ @@ -39,10 +39,11 @@ android/app/src/main/ User taps GO → Dart TestRunViewModel.runTest(config) → Iperf3Service.run(config) via MethodChannel - → Kotlin IperfRunner.runIperfLive(args, callback) - → JNI C: java_string_array_to_argv() → iperf_parse_arguments() - → iperf_run_client(test) - → Reader thread pipes stdout → CallVoidMethod → Dart onOutput callbacks + → Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once + → JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments() + → iperf_run_client(test) [blocks on the worker thread] + → Reader thread pipes output → CallVoidMethod → main-thread hop → Dart onOutput + → Dart streams each line to the UI via Iperf3Service.outputLines → On completion: Dart Completer resolves with Iperf3Result ``` @@ -51,11 +52,31 @@ User taps GO ### 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. +### 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 The JNI bridge uses `CallVoidMethod` on stored `jmethodID`s to invoke the Kotlin `IperfCallback` interface, NOT function pointer casts. Direct function pointer casts caused SIGBUS crashes on ARM due to JNINativeInterface method ID layout differences. ### Output Streaming -iperf3 output is redirected via pipe to a background reader thread that attaches to the Java VM via `AttachCurrentThread` and invokes callbacks via `CallVoidMethod`. 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 Dart uses `Completer` 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` to await native completion. The MethodChanne | 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() | | `isTestRunning()` | `isTestRunning` | Check if test is in progress | +| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir | ## Building @@ -93,7 +115,7 @@ adb install build/app/outputs/flutter-apk/app-debug.apk ## Dependencies -- Dart: `provider`, `path_provider` +- Dart: `provider` - Android: CMake external native build - Native: iperf3 3.19, cJSON (single file)