From fc158975416a841f8f50bf4b5a659ff2d4936faa Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 20:24:44 +0900 Subject: [PATCH] feat: add a headless entry point that runs a test with no UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/main_headless.dart | 105 ++++++++++++++++++++++++++++++++++++ test/architecture_test.dart | 22 +++++++- 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 lib/main_headless.dart diff --git a/lib/main_headless.dart b/lib/main_headless.dart new file mode 100644 index 0000000..143aedf --- /dev/null +++ b/lib/main_headless.dart @@ -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 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'); diff --git a/test/architecture_test.dart b/test/architecture_test.dart index 20725d8..0c9846f 100644 --- a/test/architecture_test.dart +++ b/test/architecture_test.dart @@ -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? 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)!;