docs: replace the pipe design with json_callback and note the embedding costs

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>
This commit is contained in:
2026-08-26 19:35:41 +09:00
parent cbb2aba8b0
commit 857b79fbaf
+47 -11
View File
@@ -42,8 +42,9 @@ User taps GO
→ Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once → Kotlin IperfRunner: submits to the iperf3-run worker thread, returns at once
→ JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments() → JNI C: build_argv() (prepends argv[0]) → iperf_parse_arguments()
→ iperf_run_client(test) [blocks on the worker thread] → iperf_run_client(test) [blocks on the worker thread]
→ Reader thread pipes output → CallVoidMethod → main-thread hop → Dart onOutput → iperf3 json_callback → one JSON event per report → CallVoidMethod
→ Dart streams each line to the UI via Iperf3Service.outputLines → main-thread hop → Dart onOutput → Iperf3Event.parse()
→ Iperf3Service.events → view model: transcript + interval series + summary
→ On completion: Dart Completer resolves with Iperf3Result → On completion: Dart Completer resolves with Iperf3Result
``` ```
@@ -61,16 +62,51 @@ to the main looper — stalled every callback until the test was already over.
### Callback Mechanism ### Callback Mechanism
The JNI bridge uses `CallVoidMethod` on stored `jmethodID`s to invoke the Kotlin `IperfCallback` interface, NOT function pointer casts. Direct function pointer casts caused SIGBUS crashes on ARM due to JNINativeInterface method ID layout differences. The JNI bridge uses `CallVoidMethod` on stored `jmethodID`s to invoke the Kotlin `IperfCallback` interface, NOT function pointer casts. Direct function pointer casts caused SIGBUS crashes on ARM due to JNINativeInterface method ID layout differences.
### Output Streaming ### Output via json_callback, not a pipe
iperf3 output is redirected via pipe to a background reader thread that attaches The test runs with `json_output` and `json_stream` set and a `json_callback`
to the Java VM via `AttachCurrentThread` and invokes callbacks via installed, so iperf3 hands each report to the bridge as a JSON string and never
`CallVoidMethod`. Dart re-publishes each line on `Iperf3Service.outputLines`, so writes to `test->outfile`. `iperf.h` describes the hook as "allow user apps to
the transcript grows while the test runs. receive the JSON strings, instead of writing them to the output file" — this is
its intended use.
The reader blocks in `fgets()` and only unblocks on EOF, so the pipe's write end An earlier design piped `test->outfile` to a reader thread. That imposed two
must be closed **before** `pthread_join()` — joining first deadlocks the worker constraints, both of which were gotten wrong and deadlocked every test: the
thread. The JNI caller solely owns the run-state struct and frees it after the pipe's write end had to be closed *before* joining a reader blocked in
join; the reader must never free it. `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 ### Stop Handling
A stop shuts down the control socket, so `iperf_run_client()` returns -1 with A stop shuts down the control socket, so `iperf_run_client()` returns -1 with