Files
sync_replay_project/packages/server/test/flaky_link_test.dart
T
foxyzandClaude Opus 5.5 9f2586b7ff
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s
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>
2026-10-15 21:39:47 +03:00

153 lines
5.9 KiB
Dart

// 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';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// A bare `/sync` client: hello, then a report every 100 ms (any frame
/// keeps a member fresh).
class _Client {
final WebSocketChannel ch;
final frames = <Map<String, Object?>>[];
bool ended = false;
Timer? _reports;
late final StreamSubscription<dynamic> _sub;
_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)),
onDone: () => ended = true, onError: (_) => ended = true);
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'alice'),
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;
void startReports() => _reports = Timer.periodic(const Duration(milliseconds: 100), (_) {
if (!ended) {
ch.sink.add(jsonEncode(const ReportMessage(position: 0, buffered: 0, eventId: 0).toJson()));
}
});
Future<void> close() async {
_reports?.cancel();
try {
await _sub.cancel();
} catch (_) {}
try {
await ch.sink.close().timeout(const Duration(seconds: 1));
} catch (_) {}
}
}
Future<void> until(bool Function() ok, String what, {Duration limit = const Duration(seconds: 6)}) async {
final end = DateTime.now().add(limit);
while (!ok()) {
if (DateTime.now().isAfter(end)) fail('$what: not within $limit');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
void main() {
late SyncServer server;
late FlakyProxy proxy;
final clients = <_Client>[];
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(silence: Duration(milliseconds: 600)));
await server.start();
proxy = await FlakyProxy.start(server.boundPort);
});
tearDown(() async {
for (final c in clients) {
await c.close();
}
clients.clear();
await proxy.close();
await server.stop();
});
_Client dial({int seq = 1}) {
final c = _Client(proxy.port, seq: seq);
clients.add(c);
return c;
}
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(member()!.presence, isNot(Presence.gone), reason: 'reports through the proxy keep it present');
proxy.last!.blackhole();
final seen = c.frames.length;
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));
expect(c.ended, isFalse);
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 into the same member', () async {
final c = dial();
await until(() => c.welcome != null, 'welcome');
proxy.last!.reset();
await until(() => c.ended, 'client sees the reset');
await until(() => server.hub.engine.members[c.token]?.presence == Presence.gone, 'server sees it gone');
final again = dial(seq: 2);
await until(() => again.welcome != null, 'welcome after redial');
expect(proxy.links, hasLength(2));
expect([again.memberId, again.token], [c.memberId, c.token]);
expect(server.hub.engine.members, hasLength(1));
});
}