Restructure step 1: lasting identity, presence and protocol 16
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s

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:
foxyzandClaude Opus 5.5 committed 2026-10-15 21:39:47 +03:00
1 parent 51c1c5ab78
commit 9f2586b7ff
85 files changed
+5198 -1902

No files matched your search

+192
View File
@@ -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');
});
}