forked from Foxyz/sync_replay_project
Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -1,9 +1,6 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): both
|
||||
// tests pin today's rules (a silent member is stale, then removed; a reset
|
||||
// socket loses its member, so the redial is a new one, P1); with grace they
|
||||
// expect `gone` and the same member back.
|
||||
//
|
||||
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests").
|
||||
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests"):
|
||||
// a dead link makes the member `gone` (kept with everything it had), and
|
||||
// the same app dialling again gets the same member back (P1).
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
@@ -22,7 +19,7 @@ class _Client {
|
||||
Timer? _reports;
|
||||
late final StreamSubscription<dynamic> _sub;
|
||||
|
||||
_Client(int port, {String resume = ''})
|
||||
_Client(int port, {int seq = 1})
|
||||
: ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:$port/sync')) {
|
||||
ch.ready.then((_) {}, onError: (_) {});
|
||||
_sub = ch.stream.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
|
||||
@@ -31,11 +28,15 @@ class _Client {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'alice'),
|
||||
media: const HelloMedia(name: 'f.bin', size: 1, duration: 10.0),
|
||||
resume: resume)
|
||||
client: clientId,
|
||||
seq: seq)
|
||||
.toJson()));
|
||||
}
|
||||
|
||||
static const clientId = 'flakyLinkTestClient01';
|
||||
|
||||
void send(Map<String, Object?> j) => ch.sink.add(jsonEncode(j));
|
||||
|
||||
Map<String, Object?>? get welcome => frames.where((f) => f['t'] == 'welcome').firstOrNull;
|
||||
String get token => welcome!['token'] as String;
|
||||
String get memberId => welcome!['memberId'] as String;
|
||||
@@ -73,7 +74,7 @@ void main() {
|
||||
setUp(() async {
|
||||
// Short limits so the demo runs in a couple of seconds.
|
||||
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
|
||||
liveness: const Liveness(staleAfter: Duration(milliseconds: 600), dropAfter: Duration(milliseconds: 1500)));
|
||||
liveness: const Liveness(silence: Duration(milliseconds: 600)));
|
||||
await server.start();
|
||||
proxy = await FlakyProxy.start(server.boundPort);
|
||||
});
|
||||
@@ -87,25 +88,34 @@ void main() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
_Client dial({String resume = ''}) {
|
||||
final c = _Client(proxy.port, resume: resume);
|
||||
_Client dial({int seq = 1}) {
|
||||
final c = _Client(proxy.port, seq: seq);
|
||||
clients.add(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
test('a blackholed client goes stale, then is removed; it never hears of it', () async {
|
||||
test('a blackholed client goes gone with its ready flag, queue item and relay; it comes back as itself', () async {
|
||||
final c = dial();
|
||||
await until(() => c.welcome != null, 'welcome');
|
||||
c.startReports();
|
||||
final engine = server.hub.engine;
|
||||
EngineMember? member() => engine.members[c.token];
|
||||
c.send(const ReadyIntent(ready: true).toJson());
|
||||
c.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f', key: 'key-of-f-1'))
|
||||
.toJson());
|
||||
c.send(const SwapIntent(media: HelloMedia(name: 'mine.mkv', size: 100, duration: 0, relay: MediaRelay())).toJson());
|
||||
await until(() => engine.queue.length == 1 && server.hub.relay != null && member()!.ready, 'ready, queued, relayed');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
expect(engine.isStale(member()!), isFalse, reason: 'reports through the proxy keep it fresh');
|
||||
expect(member()!.presence, isNot(Presence.gone), reason: 'reports through the proxy keep it present');
|
||||
|
||||
proxy.last!.blackhole();
|
||||
final seen = c.frames.length;
|
||||
await until(() => member() != null && engine.isStale(member()!), 'stale');
|
||||
await until(() => member() == null, 'removed');
|
||||
await until(() => member()?.presence == Presence.gone, 'gone');
|
||||
expect(member()!.ready, isTrue);
|
||||
expect(engine.queue.items.single.by, c.memberId);
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay lives through the grace (D6)');
|
||||
expect(engine.media!.relay!.sourceGone, isTrue);
|
||||
expect(server.hub.sockets.containsKey(c.token), isFalse);
|
||||
// Half-open: the server's close never reached the client.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
@@ -113,20 +123,30 @@ void main() {
|
||||
expect(c.frames.length, seen);
|
||||
expect(proxy.last!.isClosed, isFalse);
|
||||
expect(proxy.last!.dropped, greaterThan(0));
|
||||
|
||||
final again = dial(seq: 2);
|
||||
await until(() => again.welcome != null, 'welcome after redial');
|
||||
expect(again.welcome!['resumed'], isTrue);
|
||||
expect([again.memberId, again.token], [c.memberId, c.token]);
|
||||
expect(engine.members, hasLength(1));
|
||||
expect(member()!.presence, Presence.joining, reason: 'media is up: it loads, then comes in (D2)');
|
||||
expect(member()!.ready, isTrue);
|
||||
expect(engine.media!.relay!.sourceGone, isFalse);
|
||||
final actions = [for (final f in again.frames) if (f['t'] == 'feed') f['action']];
|
||||
expect(actions, isNot(contains('join')), reason: 'a return, not a join');
|
||||
});
|
||||
|
||||
test('a reset client sees the drop at once and redials; today it comes back as a new member', () async {
|
||||
test('a reset client sees the drop at once and redials into the same member', () async {
|
||||
final c = dial();
|
||||
await until(() => c.welcome != null, 'welcome');
|
||||
final first = c.memberId;
|
||||
proxy.last!.reset();
|
||||
await until(() => c.ended, 'client sees the reset');
|
||||
await until(() => !server.hub.engine.members.containsKey(c.token), 'server forgets the socket');
|
||||
await until(() => server.hub.engine.members[c.token]?.presence == Presence.gone, 'server sees it gone');
|
||||
|
||||
final again = dial(resume: c.token);
|
||||
final again = dial(seq: 2);
|
||||
await until(() => again.welcome != null, 'welcome after redial');
|
||||
expect(proxy.links, hasLength(2));
|
||||
expect(again.memberId, isNot(first), reason: 'P1: no grace yet');
|
||||
expect([again.memberId, again.token], [c.memberId, c.token]);
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): "anyone
|
||||
// sits anyone out" (Away); the rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -25,7 +23,6 @@ class _Peer {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey)
|
||||
.toJson()));
|
||||
await p.next('welcome');
|
||||
@@ -81,6 +78,7 @@ void main() {
|
||||
a.send('{"t":"report","position":1e400,"buffered":1e400,"eventId":0}');
|
||||
final err = await a.next('err');
|
||||
expect('${err['message']}', contains('finite'));
|
||||
expect([err['code'], err['for']], ['bad', 'seek']);
|
||||
expect(server.hub.engine.position, 0);
|
||||
// still alive: a valid seek round-trips through a broadcast
|
||||
a.send('{"t":"seek","position":5}');
|
||||
@@ -125,7 +123,7 @@ void main() {
|
||||
await a.ws.close();
|
||||
});
|
||||
|
||||
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
|
||||
test('anyone sets anyone away (host too), only oneself comes back in; nudges reach only the target, capped', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
@@ -137,19 +135,23 @@ void main() {
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||
EngineMember of(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name);
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
|
||||
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.away).toJson()));
|
||||
var feed = await friend.next('feed');
|
||||
while (feed['action'] != 'out') {
|
||||
while (feed['action'] != 'away') {
|
||||
feed = await friend.next('feed');
|
||||
}
|
||||
expect([feed['actor'], feed['target']], ['friend', 'host']);
|
||||
expect(of('host').out, isTrue);
|
||||
expect(of('host').inSet, isFalse);
|
||||
expect([feed['actor'], feed['target'], feed['actorId'], feed['targetId']],
|
||||
['friend', 'host', idOf('friend'), idOf('host')]);
|
||||
expect(of('host').presence, Presence.away);
|
||||
expect(server.hub.engine.countsForGate(of('host')), isFalse);
|
||||
// Only oneself comes back.
|
||||
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
|
||||
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.watching).toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect([err['code'], err['for']], ['forbidden', 'presence']);
|
||||
expect(of('host').presence, Presence.away);
|
||||
host.send(jsonEncode(const PresenceIntent(state: Presence.watching).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
expect(of('host').out, isFalse);
|
||||
expect(of('host').presence, Presence.watching, reason: 'no media up: nothing to load');
|
||||
|
||||
// 5 at once: 3 pass (per second), only to the target.
|
||||
for (var i = 0; i < 5; i++) {
|
||||
@@ -224,8 +226,7 @@ void main() {
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'a'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'a'))
|
||||
.toJson()));
|
||||
final w = await p.next('welcome');
|
||||
expect('${w['mediaUrl']}', startsWith('http://127.0.0.1:${server.boundPort}/m/'));
|
||||
@@ -239,8 +240,7 @@ void main() {
|
||||
ws2.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'c'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'c'))
|
||||
.toJson()));
|
||||
final w2 = await p2.next('welcome');
|
||||
expect('${w2['mediaUrl']}', startsWith('http://$lan:${server.boundPort}/m/'));
|
||||
|
||||
@@ -54,8 +54,7 @@ void main() {
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: const HelloUser(name: 'live'),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: const HelloUser(name: 'live'))
|
||||
.toJson()));
|
||||
final welcome = await frames.firstWhere((f) => f['t'] == 'welcome').timeout(const Duration(seconds: 5));
|
||||
ws.add(jsonEncode(SwapIntent(
|
||||
|
||||
@@ -133,9 +133,7 @@ class _Client {
|
||||
c.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: '',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
).toJson());
|
||||
user: HelloUser(name: name)).toJson());
|
||||
return (c, await c._welcome.future.timeout(const Duration(seconds: 5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): a
|
||||
// socket close no longer removes the member (explicit `bye`, grace).
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -74,14 +72,22 @@ void main() {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('clean client close removes member within seconds, not timeout',
|
||||
() async {
|
||||
test('a clean close is not a leave: the member is gone at once, kept', () async {
|
||||
final client = await _ProbeClient.hello(server.boundPort, 'alice');
|
||||
await _pollBool(
|
||||
() => server.hub.engine.members.length == 1, 'join visible');
|
||||
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
|
||||
await client.ws.close();
|
||||
await _pollBool(
|
||||
() => server.hub.engine.members.isEmpty, 'member removed');
|
||||
await _pollBool(() => server.hub.engine.members.values.single.presence == Presence.gone, 'gone');
|
||||
expect(server.session.tokens, hasLength(1), reason: 'its token works through the grace');
|
||||
expect(server.hub.sockets, isEmpty);
|
||||
});
|
||||
|
||||
test('bye leaves at once: member, token and socket go', () async {
|
||||
final client = await _ProbeClient.hello(server.boundPort, 'alice');
|
||||
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
|
||||
client.ws.add(jsonEncode(const ByeMessage().toJson()));
|
||||
final outcome = await client.closed.future.timeout(const Duration(seconds: 5));
|
||||
expect(outcome, contains('code=${CloseCodes.left}'));
|
||||
expect(server.hub.engine.members, isEmpty);
|
||||
expect(server.session.tokens, isEmpty);
|
||||
expect(server.hub.sockets, isEmpty);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Identity, grace and the feed across reconnects, on a fake clock: the
|
||||
// server's limits ([Liveness]) are stepped through, not waited for.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _Peer {
|
||||
final WebSocket ws;
|
||||
final frames = <Map<String, Object?>>[];
|
||||
late final WelcomeMessage welcome;
|
||||
_Peer(this.ws) {
|
||||
ws.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)), onError: (_) {});
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name, {String client = '', int seq = 0, int? feedAfter}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
secret: 's', user: HelloUser(name: name), client: client, seq: seq, app: '1.3.0', feedAfter: feedAfter)
|
||||
.toJson()));
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!p.frames.any((f) => f['t'] == 'welcome')) {
|
||||
if (DateTime.now().isAfter(end)) fail('no welcome: ${p.frames}');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
p.welcome = WelcomeMessage.fromJson(p.frames.firstWhere((f) => f['t'] == 'welcome'));
|
||||
return p;
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> get feed => [for (final f in frames) if (f['t'] == 'feed') f];
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() ok, String what) async {
|
||||
final end = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!ok()) {
|
||||
if (DateTime.now().isAfter(end)) fail('timed out: $what');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const install = 'presenceTestInstall001';
|
||||
late DateTime t;
|
||||
late SyncServer server;
|
||||
final peers = <_Peer>[];
|
||||
|
||||
Future<_Peer> join(String name, {String client = '', int seq = 0, int? feedAfter}) async {
|
||||
final p = await _Peer.join(server.boundPort, name, client: client, seq: seq, feedAfter: feedAfter);
|
||||
peers.add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
/// Moves the server's clock on and runs one heartbeat.
|
||||
void step(Duration d) {
|
||||
t = t.add(d);
|
||||
server.hub.heartbeat();
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
t = DateTime.utc(2026, 1, 1);
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final p in peers) {
|
||||
await p.ws.close();
|
||||
}
|
||||
peers.clear();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('a gone member leaves when its grace runs out; it comes back as a new member', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
expect(alice.welcome.grace, RelayConstants.graceSecs);
|
||||
// Bob keeps talking; alice says nothing.
|
||||
for (var i = 0; i < 59; i++) {
|
||||
server.hub.engine.touch(bob.welcome.token);
|
||||
step(const Duration(seconds: 10));
|
||||
}
|
||||
expect(server.hub.engine.byClient(install), isNotNull, reason: '9 min 50 s: still kept');
|
||||
expect(server.session.tokens.containsKey(alice.welcome.token), isTrue);
|
||||
server.hub.engine.touch(bob.welcome.token);
|
||||
step(const Duration(seconds: 10));
|
||||
expect(server.hub.engine.byClient(install), isNull);
|
||||
expect(server.session.tokens.containsKey(alice.welcome.token), isFalse, reason: 'its URLs stop working');
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'leave'), 'leave line');
|
||||
final leave = bob.feed.firstWhere((f) => f['action'] == 'leave');
|
||||
expect([leave['actorId'], leave['reason']], [alice.welcome.memberId, 'grace']);
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect(again.welcome.resumed, isFalse);
|
||||
expect(again.welcome.memberId, isNot(alice.welcome.memberId));
|
||||
});
|
||||
|
||||
test('a quick return says nothing; a slow one says gone, then return; never join', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
await _until(
|
||||
() => bob.frames.any((f) =>
|
||||
f['t'] == 'state' && (f['members'] as List).any((m) => (m as Map)['presence'] == 'gone')),
|
||||
'presence in state at once');
|
||||
step(const Duration(seconds: 5));
|
||||
final quick = await join('alice', client: install, seq: 2);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(quick.welcome.resumed, isTrue);
|
||||
expect(bob.feed.map((f) => f['action']), isNot(anyOf(contains('gone'), contains('return'), contains('leave'))));
|
||||
expect(bob.feed.where((f) => f['action'] == 'join'), hasLength(1), reason: "bob's own join only");
|
||||
await quick.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone again');
|
||||
step(const Duration(seconds: 10));
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'gone'), 'gone line after 10 s');
|
||||
await join('alice', client: install, seq: 3);
|
||||
await _until(() => bob.feed.any((f) => f['action'] == 'return'), 'return line');
|
||||
final ret = bob.feed.firstWhere((f) => f['action'] == 'return');
|
||||
expect([ret['actor'], ret['actorId']], ['alice', alice.welcome.memberId]);
|
||||
});
|
||||
|
||||
test('feed lines carry ids and seq; a reconnect replays what came after feedAfter', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
bob.ws.add(jsonEncode(const ReadyIntent(ready: true).toJson()));
|
||||
await _until(() => alice.feed.any((f) => f['action'] == 'ready'), 'ready line');
|
||||
final seqs = [for (final f in alice.feed) f['seq'] as int];
|
||||
expect(seqs, orderedEquals([...seqs]..sort()));
|
||||
final ready = alice.feed.firstWhere((f) => f['action'] == 'ready');
|
||||
expect(ready['actorId'], bob.welcome.memberId);
|
||||
final last = seqs.last;
|
||||
await alice.ws.close();
|
||||
bob.ws.add(jsonEncode(const ChatMessage(text: 'while you were out').toJson()));
|
||||
bob.ws.add(jsonEncode(const ReadyIntent(ready: false).toJson()));
|
||||
await _until(() => server.hub.engine.feed.lastSeq >= last + 2, 'lines while away');
|
||||
final again = await join('alice', client: install, seq: 2, feedAfter: last);
|
||||
final replayed = again.welcome.feed;
|
||||
expect(replayed.map((f) => f.action), containsAllInOrder(['chat', 'unready']));
|
||||
expect(replayed.every((f) => f.replayed && f.seq > last), isTrue);
|
||||
expect(again.feed, isEmpty, reason: 'replayed only inside welcome');
|
||||
});
|
||||
|
||||
test('a server restart: resumed false, another instance', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final before = alice.welcome.instance;
|
||||
expect(before, isNotEmpty);
|
||||
await server.stop();
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't'), clock: () => t);
|
||||
await server.start();
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect(again.welcome.resumed, isFalse);
|
||||
expect(again.welcome.instance, isNot(before));
|
||||
});
|
||||
|
||||
test('members carry presence and app; the client id is never broadcast nor logged', () async {
|
||||
final records = <String>[];
|
||||
final sub = Logger.root.onRecord.listen((r) => records.add('${r.message} ${r.error ?? ''}'));
|
||||
addTearDown(sub.cancel);
|
||||
final alice = await join('alice', client: install, seq: 3);
|
||||
final old = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||
old.add(jsonEncode(const HelloMessage(secret: 's', user: HelloUser(name: 'alice'), client: install, seq: 1).toJson()));
|
||||
await old.drain<void>().timeout(const Duration(seconds: 5));
|
||||
expect(old.closeCode, CloseCodes.stale);
|
||||
await alice.ws.close();
|
||||
await _until(() => server.hub.engine.byClient(install)?.presence == Presence.gone, 'gone');
|
||||
final again = await join('alice', client: install, seq: 4);
|
||||
await _until(() => again.frames.any((f) => f['t'] == 'state'), 'state');
|
||||
final state = again.frames.lastWhere((f) => f['t'] == 'state');
|
||||
final me = (state['members'] as List).cast<Map>().single;
|
||||
expect([me['presence'], me['app']], ['in', '1.3.0']);
|
||||
final everything = [...records, ...again.frames.map(jsonEncode), ...alice.frames.map(jsonEncode)].join('\n');
|
||||
expect(records, isNotEmpty);
|
||||
expect(everything, isNot(contains(install)));
|
||||
expect(redactLog('bad hello: {"client":"$install"}'), isNot(contains(install)));
|
||||
});
|
||||
|
||||
test('server close code for a refused secret; the err names its code', () async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
||||
final frames = <String>[];
|
||||
ws.listen((m) => frames.add('$m'), onError: (_) {});
|
||||
ws.add(jsonEncode(const HelloMessage(secret: 'nope', user: HelloUser(name: 'x')).toJson()));
|
||||
await _until(() => ws.closeCode != null, 'closed');
|
||||
expect(ws.closeCode, CloseCodes.secret);
|
||||
expect((jsonDecode(frames.single) as Map)['code'], 'secret');
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): queue
|
||||
// `by` and adder-only keys follow the lasting client id.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -15,14 +13,15 @@ class _Peer {
|
||||
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||
}
|
||||
|
||||
static Future<_Peer> join(int port, String name) async {
|
||||
static Future<_Peer> join(int port, String name, {String client = '', int seq = 0}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
p.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 's',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
client: client,
|
||||
seq: seq)
|
||||
.toJson());
|
||||
await p.until((m) => m['t'] == 'welcome');
|
||||
return p;
|
||||
@@ -122,4 +121,24 @@ void main() {
|
||||
expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link');
|
||||
expect(s.queue.single.key, 'k1234567890');
|
||||
});
|
||||
|
||||
test("an adder's items keep its lasting id through a drop and wait while it is gone", () async {
|
||||
const install = 'queueTestInstallId0001';
|
||||
final a = await _Peer.join(server.boundPort, 'alice', client: install, seq: 1);
|
||||
final b = await _Peer.join(server.boundPort, 'bob');
|
||||
a.send(const QueueIntent.add(QueueItem(
|
||||
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f', key: 'k1234567890'))
|
||||
.toJson());
|
||||
final added = (await b.state((s) => s.queue.isNotEmpty)).queue.single;
|
||||
final aliceId = added.by;
|
||||
await a.ws.close();
|
||||
// Gone: still a member, so whoever would put the item up waits for it
|
||||
// instead of skipping it.
|
||||
final whileGone = await b.state((s) => s.members.any((m) => m.id == aliceId && m.presence == Presence.gone));
|
||||
expect(whileGone.queue.single.by, aliceId);
|
||||
final again = await _Peer.join(server.boundPort, 'alice', client: install, seq: 2);
|
||||
final back = await again.state((s) => s.members.any((m) => m.id == aliceId && m.presence != Presence.gone));
|
||||
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again');
|
||||
expect(back.members.where((m) => m.name == 'alice'), hasLength(1));
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class _Member {
|
||||
late WelcomeMessage welcome;
|
||||
_Member(this.ws);
|
||||
|
||||
static Future<_Member> join(int port, String name, {String secret = 'pw'}) async {
|
||||
static Future<_Member> join(int port, String name, {String secret = 'pw', String client = '', int seq = 0}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final m = _Member(ws);
|
||||
final w = Completer<String>();
|
||||
@@ -29,8 +29,8 @@ class _Member {
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
).toJson()));
|
||||
client: client,
|
||||
seq: seq).toJson()));
|
||||
m.welcome = WelcomeMessage.fromJson(
|
||||
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
|
||||
return m;
|
||||
@@ -86,8 +86,8 @@ void main() {
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
Future<_Member> join(String name) async {
|
||||
final m = await _Member.join(server.boundPort, name);
|
||||
Future<_Member> join(String name, {String client = '', int seq = 0}) async {
|
||||
final m = await _Member.join(server.boundPort, name, client: client, seq: seq);
|
||||
cleanup.add(() => m.ws.close());
|
||||
return m;
|
||||
}
|
||||
@@ -251,6 +251,75 @@ void main() {
|
||||
expect(body, bytes.sublist(RelayBuffer.slice * 4));
|
||||
});
|
||||
|
||||
group('the source drops (D6)', () {
|
||||
const install = 'relaySourceInstall0001';
|
||||
Map? relayOf(_Member m) => m.media?['relay'] as Map?;
|
||||
|
||||
test('gone: the relay stays, sourceGone, the buffer serves; back with the same info it continues', () async {
|
||||
final alice = await join('alice', client: install, seq: 1);
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
final url = viewerUrl(bob, mediaIdOf(alice));
|
||||
expect((await get(url, range: 'bytes=0-${RelayBuffer.slice * 2 - 1}')).$3, bytes.sublist(0, RelayBuffer.slice * 2));
|
||||
await alice.ws.close();
|
||||
await src.close();
|
||||
await src.done;
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == true, 'sourceGone');
|
||||
expect(relayOf(bob)!['sourceGoneSince'], isA<String>());
|
||||
expect(server.hub.relay, isNotNull, reason: 'the relay lives through the grace');
|
||||
expect((await get(url, range: 'bytes=0-99')).$3, bytes.sublist(0, 100), reason: 'what is buffered serves');
|
||||
final pending = get(url, range: 'bytes=${RelayBuffer.slice * 4}-');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
final again = await join('alice', client: install, seq: 2);
|
||||
expect([again.welcome.resumed, again.welcome.token], [true, alice.welcome.token]);
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == null, 'source back');
|
||||
await startSource(again);
|
||||
final (status, _, body) = await pending.timeout(const Duration(seconds: 10));
|
||||
expect(status, 206);
|
||||
expect(body, bytes.sublist(RelayBuffer.slice * 4), reason: 'the rest is pulled from the source again');
|
||||
});
|
||||
|
||||
test('back with another media: closed with 4002, the relay ends', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
await src.close();
|
||||
await src.done;
|
||||
final other = File('${tmp.path}/other.mkv')..writeAsBytesSync(List.filled(1000, 3));
|
||||
final wrong = await startSource(alice, from: RelayMedia.single(FileRelayBytes(other)));
|
||||
await wrong.done.timeout(const Duration(seconds: 5));
|
||||
await _until(() => server.hub.relay == null, 'relay ended');
|
||||
await _until(() => relayOf(bob) != null && relayOf(bob)!['live'] == null, 'not live');
|
||||
expect((await get(viewerUrl(bob, mediaIdOf(alice)), range: 'bytes=0-9')).$1, 404);
|
||||
});
|
||||
|
||||
test('a second channel for the same relay replaces the first (4003)', () async {
|
||||
final alice = await join('alice');
|
||||
await swapRelay(alice);
|
||||
final first = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
await startSource(alice);
|
||||
await first.done.timeout(const Duration(seconds: 5));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(server.hub.relay?.live, isTrue, reason: 'the second one feeds it');
|
||||
});
|
||||
|
||||
test('its source leaving for good ends the relay', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
await swapRelay(alice);
|
||||
final src = await startSource(alice);
|
||||
await _until(() => server.hub.relay?.live == true, 'live');
|
||||
alice.send(const ByeMessage().toJson());
|
||||
await src.done.timeout(const Duration(seconds: 5));
|
||||
await _until(() => relayOf(bob)?['sourceGone'] == true && relayOf(bob)?['live'] == null, 'ended');
|
||||
expect(server.hub.relay, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('two tracks: each byte-exact at its own URL, welcome is an EDL of both', () async {
|
||||
final alice = await join('alice');
|
||||
final bob = await join('bob');
|
||||
@@ -554,13 +623,17 @@ void main() {
|
||||
expect(server.hub.warm!.queueId, isNot(id));
|
||||
});
|
||||
|
||||
test('not for an adder alone; dropped when its adder leaves', () async {
|
||||
test('not for an adder alone; kept while its adder is gone, dropped when it leaves', () async {
|
||||
final alice = await join('alice');
|
||||
await add(alice, through('k-abcdefgh'));
|
||||
expect(headRelay(alice), isNull);
|
||||
final bob = await join('bob');
|
||||
await _until(() => headRelay(bob) != null, 'warm relay once someone could read it');
|
||||
await alice.ws.close();
|
||||
await _until(() => headRelay(bob)?['sourceGone'] == true, 'adder gone');
|
||||
expect(server.hub.warm, isNotNull, reason: 'its adder may come back within the grace');
|
||||
server.hub.engine.applyLeave(alice.welcome.token, reason: 'bye');
|
||||
server.hub.changed();
|
||||
await _until(() => bob.lastState!['members'] is List && (bob.lastState!['members'] as List).length == 1, 'left');
|
||||
expect(queueOf(bob), hasLength(1));
|
||||
expect(headRelay(bob), isNull);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence):
|
||||
// "proto mismatch rejected" (min/max range, refusal frame) and "silent
|
||||
// socket dropped and member removed" (Gone with grace); the rest stays.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -43,15 +40,15 @@ void main() {
|
||||
|
||||
Future<String> hello(String secret,
|
||||
{int proto = RelayConstants.protoVersion,
|
||||
int? protoMin,
|
||||
Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final msg = HelloMessage(
|
||||
proto: proto,
|
||||
protoMin: protoMin,
|
||||
secret: secret,
|
||||
user: const HelloUser(name: 'alice'),
|
||||
media:
|
||||
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
user: const HelloUser(name: 'alice')).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
try {
|
||||
final first = await ch.stream.first.timeout(timeout);
|
||||
@@ -73,7 +70,9 @@ void main() {
|
||||
final decoded = jsonDecode(body) as Map;
|
||||
expect(decoded['ok'], isTrue);
|
||||
expect(decoded['name'], 'test');
|
||||
expect(decoded['proto'], RelayConstants.protoVersion);
|
||||
expect(decoded['proto'], RelayConstants.protoMax);
|
||||
expect([decoded['protoMin'], decoded['protoMax'], decoded['version'], decoded['tls']],
|
||||
[RelayConstants.protoMin, RelayConstants.protoMax, appVersion, false]);
|
||||
expect(decoded['viewers'], 0);
|
||||
expect(decoded['bytesServed'], 0);
|
||||
expect(decoded.containsKey('media'), isFalse, reason: 'no media name leak');
|
||||
@@ -81,7 +80,7 @@ void main() {
|
||||
|
||||
test('wrong secret gets err, right secret gets welcome+token', () async {
|
||||
final bad = await hello('wrong');
|
||||
expect(jsonDecode(bad)['t'], 'err');
|
||||
expect([jsonDecode(bad)['t'], jsonDecode(bad)['code'], jsonDecode(bad)['message']], ['err', 'secret', 'wrong secret']);
|
||||
|
||||
final good = await hello('shh');
|
||||
final decoded = jsonDecode(good) as Map;
|
||||
@@ -96,10 +95,32 @@ void main() {
|
||||
expect(server.session.tokens.length, 1);
|
||||
});
|
||||
|
||||
test('proto mismatch rejected', () async {
|
||||
final reply =
|
||||
await hello('shh', proto: RelayConstants.protoVersion + 99);
|
||||
expect(jsonDecode(reply)['t'], 'err');
|
||||
test('version ranges: a common version is taken, none is refused with code version', () async {
|
||||
final newer = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMin, proto: RelayConstants.protoMax + 3));
|
||||
expect([newer['t'], newer['proto']], ['welcome', RelayConstants.protoMax], reason: 'the highest common one');
|
||||
final ahead = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMax + 1, proto: RelayConstants.protoMax + 2));
|
||||
expect([ahead['t'], ahead['code'], ahead['min'], ahead['max'], ahead['server']],
|
||||
['err', 'version', RelayConstants.protoMin, RelayConstants.protoMax, appVersion]);
|
||||
expect(server.session.tokens, hasLength(1));
|
||||
});
|
||||
|
||||
test('a proto 15 hello gets the stable refusal it can show, then the version close code', () async {
|
||||
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
// What a 1.2.x app sends: no protoMin, a media object, a resume token.
|
||||
ch.sink.add(jsonEncode({
|
||||
't': 'hello',
|
||||
'proto': 15,
|
||||
'secret': 'shh',
|
||||
'user': {'name': 'old', 'avatar': ''},
|
||||
'media': {'name': '', 'size': 0, 'duration': 0},
|
||||
'resume': 'deadbeef',
|
||||
}));
|
||||
final frames = await ch.stream.toList().timeout(const Duration(seconds: 5));
|
||||
final err = jsonDecode('${frames.single}') as Map;
|
||||
expect(err['t'], 'err');
|
||||
expect(err['code'], 'version');
|
||||
expect(err['message'], startsWith('proto mismatch: got 15'));
|
||||
expect(ch.closeCode, CloseCodes.version);
|
||||
expect(server.session.tokens, isEmpty);
|
||||
});
|
||||
|
||||
@@ -136,7 +157,7 @@ void main() {
|
||||
await resp.drain();
|
||||
});
|
||||
|
||||
test('silent socket dropped and member removed', () async {
|
||||
test('a silent socket is closed and its member is gone, not removed', () async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final messages = <String>[];
|
||||
@@ -148,9 +169,7 @@ void main() {
|
||||
final msg = HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'ghost'),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
|
||||
user: const HelloUser(name: 'ghost')).toJson();
|
||||
ch.sink.add(jsonEncode(msg));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (messages.isEmpty && DateTime.now().isBefore(deadline)) {
|
||||
@@ -160,18 +179,16 @@ void main() {
|
||||
(jsonDecode(messages.firstWhere((m) => (jsonDecode(m) as Map)['t'] == 'welcome')) as Map)['token']
|
||||
as String;
|
||||
server.hub.engine.members[token]!.lastSeen =
|
||||
DateTime.now().toUtc().subtract(const Duration(seconds: 61));
|
||||
server.hub.dropSilentSockets();
|
||||
DateTime.now().toUtc().subtract(const Duration(seconds: 16));
|
||||
server.hub.engine.tick();
|
||||
final closeDeadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while ((!wsClosed ||
|
||||
server.hub.engine.members.containsKey(token) ||
|
||||
server.session.tokens.containsKey(token)) &&
|
||||
DateTime.now().isBefore(closeDeadline)) {
|
||||
while (!wsClosed && DateTime.now().isBefore(closeDeadline)) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
expect(wsClosed, isTrue);
|
||||
expect(server.hub.engine.members.containsKey(token), isFalse);
|
||||
expect(server.session.tokens.containsKey(token), isFalse);
|
||||
expect(ch.closeCode, CloseCodes.silent);
|
||||
expect(server.hub.engine.members[token]!.presence, Presence.gone);
|
||||
expect(server.session.tokens.containsKey(token), isTrue);
|
||||
try {
|
||||
await sub.cancel();
|
||||
} catch (_) {}
|
||||
@@ -193,9 +210,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: const HelloUser(name: 'hb'),
|
||||
media: const HelloMedia(
|
||||
name: 'f.bin', size: 8192, duration: 10.0))
|
||||
user: const HelloUser(name: 'hb'))
|
||||
.toJson()));
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final hbToken = server.session.tokens.keys
|
||||
@@ -223,8 +238,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
user: HelloUser(name: name)).toJson()));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!messages.any((m) => m['t'] == 'welcome')) {
|
||||
if (DateTime.now().isAfter(deadline)) fail('no welcome');
|
||||
@@ -329,8 +343,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
user: HelloUser(name: 'alice', avatar: base64Encode(png))).toJson()));
|
||||
final welcome = await waitFor((m) => m['t'] == 'welcome');
|
||||
final token = welcome['token'] as String;
|
||||
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
|
||||
@@ -376,7 +389,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(
|
||||
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
|
||||
final err = await waitFor((m) => m['t'] == 'err');
|
||||
expect(err['message'], 'rejected: avatar too large');
|
||||
expect([err['code'], err['for'], err['message']], ['rejected', 'profile', 'rejected: avatar too large']);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
try {
|
||||
|
||||
@@ -71,8 +71,7 @@ void main() {
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 's',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
user: HelloUser(name: name))
|
||||
.toJson()));
|
||||
while (await msgs.moveNext()) {
|
||||
final m = jsonDecode('${msgs.current}') as Map;
|
||||
|
||||
@@ -64,17 +64,17 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 15: avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, 15);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
test('avatars by hash, profile intent', () {
|
||||
expect(RelayConstants.protoVersion, RelayConstants.protoMax);
|
||||
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0);
|
||||
expect(v.toJson().containsKey('avatarHash'), isFalse);
|
||||
expect(v.toJson().containsKey('avatar'), isFalse);
|
||||
const h = MemberView(
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
|
||||
// host: only on the hosting app's member, absent otherwise.
|
||||
expect(v.toJson().containsKey('host'), isFalse);
|
||||
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true);
|
||||
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, host: true);
|
||||
expect(host.toJson()['host'], isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(host.toJson())).host, isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
|
||||
// resume-token takeover becomes a lasting client id; ping/pong and the
|
||||
// seek echo stay.
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
@@ -47,12 +44,15 @@ class _Client {
|
||||
final WebSocket ws;
|
||||
final frames = <Map<String, Object?>>[];
|
||||
final _welcome = Completer<Map<String, Object?>>();
|
||||
final closed = Completer<int?>();
|
||||
_Client(this.ws) {
|
||||
ws.listen((m) {
|
||||
final d = Map<String, Object?>.from(jsonDecode('$m') as Map);
|
||||
frames.add(d);
|
||||
if (d['t'] == 'welcome' && !_welcome.isCompleted) _welcome.complete(d);
|
||||
}, onError: (_) {}, onDone: () {});
|
||||
}, onError: (_) {}, onDone: () {
|
||||
if (!closed.isCompleted) closed.complete(ws.closeCode);
|
||||
});
|
||||
}
|
||||
|
||||
StateBroadcast? get lastState {
|
||||
@@ -65,18 +65,24 @@ class _Client {
|
||||
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
|
||||
|
||||
static Future<(_Client, WelcomeMessage)> join(int port, String name,
|
||||
{String resume = ''}) async {
|
||||
{String client = '', int seq = 0}) async {
|
||||
final c = await dial(port, name, client: client, seq: seq);
|
||||
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
|
||||
return (c, WelcomeMessage.fromJson(w));
|
||||
}
|
||||
|
||||
/// Sends the hello and returns at once (a refused one gets no welcome).
|
||||
static Future<_Client> dial(int port, String name, {String client = '', int seq = 0}) async {
|
||||
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
|
||||
final c = _Client(ws);
|
||||
c.send(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'w',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
resume: resume,
|
||||
).toJson());
|
||||
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
|
||||
return (c, WelcomeMessage.fromJson(w));
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'w',
|
||||
user: HelloUser(name: name),
|
||||
client: client,
|
||||
seq: seq)
|
||||
.toJson());
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,42 +104,70 @@ void main() {
|
||||
});
|
||||
tearDown(() => server.stop());
|
||||
|
||||
test('reconnect after an unclean drop takes over the old member, no ghost',
|
||||
() async {
|
||||
const alice = 'aliceInstallIdAAAAAAAA';
|
||||
|
||||
test('an unclean drop: the same app takes its member over (id, token, ready), no ghost', () async {
|
||||
final proxy = await _FreezableProxy.start(server.boundPort);
|
||||
final other = (await _Client.join(server.boundPort, 'other')).$1;
|
||||
final (first, welcome) = await _Client.join(proxy.port, 'alice');
|
||||
final (first, welcome) = await _Client.join(proxy.port, 'alice', client: alice, seq: 1);
|
||||
expect(welcome.resumed, isFalse);
|
||||
expect(welcome.instance, server.hub.instance);
|
||||
expect(welcome.proto, RelayConstants.protoMax);
|
||||
expect(welcome.grace, RelayConstants.graceSecs);
|
||||
first.send(const ReadyIntent(ready: true).toJson());
|
||||
await _until(() => server.hub.engine.members[welcome.token]?.ready == true,
|
||||
'ready');
|
||||
await _until(() => server.hub.engine.members[welcome.token]?.ready == true, 'ready');
|
||||
proxy.frozen = true; // unclean drop: the server's socket stays open
|
||||
|
||||
final (second, again) =
|
||||
await _Client.join(server.boundPort, 'alice', resume: welcome.token);
|
||||
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 2);
|
||||
expect(again.resumed, isTrue);
|
||||
expect(again.memberId, welcome.memberId);
|
||||
expect(again.token, welcome.token,
|
||||
reason: 'same token keeps the media URL valid');
|
||||
expect(server.hub.engine.members.length, 2,
|
||||
reason: 'alice once (taken over) plus other, no ghost');
|
||||
expect(server.hub.engine.members[welcome.token]!.ready, isTrue,
|
||||
reason: 'ready survives the takeover');
|
||||
// The frozen old socket closing later must not remove the new one.
|
||||
expect(again.token, welcome.token, reason: 'same token keeps the media URL valid');
|
||||
expect(server.hub.engine.members.length, 2, reason: 'alice once (taken over) plus other, no ghost');
|
||||
expect(server.hub.engine.members[welcome.token]!.ready, isTrue, reason: 'ready survives the takeover');
|
||||
// The frozen old socket closing later must not touch the member.
|
||||
await proxy.close();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members.containsKey(welcome.token), isTrue);
|
||||
expect(server.hub.engine.members[welcome.token]!.presence, isNot(Presence.gone));
|
||||
expect(server.hub.sockets.containsKey(welcome.token), isTrue);
|
||||
second.send(const ChatMessage(text: 'still here').toJson());
|
||||
await _until(
|
||||
() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
|
||||
await _until(() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
|
||||
'chat from the resumed member');
|
||||
await other.ws.close();
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test('unknown resume token joins as a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
||||
expect(wa.token, isNot('nope'));
|
||||
test('a takeover tells the older socket: err replaced, then its close code', () async {
|
||||
final (first, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
|
||||
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
|
||||
expect([again.resumed, again.memberId, again.token], [true, welcome.memberId, welcome.token],
|
||||
reason: 'the same seq takes over too');
|
||||
expect(await first.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.replaced);
|
||||
final err = first.frames.lastWhere((f) => f['t'] == 'err');
|
||||
expect(err['code'], 'replaced');
|
||||
// Frames from the old socket (had it stayed open) no longer count.
|
||||
expect(server.hub.sockets[welcome.token], isNotNull);
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test("an older hello than the member's gets err stale and is closed", () async {
|
||||
final (current, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 5);
|
||||
final older = await _Client.dial(server.boundPort, 'alice', client: alice, seq: 4);
|
||||
expect(await older.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.stale);
|
||||
expect(older.frames.single['code'], 'stale');
|
||||
expect(server.hub.sockets.containsKey(welcome.token), isTrue, reason: 'the current one stays');
|
||||
expect(server.hub.engine.members, hasLength(1));
|
||||
await current.ws.close();
|
||||
});
|
||||
|
||||
test('without a client id, or with another, it is a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a');
|
||||
final (b, wb) = await _Client.join(server.boundPort, 'a');
|
||||
final (c, wc) = await _Client.join(server.boundPort, 'a', client: 'someOtherInstallId0001');
|
||||
expect({wa.memberId, wb.memberId, wc.memberId}, hasLength(3));
|
||||
expect([wa.resumed, wb.resumed, wc.resumed], [false, false, false]);
|
||||
await a.ws.close();
|
||||
await b.ws.close();
|
||||
await c.ws.close();
|
||||
});
|
||||
|
||||
test('ping gets a pong with the client stamp and the server clock', () async {
|
||||
|
||||
Reference in new issue
Block a user