import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; /// Shared models of the network diagnostics: TCP connect ping, ICMP echo /// ping and traceroute. Every measurement runs directly from this device. /// Which IP versions a measurement uses. enum IpMode { /// The first address the system resolver returns (its preference order); /// the result shows which address and protocol were used. auto, v4, v6, /// IPv4 and IPv6 measured independently and at the same time. dual, } enum Family { v4, v6; String get label => this == v4 ? 'IPv4' : 'IPv6'; InternetAddressType get type => this == v4 ? InternetAddressType.IPv4 : InternetAddressType.IPv6; static Family of(InternetAddress a) => a.type == InternetAddressType.IPv6 ? v6 : v4; } enum MeasureKind { tcp, icmp, trace } /// Outcome of one attempt. enum Outcome { /// TCP handshake completed / ICMP echo reply received. ok, /// No answer within the timeout. timeout, /// The target answered with a TCP reset (port closed). refused, /// ICMP destination unreachable, or the OS reported no route. unreachable, /// An ICMP time exceeded instead of a reply (TTL too small for ping). ttlExceeded, /// The packet could not be sent or another local network error. error, } /// One attempt of a ping (TCP or ICMP). class Sample { final int seq; final DateTime time; final Family family; final String address; final int? port; final double? rttMs; final Outcome outcome; /// Extra information: ICMP code, errno, responding router. final String? detail; const Sample({ required this.seq, required this.time, required this.family, required this.address, required this.outcome, this.port, this.rttMs, this.detail, }); bool get ok => outcome == Outcome.ok && rttMs != null; Map toJson() => { 'seq': seq, 'time': time.toUtc().toIso8601String(), 'ipVersion': family.label, 'address': address, if (port != null) 'port': port, 'outcome': outcome.name, 'rttMs': rttMs, if (detail != null) 'detail': detail, }; } /// Summary statistics of a series of attempts. /// /// RTT figures use successful attempts only; failures are counted /// separately. Definitions: /// * standard deviation: population standard deviation of the RTTs; /// * jitter: mean absolute difference between consecutive successful RTTs /// (as in RFC 3550 §6.4.1, without its smoothing); /// * median and P95: nearest-rank percentiles; P95 needs at least 20 /// successful samples. class RttStats { final int attempts, successes; final double? min, max, mean, median, stddev, jitter, p95; final Duration elapsed; /// True when median/P95 were computed over only the newest samples. final bool windowed; const RttStats({ required this.attempts, required this.successes, this.min, this.max, this.mean, this.median, this.stddev, this.jitter, this.p95, this.elapsed = Duration.zero, this.windowed = false, }); int get failures => attempts - successes; double get successPercent => attempts == 0 ? 0 : 100 * successes / attempts; double get lossPercent => attempts == 0 ? 0 : 100 - successPercent; static const empty = RttStats(attempts: 0, successes: 0); /// Exact statistics of [rtts] (successful attempts in order) out of /// [attempts] attempts. factory RttStats.of( List rtts, { required int attempts, Duration elapsed = Duration.zero, }) { if (rtts.isEmpty) { return RttStats(attempts: attempts, successes: 0, elapsed: elapsed); } final n = rtts.length; final mean = rtts.reduce((a, b) => a + b) / n; var variance = 0.0; for (final r in rtts) { variance += (r - mean) * (r - mean); } var jitter = 0.0; for (var i = 1; i < n; i++) { jitter += (rtts[i] - rtts[i - 1]).abs(); } final sorted = [...rtts]..sort(); return RttStats( attempts: attempts, successes: n, min: sorted.first, max: sorted.last, mean: mean, median: percentile(sorted, 50), stddev: math.sqrt(variance / n), jitter: n < 2 ? null : jitter / (n - 1), p95: n < 20 ? null : percentile(sorted, 95), elapsed: elapsed, ); } Map toJson() => { 'attempts': attempts, 'successes': successes, 'failures': failures, 'successPercent': successPercent, 'minMs': min, 'maxMs': max, 'meanMs': mean, 'medianMs': median, 'stddevMs': stddev, 'jitterMs': jitter, 'p95Ms': p95, 'elapsedMs': elapsed.inMilliseconds, if (windowed) 'percentilesOverNewest': StatsAccumulator.window, }; } /// Nearest-rank percentile of an ascending list. double percentile(List sorted, double p) { final rank = (p / 100 * sorted.length).ceil().clamp(1, sorted.length); return sorted[rank - 1]; } /// Statistics for long or continuous measurements in bounded memory. /// /// Count, minimum, maximum, mean, standard deviation (Welford) and jitter /// are exact over all samples. Median and P95 are computed over the newest /// [window] successful samples once there are more than that. class StatsAccumulator { static const window = 10000; int attempts = 0, successes = 0; double? _min, _max, _last; double _mean = 0, _m2 = 0, _jitterSum = 0; int _jitterCount = 0; final _recent = ListQueue(); DateTime? start, end; void add(Sample s) { attempts++; start ??= s.time; end = s.time; if (!s.ok) return; final r = s.rttMs!; successes++; _min = _min == null ? r : math.min(_min!, r); _max = _max == null ? r : math.max(_max!, r); final delta = r - _mean; _mean += delta / successes; _m2 += delta * (r - _mean); if (_last != null) { _jitterSum += (r - _last!).abs(); _jitterCount++; } _last = r; _recent.addLast(r); if (_recent.length > window) _recent.removeFirst(); } RttStats stats([Duration? elapsed]) { final duration = elapsed ?? (start == null ? Duration.zero : end!.difference(start!)); if (successes == 0) { return RttStats(attempts: attempts, successes: 0, elapsed: duration); } final sorted = _recent.toList()..sort(); return RttStats( attempts: attempts, successes: successes, min: _min, max: _max, mean: _mean, median: percentile(sorted, 50), stddev: math.sqrt(_m2 / successes), jitter: _jitterCount == 0 ? null : _jitterSum / _jitterCount, p95: sorted.length < 20 ? null : percentile(sorted, 95), elapsed: duration, windowed: successes > window, ); } } /// A bounded list of the newest samples, for the log and the chart. class SampleLog { final int capacity; final _items = ListQueue(); int dropped = 0; SampleLog([this.capacity = 5000]); void add(Sample s) { _items.addLast(s); if (_items.length > capacity) { _items.removeFirst(); dropped++; } } List get items => List.unmodifiable(_items); int get length => _items.length; bool get isEmpty => _items.isEmpty; } /// One measured series: a single IP version of a ping. class Series { final Family family; final InternetAddress address; final log = SampleLog(); final stats = StatsAccumulator(); Series(this.family, this.address); void add(Sample s) { log.add(s); stats.add(s); } } /// Result of resolving a target for one IP version. class FamilyTarget { final Family family; final List addresses; /// Why there is no address: 'noAddressFamily' (no A/AAAA record), /// 'literalOtherFamily' (an IP literal of the other version), or a /// failure code. final String? missing; const FamilyTarget(this.family, this.addresses, [this.missing]); bool get available => addresses.isNotEmpty; } class Resolution { final String host; final List families; /// For automatic mode: the address the system resolver prefers. final InternetAddress? preferred; final int dnsMs; const Resolution(this.host, this.families, this.dnsMs, {this.preferred}); FamilyTarget? of(Family f) { for (final t in families) { if (t.family == f) return t; } return null; } } typedef Lookup = Future> Function(String, InternetAddressType); Future> systemLookup( String host, InternetAddressType type, ) => InternetAddress.lookup( host, type: type, ).timeout(const Duration(seconds: 10)); /// Whether a lookup failure means "no record of this type" rather than a /// broken resolver: an empty answer or the resolver's "no address" errors. bool _noRecord(Object e) { if (e is! SocketException) return false; final code = e.osError?.errorCode; // EAI_NODATA (Linux -5 / Darwin 7) and EAI_NONAME (Linux -2 / Darwin 8) // mean the name exists without records of the type, or does not exist. return code == -5 || code == 7 || code == -2 || code == 8; } /// Resolves [host] for [mode]. A/AAAA are looked up separately so that a /// missing record of one type is reported instead of silently using the /// other type. Future resolveTarget( String host, IpMode mode, { Lookup lookup = systemLookup, }) async { final watch = Stopwatch()..start(); final literal = InternetAddress.tryParse( host.startsWith('[') && host.endsWith(']') ? host.substring(1, host.length - 1) : host, ); final wanted = switch (mode) { IpMode.v4 => [Family.v4], IpMode.v6 => [Family.v6], IpMode.dual => [Family.v4, Family.v6], IpMode.auto => [], }; if (literal != null) { final family = Family.of(literal); if (mode == IpMode.auto) { return Resolution( host, [ FamilyTarget(family, [literal]), ], 0, preferred: literal, ); } return Resolution(host, [ for (final f in wanted) f == family ? FamilyTarget(f, [literal]) : FamilyTarget(f, const [], 'literalOtherFamily'), ], 0); } if (mode == IpMode.auto) { final all = await lookup(host, InternetAddressType.any); if (all.isEmpty) throw const SocketException('no address'); final preferred = all.first; final families = []; for (final f in Family.values) { final list = [ for (final a in all) if (Family.of(a) == f) a, ]; if (list.isNotEmpty) families.add(FamilyTarget(f, list)); } return Resolution( host, families, watch.elapsedMilliseconds, preferred: preferred, ); } final results = await Future.wait([ for (final f in wanted) lookup(host, f.type).then( (list) { final own = [ for (final a in list) if (Family.of(a) == f) a, ]; return own.isEmpty ? FamilyTarget(f, const [], 'noAddressFamily') : FamilyTarget(f, own); }, onError: (Object e) => _noRecord(e) ? FamilyTarget(f, const [], 'noAddressFamily') : FamilyTarget(f, const [], 'dnsFailed'), ), ]); if (!results.any((r) => r.available)) { // Neither version has an address: the name does not resolve at all. throw const SocketException('lookup failed'); } return Resolution(host, results, watch.elapsedMilliseconds); } /// The text of a TCP/ICMP ping report. String formatEndpoint(String address, int? port) { if (port == null) return address; return address.contains(':') ? '[$address]:$port' : '$address:$port'; } /// Report formats for copying and export. enum ReportFormat { txt, csv, json } String _csv(Object? v) { final s = v?.toString() ?? ''; return s.contains(RegExp(r'[",\n\r]')) ? '"${s.replaceAll('"', '""')}"' : s; } String _n(double? v) => v == null ? '' : v.toStringAsFixed(3); /// A ping report (TCP connect or ICMP echo) for [series]. String pingReport( ReportFormat format, { required MeasureKind kind, required String host, required IpMode mode, required Map settings, required List series, }) { final method = kind == MeasureKind.tcp ? 'TCP connect' : 'ICMP echo'; switch (format) { case ReportFormat.json: return const JsonEncoder.withIndent(' ').convert({ 'tool': 'SM Mobile', 'method': method, 'host': host, 'ipMode': mode.name, 'settings': settings, 'series': [ for (final s in series) { 'ipVersion': s.family.label, 'address': s.address.address, 'statistics': s.stats.stats().toJson(), if (s.log.dropped > 0) 'samplesOmitted': s.log.dropped, 'samples': [for (final x in s.log.items) x.toJson()], }, ], }); case ReportFormat.csv: final b = StringBuffer( 'ip_version,address,port,seq,time_utc,outcome,rtt_ms,detail\n', ); for (final s in series) { for (final x in s.log.items) { b.writeln( [ x.family.label, x.address, x.port ?? '', x.seq, x.time.toUtc().toIso8601String(), x.outcome.name, _n(x.rttMs), x.detail ?? '', ].map(_csv).join(','), ); } } return b.toString(); case ReportFormat.txt: final b = StringBuffer('SM Mobile · $method · $host (${mode.name})\n'); settings.forEach((k, v) => b.writeln('$k: $v')); for (final s in series) { final st = s.stats.stats(); b ..writeln() ..writeln('${s.family.label} ${s.address.address}'); for (final x in s.log.items) { b.writeln( '#${x.seq} ${x.time.toLocal().toIso8601String()} ' '${formatEndpoint(x.address, x.port)} ${x.outcome.name}' '${x.rttMs == null ? '' : ' ${_n(x.rttMs)} ms'}' '${x.detail == null ? '' : ' (${x.detail})'}', ); } b.writeln( 'attempts ${st.attempts}, ok ${st.successes}, failed ${st.failures}' ' (${st.successPercent.toStringAsFixed(1)} % ok)', ); if (st.successes > 0) { b.writeln( 'rtt min/avg/median/max ${_n(st.min)}/${_n(st.mean)}/' '${_n(st.median)}/${_n(st.max)} ms, stddev ${_n(st.stddev)} ms, ' 'jitter ${_n(st.jitter)} ms' '${st.p95 == null ? '' : ', p95 ${_n(st.p95)} ms'}', ); } } return b.toString(); } }