Files
sync_replay_project/apps/desktop/test/connection_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

163 lines
6.7 KiB
Dart

// The Connection against a scripted fake server: what a hello says, a
// reconnect's hello, refusals in plain words, the backoff.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/connection.dart';
import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/sync/sync_client.dart';
/// A `/sync` endpoint that hands each socket and its hello to [onHello].
class _Fake {
final HttpServer _server;
final hellos = <Map<String, Object?>>[];
void Function(WebSocket ws, Map<String, Object?> hello) onHello;
_Fake(this._server, this.onHello) {
_server.listen((req) async {
final ws = await WebSocketTransformer.upgrade(req);
var first = true;
ws.listen((m) {
if (!first) return;
first = false;
final hello = Map<String, Object?>.from(jsonDecode('$m') as Map);
hellos.add(hello);
onHello(ws, hello);
}, onError: (_) {});
});
}
static Future<_Fake> start(void Function(WebSocket ws, Map<String, Object?> hello) onHello) async =>
_Fake(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), onHello);
int get port => _server.port;
Future<void> close() => _server.close(force: true);
}
void _welcome(WebSocket ws, {String instance = 'boot-1', bool resumed = false}) => ws.add(jsonEncode(WelcomeMessage(
token: 'tok', memberId: 'm1', instance: instance, resumed: resumed,
state: StateBroadcast(
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0, members: const [])
.toJson())
.toJson()));
Future<void> _poll(FutureOr<bool> Function() cond, String what, {Duration timeout = const Duration(seconds: 10)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 50));
}
fail('timed out waiting for $what');
}
ConnectParams _params(int port) => ConnectParams(host: '127.0.0.1', port: port, secret: 's', name: 'me');
void main() {
test('the hello: version range, client id, a rising seq, app, platform, caps; feedAfter only on a reconnect',
() async {
final fake = await _Fake.start((ws, hello) {
_welcome(ws);
// One feed line, then the link dies.
ws.add(jsonEncode(FeedMessage(actor: 'Ann', action: 'join', at: DateTime.now().toUtc(), seq: 5).toJson()));
});
final id = ClientIdentity.ephemeral();
final c = Connection(identity: id, caps: () => const ClientCaps(source: true, resolve: true),
reconnectDelay: const Duration(milliseconds: 50));
try {
c.begin(_params(fake.port));
await c.dial(c.invalidate());
await _poll(() => c.client.lastFeedSeq == 5, 'the feed line');
final h = fake.hellos.single;
expect(h['proto'], RelayConstants.protoMax);
expect(h['protoMin'], RelayConstants.protoMin);
expect(h['client'], id.clientId);
expect((h['client'] as String).length, 22);
expect(validClientId(h['client'] as String), isTrue);
expect(h['seq'], 1);
expect(h['app'], appVersion);
expect(h['platform'], Platform.operatingSystem);
expect(h['caps'], {'source': true, 'resolve': true});
expect(h.containsKey('feedAfter'), isFalse, reason: 'a first join replays nothing');
expect(h['media'], <String, Object?>{}, reason: 'a proto 15 server answers its own proto mismatch');
await c.client.drop();
// A dropped socket: no status from it; drive the drop the way the
// silence check does.
c.scheduleReconnect();
await _poll(() => fake.hellos.length == 2 && c.state.peek() is ConnOnline, 'the reconnect');
expect(fake.hellos[1]['seq'], 2);
expect(fake.hellos[1]['client'], id.clientId);
expect(fake.hellos[1]['feedAfter'], 5);
} finally {
await c.dispose();
await fake.close();
}
});
test('a proto 15 host refuses in its own words: "the host runs an older Starsync"', () async {
final fake = await _Fake.start((ws, hello) {
ws.add(jsonEncode({'t': 'err', 'message': 'proto mismatch: got 16, want 15'}));
ws.close(1008, 'refused');
});
final c = Connection();
try {
c.begin(_params(fake.port));
await expectLater(c.dial(c.invalidate()), throwsA(isA<HelloRefused>()));
final s = c.state.peek();
expect(s, isA<ConnRefused>());
expect((s as ConnRefused).code, 'version');
expect(s.message, 'the host runs an older Starsync — they need to update');
} finally {
await c.dispose();
await fake.close();
}
});
test('refusals in plain words', () {
HelloRefused v({int? min, int? max}) =>
HelloRefused(error: ErrorFrame(code: ErrCode.version, message: 'proto mismatch', min: min, max: max));
expect(connectErrorText(v(min: 15, max: 15)), contains('older Starsync'));
expect(connectErrorText(v(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 2)),
contains('newer Starsync'));
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.secret, message: 'wrong secret'))),
'wrong server password (401)');
expect(connectErrorText(const HelloRefused(closeCode: CloseCodes.replaced)), replacedText);
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.stale, message: 'older'))), replacedText);
});
test('backoff: about 1 s first, doubling, capped near 15 s, with jitter', () {
final c = Connection();
for (var i = 0; i < 20; i++) {
final first = c.delayFor(1).inMilliseconds;
expect(first, inInclusiveRange(800, 1200));
expect(c.delayFor(2).inMilliseconds, inInclusiveRange(1600, 2400));
expect(c.delayFor(30).inMilliseconds, inInclusiveRange(12000, 18000));
}
unawaited(c.dispose());
});
test('replaced is terminal: no more attempts', () async {
late WebSocket held;
final fake = await _Fake.start((ws, hello) {
held = ws;
_welcome(ws);
});
final c = Connection(reconnectDelay: const Duration(milliseconds: 50));
try {
c.begin(_params(fake.port));
await c.dial(c.invalidate());
held.add(jsonEncode(const ErrorFrame(code: ErrCode.replaced, message: 'another connection').toJson()));
await held.close(CloseCodes.replaced, 'replaced');
await _poll(() => c.state.peek() is ConnReplaced, 'replaced');
await Future.delayed(const Duration(milliseconds: 500));
expect(c.state.peek(), isA<ConnReplaced>());
expect(fake.hellos, hasLength(1), reason: 'it never dials again by itself');
} finally {
await c.dispose();
await fake.close();
}
});
}