runNetTransportConformance function

void runNetTransportConformance(
  1. String backend, {
  2. required NetTransport create(),
  3. Future<void> settle()?,
  4. void tearDownAll()?,
})

Every rule NetTransport states, as tests, so that a backend proves it rather than intending it.

void main() {
  runNetTransportConformance('loopback', create: LoopbackNetTransport.new);
}

Why this lives in lib/ and not in test/

A conformance suite that only the package defining the interface can run is a suite that tests one implementation. Backends live in their own packages (good_net_p2p, and whatever comes after it), and a test directory is not importable across packages - so the suite ships as library code and each backend's test file is three lines calling it. The cost is a flutter_test dependency on this package, which is deliberate and is the whole reason it is here.

create must return a fresh transport each call; the suite builds several and expects them not to share state beyond the backend's switchboard.

settle is awaited on both sides of every flush, so that a backend which has to cross a socket gets the chance to. In-process backends leave it null. It is a nudge and not a guarantee - how long delivery really takes is what pump waits out.

Implementation

void runNetTransportConformance(
  String backend, {
  required NetTransport Function() create,
  Future<void> Function()? settle,
  void Function()? tearDownAll,
}) {
  /// Flushes every peer and then polls every peer, over and over, until
  /// [arrived] says the thing the caller is about to assert has happened.
  ///
  /// A fixed round count is a race with anything slower than a function call.
  /// It suits an in-process backend, where a send has landed by the time it
  /// returns, and it loses to a socket the moment a reliable message needs a
  /// retransmit - the test then fails on a timing accident rather than on the
  /// backend being wrong. Waiting for the condition costs the in-process case
  /// nothing, because its first round satisfies [arrived] and this returns
  /// without ever yielding, and it gives a socket the rounds it needs.
  ///
  /// [arrived] has to be something a working backend eventually makes true;
  /// "nobody received anything" cannot be waited for and is asserted after
  /// this returns, not passed in here.
  ///
  /// Reaching [limit] returns instead of failing, because the `expect` that
  /// follows names what is missing - `Expected: <1> Actual: <0>` - which
  /// beats anything this could say about a predicate it cannot describe.
  Future<void> pump(
    List<_Peer> peers,
    bool Function() arrived, {
    Duration limit = const Duration(seconds: 5),
  }) async {
    final deadline = DateTime.now().add(limit);
    while (true) {
      if (settle != null) await settle();
      for (var p = 0; p < peers.length; p++) {
        peers[p].transport.flush();
      }
      if (settle != null) await settle();
      for (var p = 0; p < peers.length; p++) {
        peers[p].pump();
      }
      if (arrived()) return;
      if (!DateTime.now().isBefore(deadline)) return;
      // A backend with no `settle` never yields inside a round, so a
      // condition that is never coming would spin the isolate flat for the
      // whole of `limit` and starve whatever it is waiting on.
      if (settle == null) await Future<void>.delayed(_idle);
    }
  }

  /// Every joiner is on the host's roster, and the host has been told about
  /// each of them. The client side of a join is done by the time
  /// `NetTransport.join` completes; this is the half that needs a poll.
  bool joinedUp(_Peer host, int clients) =>
      host.joined.length == clients &&
      (host.transport.session?.peerCount ?? -1) == clients;

  group('$backend: NetTransport conformance', () {
    final peers = <_Peer>[];

    _Peer peer() {
      final made = _Peer(create());
      peers.add(made);
      return made;
    }

    tearDown(() async {
      for (var i = 0; i < peers.length; i++) {
        await peers[i].transport.close();
      }
      peers.clear();
      if (tearDownAll != null) tearDownAll();
    });

    test('a hosted session names this peer as the host', () async {
      final host = peer();
      final session = await host.transport.host(
        const SessionOptions(name: 'kitchen'),
      );

      expect(session.isHost, isTrue);
      expect(session.localPeer, NetPeerId.host);
      expect(session.name, 'kitchen');
      expect(session.peerCount, 0, reason: 'nobody else has joined yet');
      expect(
        SessionId(session.id).isWellFormed,
        isTrue,
        reason: 'a generated code is one a player can retype',
      );
    });

    test('joining puts both peers on each other roster', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      expect(joined.isHost, isFalse);
      expect(joined.localPeer.isHost, isFalse);
      expect(joined.id, hosted.id);
      expect(joined.peerCount, 1);
      expect(joined.peerAt(0), NetPeerId.host);
      expect(joined.connectionTo(NetPeerId.host), isNotNull);

      expect(hosted.peerCount, 1);
      expect(hosted.peerAt(0), joined.localPeer);
      expect(hosted.connectionTo(joined.localPeer), isNotNull);
      expect(host.joined, <NetPeerId>[
        joined.localPeer,
      ], reason: 'the host is told who arrived, once');
    });

    test('a message crosses in both directions, unchanged', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      final up = Uint8List.fromList(<int>[1, 2, 3, 250]);
      joined.connectionTo(NetPeerId.host)!.send(NetChannel.reliable, up);
      await pump(peers, () => host.received.length == 1);

      expect(host.received.length, 1);
      expect(host.received.single.bytes, up);
      expect(host.received.single.from, joined.localPeer);
      expect(host.received.single.channel, NetChannel.reliable);

      final down = Uint8List.fromList(<int>[9, 8, 7]);
      host.transport.session!
          .connectionTo(joined.localPeer)!
          .send(NetChannel.reliable, down);
      await pump(peers, () => client.received.length == 1);

      expect(client.received.length, 1);
      expect(client.received.single.bytes, down);
      expect(
        client.received.single.from,
        NetPeerId.host,
        reason: 'a client hears from the host under the host id',
      );
    });

    test('a slice of a bigger buffer sends only that slice', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      final scratch = Uint8List.fromList(<int>[0, 0, 42, 43, 0]);
      joined
          .connectionTo(NetPeerId.host)!
          .send(NetChannel.reliable, scratch, 2, 2);
      await pump(peers, () => host.received.length == 1);

      expect(
        host.received.single.bytes,
        Uint8List.fromList(<int>[42, 43]),
        reason:
            'offset and length are how a caller sends out of a buffer it '
            'reuses, so a backend that ignores them corrupts every batch',
      );
    });

    test('reliable messages arrive in the order they were sent', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      final connection = joined.connectionTo(NetPeerId.host)!;
      for (var i = 0; i < 20; i++) {
        connection.send(NetChannel.reliable, Uint8List.fromList(<int>[i]));
      }
      await pump(peers, () => host.received.length == 20);

      expect(host.received.length, 20);
      for (var i = 0; i < 20; i++) {
        expect(host.received[i].bytes.single, i);
      }
    });

    test(
      'an unreliable message arrives over a link that is not lossy',
      () async {
        final host = peer();
        final client = peer();
        final hosted = await host.transport.host();
        final joined = await client.transport.join(hosted.id);
        await pump(peers, () => joinedUp(host, 1));

        joined
            .connectionTo(NetPeerId.host)!
            .send(NetChannel.unreliable, Uint8List.fromList(<int>[77]));
        await pump(peers, () => host.received.length == 1);

        expect(host.received.length, 1);
        expect(host.received.single.channel, NetChannel.unreliable);
        expect(host.received.single.bytes.single, 77);
      },
    );

    test('a payload far larger than one datagram round-trips', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      final big = Uint8List(8000);
      for (var i = 0; i < big.length; i++) {
        big[i] = i & 0xFF;
      }
      joined.connectionTo(NetPeerId.host)!.send(NetChannel.reliable, big);
      await pump(peers, () => host.received.length == 1);

      expect(host.received.length, 1);
      expect(host.received.single.bytes, big);
    });

    test('sendToAll reaches every client and not the sender', () async {
      final host = peer();
      final one = peer();
      final two = peer();
      final hosted = await host.transport.host();
      await one.transport.join(hosted.id);
      await two.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 2));

      hosted.sendToAll(NetChannel.reliable, Uint8List.fromList(<int>[5]));
      await pump(
        peers,
        () => one.received.length == 1 && two.received.length == 1,
      );

      expect(one.received.length, 1);
      expect(two.received.length, 1);
      expect(host.received, isEmpty);
    });

    test('nothing is delivered outside poll', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));
      host.received.clear();

      joined
          .connectionTo(NetPeerId.host)!
          .send(NetChannel.reliable, Uint8List.fromList(<int>[1]));
      client.transport.flush();
      if (settle != null) await settle();

      expect(
        host.received,
        isEmpty,
        reason:
            'a simulation consumes input at one point in its tick; a backend '
            'that delivers from its socket callback lands half a burst inside '
            'the tick and half outside it',
      );

      host.pump();
      expect(host.received.length, 1);
    });

    test('a client leaving is reported to the host', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      final joined = await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));
      final id = joined.localPeer;

      await joined.leave();
      await pump(peers, () => host.left.length == 1 && client.closed != null);

      expect(host.left.length, 1);
      expect(host.left.single.peer, id);
      expect(host.transport.session!.peerCount, 0);
      expect(host.transport.session!.hasPeer(id), isFalse);
      expect(
        client.closed,
        isNotNull,
        reason: 'the peer that left is told its session ended',
      );
      expect(client.transport.session, isNull);
    });

    test('the host closing ends the session for the clients', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host();
      await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      await hosted.leave();
      await pump(peers, () => client.closed != null);

      expect(client.closed, isNotNull);
      expect(client.transport.session, isNull);
      expect(host.transport.session, isNull);
    });

    test('a reused slot is a different peer id', () async {
      final host = peer();
      final first = peer();
      final hosted = await host.transport.host();
      final firstSession = await first.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));
      final firstId = firstSession.localPeer;

      await firstSession.leave();
      await pump(peers, () => host.left.length == 1);

      final second = peer();
      final secondSession = await second.transport.join(hosted.id);
      await pump(peers, () => host.joined.length == 2);
      final secondId = secondSession.localPeer;

      expect(
        secondId.slot,
        firstId.slot,
        reason: 'slots are dense and get reused - that is what they are for',
      );
      expect(
        secondId,
        isNot(firstId),
        reason:
            'a packet still in flight from the peer that left must not be '
            'delivered as if the new one had sent it',
      );
      expect(secondId.generation, firstId.generation + 1);
    });

    test('joining a code nobody is hosting fails', () async {
      final client = peer();
      await expectLater(
        client.transport.join(SessionId.random(length: 10)),
        throwsA(isA<NetException>()),
      );
      expect(client.transport.session, isNull);
    });

    test('a full session refuses the next joiner', () async {
      final host = peer();
      final client = peer();
      final hosted = await host.transport.host(
        const SessionOptions(maxPeers: 2),
      );
      await client.transport.join(hosted.id);
      await pump(peers, () => joinedUp(host, 1));

      final late = peer();
      await expectLater(
        late.transport.join(hosted.id),
        throwsA(isA<NetException>()),
      );
    });

    test('a peer running a different build is refused', () async {
      final host = peer();
      final client = peer();
      host.transport.bindSchema(0xABCDEF);
      client.transport.bindSchema(0x123456);
      final hosted = await host.transport.host();

      await expectLater(
        client.transport.join(hosted.id),
        throwsA(isA<NetException>()),
        reason:
            'index-on-the-wire means build skew reads one message as another; '
            'refusing the join is the only honest answer',
      );
    });
  });
}