The output-streaming section described the pipe and reader thread that no longer exist, including the join-ordering rule that was the reason they were dangerous. Replaced with how the json_callback path works and why, and an explicit note not to reintroduce the pipe. Adds a section on what embedding a CLI in a long-lived app process actually costs, since that is the recurring trap here: iperf3's main() can leave process-global state behind because it is about to exit, and this app cannot. Records what iperf3 does clean up — joined stream threads, closed fds, an immediately unlinked temp file — with the measurements taken on device, so the next person does not have to re-derive whether repeated runs leak. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.6 KiB
iperf3-gui
A Flutter-based Android GUI wrapper for iperf3, the popular network performance measurement tool.
Architecture
lib/ # Flutter/Dart application layer
├── main.dart # App entry point (MaterialApp)
├── domain/ # Business logic (platform-independent)
│ ├── 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)
└── ui/ # Presentation layer
└── features/
└── test_run/
├── test_run_screen.dart # Server/port inputs + GO button
└── test_run_view_model.dart # State machine: idle → running → completed/error
android/app/src/main/
├── kotlin/ # Kotlin plugin layer
│ └── com/novoyuuparosk/iperf3gui/
│ ├── MainActivity.kt # Registers IperfRunner as FlutterPlugin
│ └── IperfRunner.kt # MethodChannel handler → native JNI calls
└── cpp/ # Native iperf3 library
├── CMakeLists.txt # Compiles iperf3 + cJSON + JNI into libiperf3jni.so
├── iperf_jni.c # JNI bridge: Java String[] → iperf3 API
├── iperf/ # iperf3 3.19 C source tree (see below)
├── iperf_config.h # Generated Android-compatible config header
└── iperf_config_android.h # Android-specific compile options
Data Flow
User taps GO
→ Dart TestRunViewModel.runTest(config)
→ Iperf3Service.run(config) via MethodChannel
→ 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]
→ iperf3 json_callback → one JSON event per report → CallVoidMethod
→ main-thread hop → Dart onOutput → Iperf3Event.parse()
→ Iperf3Service.events → view model: transcript + interval series + summary
→ On completion: Dart Completer resolves with Iperf3Result
Key Design Decisions
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 jmethodIDs 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 via json_callback, not a pipe
The test runs with json_output and json_stream set and a json_callback
installed, so iperf3 hands each report to the bridge as a JSON string and never
writes to test->outfile. iperf.h describes the hook as "allow user apps to
receive the JSON strings, instead of writing them to the output file" — this is
its intended use.
An earlier design piped test->outfile to a reader thread. That imposed two
constraints, both of which were gotten wrong and deadlocked every test: the
pipe's write end had to be closed before joining a reader blocked in
fgets(), and exactly one side could own the shared state struct. Reading from
the callback removes the pipe, the reader thread and both constraints. Don't
reintroduce it.
json_callback carries no user-data pointer, so the run state is reached
through a global published before iperf_run_client() and cleared afterwards;
only one test may run at a time, which runIperfLive enforces. The callback
fires on iperf3's reporting thread, so it resolves a JNIEnv for the calling
thread rather than reusing a stored one.
Dart parses each event into a sealed Iperf3Event; unrecognised output becomes
an Iperf3LogLine rather than being discarded. The UI rebuilds iperf3's text
layout from the events and keeps the interval series as data.
Embedding a CLI in a long-lived process
iperf3's main() runs one test and exits, so anything it leaves behind is
reclaimed by the OS. Linked into an app process that runs tests repeatedly for
hours, every such leftover becomes this app's problem. What that costs in
practice:
- Signal handlers are process-global.
iperf_catch_sigend()andsignal(SIGPIPE, SIG_IGN)change behaviour for the whole process, Flutter and ART included. Handlers are restored toSIG_DFLwhen a test ends. g_sigend_jmp_bufoutlives its frame. Harmless in a process about to exit; a jump into a dead stack frame otherwise. Same fix as above.- Globals mean one test at a time.
i_errnoand the bridge's own statics hold a single test's worth of state, so concurrent runs are refused.
What iperf3 itself handles correctly, verified rather than assumed: per-stream
threads are joined (iperf_client_api.c), stream buffer fds are closed, and the
mkstemp temp file is unlinked immediately after creation
(iperf_api.c:4652), so nothing accumulates in the cache dir. Measured across
four consecutive 4-stream runs on device, thread count returned to baseline
(33 → 40 mid-run → 34) and fd count returned exactly to baseline (175 → 183 →
175).
Stop Handling
A stop shuts down the control socket, so iperf_run_client() returns -1 with
"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<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.
iperf3 Source
- Version: 3.19
- Location:
android/app/src/main/cpp/iperf/ - Files: Full C source tree including cJSON, headers, man pages, PEM keys
- Build: CMake compiles into
libiperf3jni.soforarmeabi-v7aandarm64-v8a - Config:
iperf_config.hgenerated fromiperf_config.h.inwith Android-compatible POSIX/threading macros
Native JNI API
| Kotlin Method | JNI Signature | Purpose |
|---|---|---|
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
Prerequisites:
- Flutter 3.47+
- Android SDK with platform android-36
- JDK 17
- NDK r28+
- CMake 3.22.1
flutter pub get
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
Dependencies
- Dart:
provider - Android: CMake external native build
- Native: iperf3 3.19, cJSON (single file)
License
MIT — bundled iperf3 3.19 is BSD-3-Clause licensed.