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:
@@ -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');
|
||||||
@@ -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', () {
|
test('every layer directory is actually covered by these rules', () {
|
||||||
// Guards against a new top-level layer appearing and silently escaping
|
// Guards against a new top-level layer appearing and silently escaping
|
||||||
// every rule above.
|
// every rule above.
|
||||||
@@ -102,8 +118,9 @@ void main() {
|
|||||||
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
||||||
List<({String file, String import})> _importsMatching(
|
List<({String file, String import})> _importsMatching(
|
||||||
String directory,
|
String directory,
|
||||||
bool Function(String import) test,
|
bool Function(String import) test, {
|
||||||
) {
|
Set<String>? onlyFiles,
|
||||||
|
}) {
|
||||||
final results = <({String file, String import})>[];
|
final results = <({String file, String import})>[];
|
||||||
final dir = Directory(directory);
|
final dir = Directory(directory);
|
||||||
if (!dir.existsSync()) return results;
|
if (!dir.existsSync()) return results;
|
||||||
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
|
|||||||
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
||||||
for (final entity in dir.listSync(recursive: true)) {
|
for (final entity in dir.listSync(recursive: true)) {
|
||||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||||
|
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
|
||||||
final source = entity.readAsStringSync();
|
final source = entity.readAsStringSync();
|
||||||
for (final match in pattern.allMatches(source)) {
|
for (final match in pattern.allMatches(source)) {
|
||||||
final import = match.group(1)!;
|
final import = match.group(1)!;
|
||||||
|
|||||||
Reference in New Issue
Block a user