feat: add a headless entry point that runs a test with no UI

Separation that nothing exercises is separation that quietly stops being true,
so this 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, this stops working. An architecture test
additionally forbids it importing lib/ui.

It is also the accurate way to measure. A 4-stream run against a local server
reports 30.2% local CPU here, against 79.9% for a comparable run with the
transcript repainting alongside — the UI was costing roughly fifty points of
CPU on a phone already near CPU-bound at gigabit rates. Throughput figures
taken through the UI are therefore pessimistic, and this is the path to use
when the number matters.

Parameters come from --dart-define so a run needs no code edit:
  flutter run -t lib/main_headless.dart --dart-define=IPERF_SERVER=…

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:24:44 +09:00
parent 9485049b57
commit fc15897541
2 changed files with 125 additions and 2 deletions
+105
View File
@@ -0,0 +1,105 @@
// Headless entry point: runs one iperf3 test with no widgets.
//
// This exists to keep the core honest. If anything in the run path ever starts
// depending on the UI, this stops compiling or stops working — a claim about
// separation that is checked rather than asserted.
//
// It is also the low-noise way to measure. The UI competes with the test for
// CPU (a gigabit run can put the phone near CPU-bound), so figures taken here
// are cleaner than figures taken with a transcript repainting alongside.
//
// flutter run -t lib/main_headless.dart \
// --dart-define=IPERF_SERVER=192.168.2.182 \
// --dart-define=IPERF_STREAMS=4
//
// Nothing below imports lib/ui, and test/architecture_test.dart enforces that.
import 'package:flutter/widgets.dart' show WidgetsFlutterBinding;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:iperf3gui/data/services/iperf3_service.dart';
import 'package:iperf3gui/domain/models/iperf3_event.dart';
import 'package:iperf3gui/domain/models/iperf3_session.dart';
import 'package:iperf3gui/domain/models/test_configuration.dart';
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
/// Test parameters, supplied at build time with --dart-define.
const String _server = String.fromEnvironment('IPERF_SERVER',
defaultValue: 'iperf.es.net');
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
Future<void> main() async {
// Required for the MethodChannel. Notably NOT runApp() — there is no widget
// tree, no render surface and no Flutter UI thread work in this process.
WidgetsFlutterBinding.ensureInitialized();
const config = TestConfiguration(
server: _server,
port: _port,
duration: Duration(seconds: _seconds),
parallelStreams: _streams,
);
final Iperf3Runner runner = Iperf3Service();
final session = Iperf3Session();
final subscription = runner.events.listen((event) {
session.add(event);
if (event is Iperf3Interval && !event.omitted) {
_out('interval ${event.start.toStringAsFixed(2)}-'
'${event.end.toStringAsFixed(2)}s '
'${_mbits(event.bitsPerSecond)} Mbit/s'
'${event.retransmits != null ? ' retr=${event.retransmits}' : ''}');
} else if (event is Iperf3Error) {
_out('error: ${event.message}');
}
});
_out('running iperf3 -c ${config.server} -p ${config.port} '
'-t ${config.duration.inSeconds} -P ${config.parallelStreams}');
final result = await runner.run(config);
await subscription.cancel();
await runner.dispose();
_report(result.exitCode, session);
}
void _report(int exitCode, Iperf3Session session) {
_out('--- result ---');
_out('exit code: $exitCode');
_out('samples: ${session.sampleCount}');
final mean = session.meanBitsPerSecond;
final peak = session.peakBitsPerSecond;
if (mean != null) _out('mean: ${_mbits(mean)} Mbit/s');
if (peak != null) _out('peak: ${_mbits(peak)} Mbit/s');
final retr = session.totalRetransmits;
_out('retransmits: ${retr ?? 'not measured'}');
final sent = session.summary?.sent;
final received = session.summary?.received;
if (sent != null) {
_out('sent: ${_mbits(sent.bitsPerSecond)} Mbit/s '
'over ${sent.seconds.toStringAsFixed(2)}s');
}
if (received != null) {
_out('received: ${_mbits(received.bitsPerSecond)} Mbit/s '
'over ${received.seconds.toStringAsFixed(2)}s');
}
final cpu = session.summary?.hostCpuPercent;
if (cpu != null) _out('local cpu: ${cpu.toStringAsFixed(1)}%');
if (session.stopped) _out('ended early: stopped');
for (final error in session.errors) {
_out('error: $error');
}
}
String _mbits(double bitsPerSecond) =>
(bitsPerSecond / 1e6).toStringAsFixed(1);
void _out(String line) => debugPrint('[iperf3-headless] $line');
+20 -2
View File
@@ -82,6 +82,22 @@ void main() {
);
});
test('the headless entry point does not depend on ui', () {
// main_headless.dart is the standing proof that the core runs without a
// widget tree; an import from lib/ui would quietly end that.
final offenders = _importsMatching(
'lib',
(import) => import.contains('/ui/'),
onlyFiles: {'lib/main_headless.dart'},
);
expect(
offenders,
isEmpty,
reason: 'The headless entry point must run the core with no UI.\n'
'${_render(offenders)}',
);
});
test('every layer directory is actually covered by these rules', () {
// Guards against a new top-level layer appearing and silently escaping
// every rule above.
@@ -102,8 +118,9 @@ void main() {
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
List<({String file, String import})> _importsMatching(
String directory,
bool Function(String import) test,
) {
bool Function(String import) test, {
Set<String>? onlyFiles,
}) {
final results = <({String file, String import})>[];
final dir = Directory(directory);
if (!dir.existsSync()) return results;
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
final source = entity.readAsStringSync();
for (final match in pattern.allMatches(source)) {
final import = match.group(1)!;