import 'dart:async'; import 'dart:io'; import 'measure.dart'; /// Settings of a TCP connect measurement. [count] 0 means continuous until /// stopped. class TcpPingSettings { final int port, count; final Duration interval, timeout; const TcpPingSettings({ required this.port, this.count = 4, this.interval = const Duration(seconds: 1), this.timeout = const Duration(seconds: 2), }); Map toJson() => { 'port': port, 'count': count == 0 ? 'continuous' : count, 'intervalMs': interval.inMilliseconds, 'timeoutMs': timeout.inMilliseconds, }; } /// A running measurement; [cancel] stops it at once, also aborting a /// connection attempt in progress. class PingRun { final Stream samples; final Future Function() cancel; PingRun(this.samples, this.cancel); } /// errno values for a refused connection and for "no route" (Darwin, /// Linux/Android). const _refused = {61, 111}; const _timedOut = {60, 110}; const _unreachable = {51, 65, 101, 113}; /// Classifies a failed connect. A connection refused (TCP reset from the /// target) is never reported as a timeout. (Outcome, String?) classifyConnectError(Object e, {required bool timedOut}) { if (e is SocketException) { final code = e.osError?.errorCode; if (code != null) { if (_refused.contains(code)) return (Outcome.refused, 'errno $code'); if (_timedOut.contains(code)) return (Outcome.timeout, 'errno $code'); if (_unreachable.contains(code)) { return (Outcome.unreachable, 'errno $code'); } return (Outcome.error, '${e.osError!.message} (errno $code)'); } if (timedOut || e.message.toLowerCase().contains('timed out')) { return (Outcome.timeout, null); } return (Outcome.error, e.message); } if (e is TimeoutException) return (Outcome.timeout, null); return (Outcome.error, e.toString()); } typedef Connector = Future> Function(InternetAddress, int); Future> _connect(InternetAddress a, int port) => Socket.startConnect(a, port); /// Measures how long the TCP three-way handshake to [address]:[port] takes. /// This is TCP connect timing, not an ICMP echo: a closed port answers with /// a reset (refused), a filtered one often not at all (timeout). PingRun tcpPing( InternetAddress address, TcpPingSettings settings, { Connector connect = _connect, }) { final out = StreamController(); var stopped = false; ConnectionTask? pending; Timer? wait; Completer? sleeping; final family = Family.of(address); Future loop() async { var seq = 0; final clock = Stopwatch()..start(); var nextStart = Duration.zero; while (!stopped && (settings.count == 0 || seq < settings.count)) { final delay = nextStart - clock.elapsed; if (delay > Duration.zero) { final done = sleeping = Completer(); wait = Timer(delay, () { if (!done.isCompleted) done.complete(); }); await done.future; if (stopped) break; } seq++; final started = clock.elapsed; nextStart = started + settings.interval; final time = DateTime.now(); final watch = Stopwatch()..start(); Sample sample; try { final task = pending = await connect(address, settings.port); Socket socket; try { socket = await task.socket.timeout(settings.timeout); } on TimeoutException { task.cancel(); rethrow; } final ms = watch.elapsedMicroseconds / 1000; socket.destroy(); sample = Sample( seq: seq, time: time, family: family, address: address.address, port: settings.port, rttMs: ms, outcome: Outcome.ok, ); } catch (e) { if (stopped) break; final (outcome, detail) = classifyConnectError( e, timedOut: watch.elapsed >= settings.timeout, ); sample = Sample( seq: seq, time: time, family: family, address: address.address, port: settings.port, outcome: outcome, // A refusal still measures a full round trip to the target. rttMs: outcome == Outcome.refused ? watch.elapsedMicroseconds / 1000 : null, detail: detail, ); } finally { pending = null; } if (stopped) break; out.add(sample); } await out.close(); } Future cancel() async { if (stopped) return; stopped = true; pending?.cancel(); wait?.cancel(); if (sleeping != null && !sleeping!.isCompleted) sleeping!.complete(); if (!out.isClosed) unawaited(out.close()); } out.onListen = () => unawaited(loop()); out.onCancel = cancel; return PingRun(out.stream, cancel); }