0dd0b95afb
Targets runs-on: flutter-android for both jobs. Since that runner is host mode with the toolchain preinstalled, subosito/flutter-action and actions/setup-java are removed — they would have downloaded a second Flutter and JDK over the ones being used, and CI would no longer be building with the toolchain the release was verified against. A Toolchain step fails loudly if flutter or ANDROID_HOME is missing rather than letting a later step fail obscurely. Documents in docs/README.md why the runner cannot be the Gitea host, what host mode trades away, and what each job protects — including why building the release APK is necessary but not sufficient, which is the reason the JNI name guard exists at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
12 KiB
Markdown
245 lines
12 KiB
Markdown
# iperf3-gui
|
|
|
|
A Flutter-based Android GUI wrapper for iperf3, the popular network performance measurement tool.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
lib/ # Flutter/Dart application layer
|
|
├── main.dart # Composition root: picks the Iperf3Runner impl
|
|
├── main_headless.dart # Runs a test with no widgets (see Layering)
|
|
├── domain/ # Pure Dart — no Flutter import
|
|
│ ├── models/
|
|
│ │ ├── test_configuration.dart # Server, port, duration, interval, parallel streams
|
|
│ │ ├── iperf3_result.dart # Raw output, exit code, stopped flag
|
|
│ │ ├── iperf3_event.dart # Parsed --json-stream reports
|
|
│ │ └── iperf3_session.dart # One run accumulated: samples, totals, stats
|
|
│ └── services/
|
|
│ └── iperf3_runner.dart # The contract the UI depends on
|
|
├── data/ # Transport — flutter/services only
|
|
│ └── services/
|
|
│ └── iperf3_service.dart # Iperf3Runner over MethodChannel → JNI
|
|
└── ui/ # Presentation
|
|
└── features/
|
|
└── test_run/
|
|
├── test_run_screen.dart # Inputs, GO/STOP, live transcript
|
|
├── test_run_view_model.dart # idle → running → completed/error
|
|
└── iperf3_event_formatter.dart # Events → iperf3-style text
|
|
|
|
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
|
|
```
|
|
|
|
## Layering
|
|
|
|
Dependencies point inward: `ui → domain ← data`, never the reverse.
|
|
|
|
| Layer | May import | Holds |
|
|
|---|---|---|
|
|
| `domain` | nothing else in this package, no Flutter | models and the `Iperf3Runner` contract |
|
|
| `data` | `domain`, `package:flutter/services.dart` | the MethodChannel/JNI implementation |
|
|
| `ui` | `domain` | widgets, view model, formatting |
|
|
| `main.dart` | everything | the single place that picks an implementation |
|
|
|
|
The UI depends on `Iperf3Runner`, not on `Iperf3Service`, so presentation names
|
|
no transport. That is what lets `test/widget_test.dart` drive the real widget
|
|
tree against an in-memory runner with no MethodChannel, native library or
|
|
device — and what keeps a test double at five stable members instead of
|
|
mirroring the transport's whole surface.
|
|
|
|
**The core does not need the UI.** What happened during a run is accumulated by
|
|
`Iperf3Session` in the domain layer — samples, totals, errors, and the mean,
|
|
peak and retransmit figures over them. The view model holds no run data of its
|
|
own and the screen derives no numbers; both read the session. Replacing the
|
|
presentation layer therefore means rewriting rendering, not re-deriving results.
|
|
|
|
`lib/main_headless.dart` is the standing proof: it runs a full test through
|
|
`Iperf3Service` and `Iperf3Session` with `WidgetsFlutterBinding` but no
|
|
`runApp` — no widget tree, no render surface. If the run path ever starts
|
|
needing the UI, it stops working.
|
|
|
|
```bash
|
|
flutter run -t lib/main_headless.dart \
|
|
--dart-define=IPERF_SERVER=192.168.2.182 --dart-define=IPERF_STREAMS=4
|
|
```
|
|
|
|
Use it when a figure has to be trustworthy. A 4-stream local run reports
|
|
**30.2% local CPU headless against 79.9% through the UI** — the phone is near
|
|
CPU-bound at gigabit rates, so a repainting transcript measurably depresses
|
|
throughput. Numbers taken through the UI are pessimistic.
|
|
|
|
`test/architecture_test.dart` enforces all of this by reading the actual
|
|
imports under `lib/`, including a check that no fourth layer directory has
|
|
appeared unnoticed. Change the layering there when the layering should change;
|
|
don't work around it.
|
|
|
|
## 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 `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 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()` and
|
|
`signal(SIGPIPE, SIG_IGN)` change behaviour for the whole process, Flutter and
|
|
ART included. Handlers are restored to `SIG_DFL` when a test ends.
|
|
- **`g_sigend_jmp_buf` outlives its frame.** Harmless in a process about to
|
|
exit; a jump into a dead stack frame otherwise. Same fix as above.
|
|
- **Globals mean one test at a time.** `i_errno` and the bridge's own statics
|
|
hold a single test's worth of state, so concurrent runs are refused.
|
|
|
|
What iperf3 itself handles correctly, verified rather than assumed: per-stream
|
|
threads are joined (`iperf_client_api.c`), stream buffer fds are closed, and the
|
|
`mkstemp` temp file is `unlink`ed immediately after creation
|
|
(`iperf_api.c:4652`), so nothing accumulates in the cache dir. Measured across
|
|
four consecutive 4-stream runs on device, thread count returned to baseline
|
|
(33 → 40 mid-run → 34) and fd count returned exactly to baseline (175 → 183 →
|
|
175).
|
|
|
|
### Stop Handling
|
|
A stop shuts down the control socket, so `iperf_run_client()` returns -1 with
|
|
"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.so` for `armeabi-v7a` and `arm64-v8a`
|
|
- Config: `iperf_config.h` generated from `iperf_config.h.in` with 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 |
|
|
|
|
## CI
|
|
|
|
Both jobs run on a **self-hosted runner registered at `android-stuff` org scope**,
|
|
targeted by the capability label `flutter-android` rather than by hostname.
|
|
`tool/setup_ci_runner.sh` provisions it.
|
|
|
|
It does not run on the Gitea host. Gitea lives on an aarch64 box, and Flutter
|
|
cannot build an Android APK on a Linux arm64 host at all — the SDK ships no
|
|
arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/issues/189724),
|
|
[#75864](https://github.com/flutter/flutter/issues/75864), open as of July 2026).
|
|
An x86_64 runner is a requirement, not a preference.
|
|
|
|
The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android
|
|
SDK/NDK. That means CI builds with the same toolchain that produced the verified
|
|
release, and no NDK download per run. The trade is isolation — workflows run as
|
|
that user on that machine — so the runner is only for repos whose workflows are
|
|
trusted.
|
|
|
|
Two jobs:
|
|
|
|
| Job | What it protects |
|
|
|---|---|
|
|
| `analyze` | `flutter analyze`, then `flutter test` — which includes `architecture_test.dart`, failing the build if a layer boundary is crossed |
|
|
| `release-apk` | builds the **minified** APK and runs `tool/check_jni_keep_rules.sh` |
|
|
|
|
The second exists because of a specific escape. A release APK that aborted on
|
|
every test shipped once, while every on-device check passed — because those
|
|
checks all used the debug build, which does not minify. Building the release APK
|
|
is necessary but not sufficient: a renamed JNI callback still produces a
|
|
*successful build* and a broken app. So the guard reads R8's `mapping.txt` for
|
|
the callback classes and methods, and the DEX string pool for the native entry
|
|
points. It is verified to fail: delete the keep rules from
|
|
`android/app/proguard-rules.pro`, rebuild, and it reports `MainThreadCallback`,
|
|
`onOutput`, `onError` and `onComplete` renamed.
|
|
|
|
## Building
|
|
|
|
Prerequisites:
|
|
- Flutter 3.47+
|
|
- Android SDK with platform android-36
|
|
- JDK 17
|
|
- NDK r28+
|
|
- CMake 3.22.1
|
|
|
|
```bash
|
|
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.
|