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

+149 -11
View File
@@ -60,12 +60,10 @@ void main() {
const msg = HelloMessage(
proto: 1,
secret: 'shh',
user: HelloUser(name: 'alice'),
media: HelloMedia(name: 'Ep01.mkv', size: 1500000000, duration: 1400.0),
);
user: HelloUser(name: 'alice'));
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
expect(back.user.name, 'alice');
expect(back.media.size, 1500000000);
expect(msg.toJson()['media'], isEmpty, reason: 'a 15 server needs it to say "proto mismatch"');
expect(back.hostKey, isEmpty);
expect(msg.toJson().containsKey('hostKey'), isFalse);
});
@@ -75,9 +73,7 @@ void main() {
proto: 1,
secret: '',
user: HelloUser(name: 'host'),
media: HelloMedia(name: '', size: 0, duration: 0),
hostKey: 'hk',
);
hostKey: 'hk');
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
expect(back.hostKey, 'hk');
});
@@ -168,11 +164,9 @@ void main() {
final hello = HelloMessage.fromJson(rt(const HelloMessage(
proto: 6,
secret: '',
user: HelloUser(name: 'a'),
media: HelloMedia(name: '', size: 0, duration: 0),
resume: 'tok')
user: HelloUser(name: 'a'))
.toJson()));
expect(hello.resume, 'tok');
expect((hello.protoMin, hello.proto), (6, 6));
final seek = SeekIntent.fromJson(rt(const SeekIntent(position: 3, seq: 9).toJson()));
expect(seek.seq, 9);
final report = ReportMessage.fromJson(rt(const ReportMessage(
@@ -210,4 +204,148 @@ void main() {
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 3))), closeTo(1.0, 1e-9));
expect(s.bufferNeedAt(t0.add(const Duration(seconds: 5))), 0);
});
group('proto 16', () {
Map<String, Object?> rt(Map<String, Object?> j) => Map<String, Object?>.from(jsonDecode(jsonEncode(j)) as Map);
test('version ranges: the highest common one, or none', () {
expect(pickProto(16, 16, serverMin: 16, serverMax: 16), 16);
expect(pickProto(16, 18, serverMin: 16, serverMax: 17), 17);
expect(pickProto(15, 15, serverMin: 16, serverMax: 16), isNull);
expect(pickProto(17, 18, serverMin: 16, serverMax: 16), isNull);
expect(HelloMessage.protoRangeOf({'proto': 15, 'media': {}}), (min: 15, max: 15));
expect(HelloMessage.protoRangeOf({'proto': 17, 'protoMin': 16}), (min: 16, max: 17));
expect(HelloMessage.protoRangeOf({'protoMin': 16}), isNull);
});
test('the refusal stays readable by a proto 15 app', () {
final err = ErrorFrame.version(got: 15, server: '1.3.0').toJson();
expect(err, {
't': 'err',
'code': 'version',
'message': 'proto mismatch: got 15, want ${RelayConstants.protoMin}..${RelayConstants.protoMax}',
'for': 'hello',
'min': RelayConstants.protoMin,
'max': RelayConstants.protoMax,
'server': '1.3.0',
});
final back = ErrorFrame.fromJson(rt(err));
expect([back.code, back.min, back.server], [ErrCode.version, RelayConstants.protoMin, '1.3.0']);
expect(ErrorFrame.fromJson({'t': 'err', 'message': 'wrong secret'}).code, isNull, reason: 'a 15 server');
});
test('hello: identity, caps, feedAfter; a bad client id is refused', () {
const hello = HelloMessage(
secret: 's',
user: HelloUser(name: 'a'),
client: 'AbCdEfGhIjKlMnOpQrStUv',
seq: 7,
app: '1.3.0',
platform: 'linux',
caps: ClientCaps(source: true, resolve: true),
feedAfter: 41);
final json = hello.toJson();
expect([json['protoMin'], json['proto'], json['media']], [RelayConstants.protoMin, RelayConstants.protoMax, {}]);
expect(json.containsKey('resume'), isFalse);
final back = HelloMessage.fromJson(rt(json));
expect([back.client, back.seq, back.app, back.platform, back.feedAfter], ['AbCdEfGhIjKlMnOpQrStUv', 7, '1.3.0', 'linux', 41]);
expect(back.caps, const ClientCaps(source: true, resolve: true));
expect(ClientCaps.fromJson({'source': true, 'teleport': true}).toJson(), {'source': true});
for (final bad in ['short', 'has space in it 12345', 'x' * 65, 7]) {
expect(() => HelloMessage.fromJson({...json, 'client': bad}), throwsA(isA<ProtocolException>()));
}
expect(HelloMessage.fromJson({...json}..remove('client')).client, isEmpty);
});
test('welcome: proto, resumed, instance, grace, replayed feed', () {
final w = WelcomeMessage(
token: 't',
memberId: 'm1',
proto: 16,
resumed: true,
instance: 'boot1',
grace: 600,
feed: [FeedMessage(actor: 'a', action: 'join', at: DateTime.utc(2026), seq: 3, replayed: true)]);
final back = WelcomeMessage.fromJson(rt(w.toJson()));
expect([back.proto, back.resumed, back.instance, back.grace], [16, true, 'boot1', 600]);
expect([back.feed.single.seq, back.feed.single.replayed], [3, true]);
});
test('presence intent, bye', () {
const p = PresenceIntent(target: 'm2', state: Presence.away, reason: PresenceReason.idle);
expect(p.toJson(), {'t': 'presence', 'target': 'm2', 'state': 'away', 'reason': 'idle'});
final back = PresenceIntent.fromJson(rt(p.toJson()));
expect([back.target, back.state, back.reason], ['m2', Presence.away, PresenceReason.idle]);
expect(PresenceIntent.fromJson({'t': 'presence', 'state': 'in'}).target, isEmpty);
expect(() => PresenceIntent.fromJson({'t': 'presence', 'state': 'gone'}), throwsA(isA<ProtocolException>()));
expect(const ByeMessage().toJson(), {'t': 'bye'});
});
test('members carry presence, goneSince, app and caps; no out, no stale', () {
final since = DateTime.utc(2026, 1, 1, 12);
final v = MemberView(
id: 'm1',
name: 'a',
ready: true,
inSet: false,
buffered: 0,
presence: Presence.gone,
goneSince: since,
app: '1.3.0',
caps: const ClientCaps(jellyfin: true));
final json = v.toJson();
expect([json['presence'], json['goneSince'], json['app'], json['caps']],
['gone', since.toIso8601String(), '1.3.0', {'jellyfin': true}]);
expect(json.containsKey('out') || json.containsKey('stale'), isFalse);
final back = MemberView.fromJson(rt(json));
expect([back.presence, back.goneSince, back.caps.jellyfin], [Presence.gone, since, true]);
});
test('media.ref rides along, at most 2 KiB; relay sourceGone', () {
const ref = MediaRef(kind: 'episode', title: 'Show', anilistId: 1, anilistEp: 6, tvdbId: 2, ep: 6, absolute: 30);
const media = HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y', ref: ref);
final back = HelloMedia.fromJson(rt(media.toJson()));
expect(back.ref, ref);
expect(back.ref!.season, isNull);
expect(MediaRef.fromJson({'kind': 'trailer'}), isNull);
expect(() => MediaRef.fromJson({'kind': 'movie', 'title': 'x' * 3000}), throwsA(isA<ProtocolException>()));
final since = DateTime.utc(2026, 1, 1);
final relay = MediaRelay(source: 'm2', sourceGone: true, sourceGoneSince: since);
final r = MediaRelay.fromJson(rt(relay.toJson()));
expect([r.sourceGone, r.sourceGoneSince], [true, since]);
expect(r.copyWith(sourceGone: false).toJson().containsKey('sourceGoneSince'), isFalse);
});
test('state carries hold and the reserved advance', () {
final at = DateTime.utc(2026);
final s = StateBroadcast(
eventId: 1,
transport: 'held',
position: 0,
serverTime: at,
mediaId: 1,
members: const [],
hold: HoldInfo(reason: HoldReason.joining, members: const ['m2'], until: at),
advance: QueueAdvance(id: 'q3', at: at, secs: 10));
final back = StateBroadcast.fromJson(rt(s.toJson()));
expect([back.hold!.reason, back.hold!.members, back.hold!.until], [HoldReason.joining, ['m2'], at]);
expect([back.advance!.id, back.advance!.secs], ['q3', 10]);
});
test('close codes are distinct', () {
const codes = [
CloseCodes.relayNotSource,
CloseCodes.relayOtherMedia,
CloseCodes.relayReplaced,
CloseCodes.version,
CloseCodes.secret,
CloseCodes.refused,
CloseCodes.replaced,
CloseCodes.stale,
CloseCodes.silent,
CloseCodes.left,
];
expect(codes.toSet(), hasLength(codes.length));
});
});
}
+2 -2
View File
@@ -62,8 +62,8 @@ void main() {
mediaId: 2,
media: const HelloMedia(name: 'Pilot', size: 0, duration: 0, jellyfin: ref).toJson(),
members: const [
MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false, jf: {'abc123': false}),
MemberView(id: 'm2', name: 'b', ready: false, inSet: false, buffered: 0, stale: false),
MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, jf: {'abc123': false}),
MemberView(id: 'm2', name: 'b', ready: false, inSet: false, buffered: 0),
],
relay: true,
relayNext: true,
+9
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
@@ -7,6 +8,14 @@ void main() {
group('redactLog', () {
const tok = '50989dda73c45a4710751f8aff4a1982';
test('the per-install client id never shows (hello.client)', () {
const id = 'AbCdEfGhIjKlMnOpQrStUv';
final hello = jsonEncode(const HelloMessage(secret: '', user: HelloUser(name: 'a'), client: id).toJson());
expect(redactLog('bad hello: $hello'), isNot(contains(id)));
expect(redactLog('client=$id seq=3'), 'client=*** seq=3');
expect(redactLog('{"clientId":"$id"}'), isNot(contains(id)));
});
test('signed URLs keep scheme, host, path and parameter names only', () {
final out = redactLog('GET https://upos-hz-mirrorakam.akamaized.net/a/b-30080.m4s?deadline=1791826934&upsig=0f61b79b&hdnts=exp~acl -> 403');
expect(out, 'GET https://upos-hz-mirrorakam.akamaized.net/a/b-30080.m4s?deadline&upsig&hdnts=… -> 403');
+48
View File
@@ -0,0 +1,48 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
void main() {
const policy = PresencePolicy();
test('the table: what each presence counts for', () {
String row(Presence p) => [
for (final purpose in Purpose.values) policy.countsFor(p, purpose) ? 'y' : '-',
].join();
// gate, readyCounter, tracking, preload, notifySocial (a joiner's gate
// cell: held, just joined).
expect(Purpose.values, [Purpose.gate, Purpose.readyCounter, Purpose.tracking, Purpose.preload, Purpose.notifySocial]);
expect({for (final p in Presence.values) p.wire: row(p)}, {
'joining': 'yy-yy',
'in': 'yyyyy',
'away': '-----',
'gone': '-----',
});
});
test('D2: a joiner never holds a playing gate, a held one at most joinerHold', () {
expect(policy.countsFor(Presence.joining, Purpose.gate, playing: true), isFalse);
expect(policy.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 14)), isTrue);
expect(policy.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 15)), isFalse);
const short = PresencePolicy(joinerHold: Duration(seconds: 2));
expect(short.countsFor(Presence.joining, Purpose.gate, joiningFor: const Duration(seconds: 2)), isFalse);
expect(policy.countsFor(Presence.watching, Purpose.gate, playing: true), isTrue);
});
test('wire names', () {
expect([for (final p in Presence.values) p.wire], ['joining', 'in', 'away', 'gone']);
for (final p in Presence.values) {
expect(Presence.parse(p.wire), p);
}
expect(Presence.parse('watching'), isNull, reason: 'the wire says in');
expect(PresenceReason.parse('minimised'), PresenceReason.minimised);
expect(PresenceReason.parse('nonsense'), PresenceReason.manual);
});
test('alone: nobody else connected (gone is not company)', () {
expect(isAlone(const []), isTrue);
expect(isAlone(const [Presence.gone, Presence.gone]), isTrue);
expect(isAlone(const [Presence.gone, Presence.away]), isFalse);
expect(isAlone(const [Presence.joining]), isFalse);
expect(isAlone(const [Presence.watching]), isFalse);
});
}
+54 -1
View File
@@ -83,7 +83,8 @@ void main() {
for (var i = 0; i < RelayConstants.maxQueueItems; i++) {
expect(engine.applyQueueAdd('a', link('$i')), isNull);
}
expect(engine.applyQueueAdd('a', link('over')), contains('full'));
final refused = engine.applyQueueAdd('a', link('over'))!;
expect([refused.code, refused.message], [ErrCode.full, contains('full')]);
expect(engine.queue.length, RelayConstants.maxQueueItems);
});
@@ -118,4 +119,56 @@ void main() {
expect((intent.op, intent.next, intent.item!.title), ('add', true, 'x'));
expect(() => QueueIntent.fromJson(const {'t': 'queue', 'op': 'nope'}), throwsA(isA<ProtocolException>()));
});
group('auto (the continuation)', () {
QueueItem next(String title) => link(title).copyWith(auto: true, forMedia: engine.mediaId);
setUp(() {
engine.applySwap('a', const HelloMedia(name: 'Ep01', size: 0, duration: 0, url: 'https://example.com/1'));
});
test('only the swapper (or, without it, the first in) adds it, for the current media', () {
expect(engine.applyQueueAdd('b', next('Ep02'))!.code, ErrCode.forbidden);
expect(engine.applyQueueAdd('a', link('Ep02').copyWith(auto: true, forMedia: 99))!.code, ErrCode.bad);
expect(engine.applyQueueAdd('a', next('Ep02')), isNull);
engine.applyPresence('a', '', Presence.away);
expect(engine.applyQueueAdd('b', next('Ep02b')), isNull, reason: 'the swapper is away: bob is the first in');
expect(titles(), ['Ep02b'], reason: 'a newer one replaces it');
});
test('it stays after the others; a swap of something else and cancel drop it', () {
engine.applyQueueAdd('a', next('Ep02'));
engine.applyQueueAdd('b', link('x'));
engine.applyQueueAdd('b', link('y'), next: true);
expect(titles(), ['y', 'x', 'Ep02']);
expect(engine.queue.move(idOf('y'), 99), isTrue);
expect(engine.queue.move(idOf('Ep02'), 0), isFalse);
expect(titles(), ['x', 'y', 'Ep02'], reason: 'nothing moves past it');
final json = engine.broadcast().toJson();
final last = ((json['queue'] as List).last as Map);
expect([last['auto'], last['for']], [true, engine.mediaId]);
expect(QueueItem.fromJson(jsonDecode(jsonEncode(last)))!.forMedia, engine.mediaId);
engine.applySwap('b', const HelloMedia(name: 'other', size: 0, duration: 0, url: 'https://example.com/o'),
queueId: idOf('y'));
expect(titles(), ['x'], reason: 'a swap to something else drops it');
engine.applyQueueAdd('b', link('z'));
expect(engine.applyQueueAdd('b', next('Ep03')), isNull);
engine.feed.drain();
expect(engine.applyQueueCancel('a'), isTrue);
expect(titles(), ['x', 'z']);
final line = engine.feed.drain().single;
expect([line.action, line.actorId, line.target], ['advance-cancel', 'm1', 'Ep03']);
expect(engine.applyQueueCancel('a'), isFalse);
expect(QueueIntent.fromJson(const QueueIntent.cancel().toJson()).op, 'cancel');
});
test('a swap that puts it up keeps the queue going', () {
engine.applyQueueAdd('a', next('Ep02'));
final id = idOf('Ep02');
engine.applySwap('a', const HelloMedia(name: 'Ep02', size: 0, duration: 0, url: 'https://example.com/2'),
queueId: id);
expect(engine.queue.isEmpty, isTrue);
expect(engine.media!.name, 'Ep02');
});
});
}
+253 -83
View File
@@ -1,8 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
// groups "sitting out", "membership + gate", "presence gate", "stale ready
// rejoins" and the member rules of "swap" pin today's presence (out flag,
// stale sweep, leave on drop); presence In/Away/Gone with grace replaces
// them ("liveness on the engine clock" then steps through the grace). The clock, correction, end-of-file and wire groups stay.
import 'dart:convert';
import 'package:sync_relay_core/core.dart';
@@ -41,52 +36,59 @@ void main() {
});
});
group('sitting out', () {
test('an out member never blocks, is never ready, its buffer is ignored', () {
group('away', () {
test('an away member never blocks; ready, force, swap and reports leave it away', () {
join('a', 'alice');
final bob = join('b', 'bob');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.held, reason: 'bob is in the set');
engine.applyOut('a', bob, true);
expect(engine.applyPresence('a', bob, Presence.away), isNull);
final b = engine.members['b']!;
expect([b.out, b.inSet, b.ready], [true, false, false]);
expect(b.presence, Presence.away);
expect(engine.countsForGate(b), isFalse);
expect(engine.transport, Transport.playing);
final feed = engine.feed.drain().lastWhere((f) => f.action == 'out');
expect([feed.actor, feed.target], ['alice', 'bob']);
expect(engine.broadcast().members.last.toJson()['out'], isTrue);
expect(engine.broadcast().members.first.toJson().containsKey('out'), isFalse);
// Ready, force, swap and reports leave it out.
final feed = engine.feed.drain().lastWhere((f) => f.action == 'away');
expect([feed.actor, feed.actorId, feed.target, feed.targetId], ['alice', 'm1', 'bob', 'm2']);
final view = engine.broadcast().members.last.toJson();
expect([view['presence'], view['inSet']], ['away', false]);
expect(engine.broadcast().members.first.toJson()['presence'], 'in');
engine.applyReady('b', true);
engine.applyForce('a', 'all', true);
buffer('b', 9);
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
expect([b.ready, b.inSet, b.buffered], [false, false, 0]);
expect(b.presence, Presence.away);
expect(engine.countsForGate(b), isFalse);
expect(engine.members['a']!.inSet, isTrue);
});
test('only oneself comes back: in the set unready, ready follows', () {
test('only oneself comes back in, through joining: no pause while playing', () {
engine.media = const HelloMedia(name: 'f', size: 1, duration: 100);
final alice = join('a', 'alice');
final bob = join('b', 'bob');
engine.applyOut('b', bob, true);
expect(engine.feed.drain().last.target, isNull, reason: 'sat out by itself');
engine.applyReady('a', true);
buffer('a', 4.0);
expect(engine.transport, Transport.playing);
engine.applyOut('a', bob, false);
expect(engine.members['b']!.out, isTrue, reason: 'others cannot bring bob back');
engine.applyOut('b', bob, false);
final b = engine.members['b']!;
expect([b.out, b.inSet, b.ready], [false, false, false], reason: 'playing: in from its ready');
expect(engine.transport, Transport.playing);
engine.applyReady('b', true);
expect(engine.transport, Transport.held, reason: 'now the gate waits for bob');
buffer('b', 4.0);
engine.applyPresence('b', bob, Presence.away, reason: PresenceReason.idle);
final line = engine.feed.drain().last;
expect([line.action, line.target, line.reason], ['away', null, 'idle'], reason: 'sat out by itself');
engine.applyReady('a', true);
expect(engine.transport, Transport.playing);
// The host can be sat out like anyone.
expect(engine.applyPresence('a', bob, Presence.watching), ErrCode.forbidden);
expect(engine.members['b']!.presence, Presence.away, reason: 'others cannot bring bob back');
expect(engine.applyPresence('b', '', Presence.watching), isNull);
final b = engine.members['b']!;
expect(b.presence, Presence.joining);
expect(engine.feed.drain().last.action, 'in');
engine.applyReady('b', true);
t = t.add(const Duration(seconds: 1));
expect(engine.transport, Transport.playing, reason: 'a joiner never holds a playing gate (D2)');
buffer('b', 4.0);
expect(b.presence, Presence.watching);
expect(engine.transport, Transport.playing, reason: 'it came in already in sync');
// The host can be set away like anyone.
engine.members['a']!.host = true;
engine.applyOut('b', alice, true);
expect(engine.members['a']!.out, isTrue);
engine.applyPresence('b', alice, Presence.away);
expect(engine.members['a']!.presence, Presence.away);
});
});
@@ -177,7 +179,7 @@ void main() {
expect(engine.members['b']!.inSet, isTrue);
});
test('disconnect removes from quorum, playback resumes', () {
test('a drop is not a leave: gone, out of the gate, kept with its ready flag', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
@@ -185,25 +187,18 @@ void main() {
buffer('a', 4.0);
buffer('b', 4.0);
expect(engine.transport, Transport.playing);
engine.applyLeave('b');
engine.applyGone('b');
expect(engine.transport, Transport.playing);
final b = engine.members['b']!;
expect([b.presence, b.ready, b.goneSince], [Presence.gone, true, t]);
final view = engine.broadcast().members.last.toJson();
expect([view['presence'], view['goneSince'], view['inSet']], ['gone', t.toIso8601String(), false]);
engine.applyLeave('b', reason: 'bye');
expect(engine.members.containsKey('b'), isFalse);
expect(engine.feed.drain().last.reason, 'bye');
});
test('stale ghosts excluded from gate', () { join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 4.0);
buffer('b', 4.0);
expect(engine.transport, Transport.playing);
t = t.add(const Duration(seconds: 60));
engine.sweepStale();
expect(engine.transport, Transport.playing);
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').stale, isTrue);
});
test('silent past drop window removed with leave feed', () {
test('silent past the limit goes gone (not a leave)', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
@@ -211,18 +206,34 @@ void main() {
buffer('a', 4.0);
buffer('b', 4.0);
expect(engine.transport, Transport.playing);
t = t.add(const Duration(seconds: 30));
expect(engine.sweepDropped(), isEmpty);
t = t.add(const Duration(seconds: 15));
buffer('a', 4.0);
t = t.add(const Duration(seconds: 61));
expect(engine.sweepSilent(), isEmpty, reason: 'exactly the limit is not past it');
t = t.add(const Duration(seconds: 1));
buffer('a', 4.0);
final dropped = engine.sweepDropped();
expect(dropped, ['b']);
expect(engine.members.containsKey('b'), isFalse);
expect(engine.sweepSilent(), ['b']);
expect(engine.members['b']!.presence, Presence.gone);
expect(engine.transport, Transport.playing);
expect(engine.feed.drain().where((f) => f.action == 'leave'), isEmpty);
});
test('a gone member leaves after the grace (feed leave, reason grace)', () {
final left = <String>[];
engine.onLeft = (m) => left.add(m.id);
join('a', 'alice');
join('b', 'bob');
engine.applyGone('b');
t = t.add(const Duration(minutes: 9, seconds: 59));
buffer('a', 0);
engine.tick();
expect(engine.members.containsKey('b'), isTrue);
t = t.add(const Duration(seconds: 1));
buffer('a', 0);
engine.tick();
expect(engine.members.containsKey('b'), isFalse);
expect(left, ['m2']);
final leaves = engine.feed.drain().where((f) => f.action == 'leave').toList();
expect(leaves.single.actor, 'bob');
expect(leaves.single.reason, 'silent too long');
expect([leaves.single.actor, leaves.single.actorId, leaves.single.reason], ['bob', 'm2', 'grace']);
});
});
@@ -262,13 +273,14 @@ void main() {
expect(engine.transport, Transport.playing);
});
test('stale other exempts the lone watcher', () {
test('a silent other (gone) exempts the lone watcher', () {
join('a', 'alice');
join('b', 'bob');
t = t.add(const Duration(seconds: 16));
engine.applyReady('a', true);
buffer('a', 6.0);
engine.tick();
expect(engine.members['b']!.presence, Presence.gone);
expect(engine.transport, Transport.playing);
});
@@ -420,19 +432,25 @@ void main() {
expect(engine.transport, Transport.playing);
});
test('a stale member stays out, rejoins with its next frame if ready', () {
test('a gone member stays out of the gate; back, it comes in through joining', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('b', true);
t = t.add(const Duration(seconds: 16));
engine.applyGone('b');
engine.applyReady('a', true);
engine.applySwap('a', ep2);
expect(engine.members['b']!.inSet, isFalse);
expect(engine.countsForGate(engine.members['b']!), isFalse);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
expect(engine.applyTakeover('b', seq: 1), isTrue);
final b = engine.members['b']!;
expect([b.presence, b.ready], [Presence.joining, true]);
buffer('b', 0);
expect(engine.members['b']!.inSet, isTrue);
expect(engine.transport, Transport.held);
expect(engine.transport, Transport.playing, reason: 'returning never pauses a playing gate (D2)');
buffer('b', 6.0);
expect(b.presence, Presence.watching);
expect(engine.countsForGate(b), isTrue);
expect(engine.transport, Transport.playing);
});
test("the previous media's buffer never starts the new one", () {
@@ -584,8 +602,8 @@ void main() {
});
});
group('stale ready rejoins', () {
test('ready member swept while stale is back in the set when reports resume', () {
group('return', () {
test('a ready member back from gone is in the set again once in sync', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
@@ -593,19 +611,19 @@ void main() {
buffer('a', 6.0);
buffer('b', 6.0);
expect(engine.transport, Transport.playing);
// bob goes silent past staleAfter; alice keeps reporting.
// bob goes silent past the limit; alice keeps reporting.
for (var i = 0; i < 16; i++) {
t = t.add(const Duration(seconds: 1));
buffer('a', 6.0);
engine.sweepStale();
engine.tick();
}
expect(engine.members['b']!.inSet, isFalse);
expect(engine.members['b']!.presence, Presence.gone);
expect(engine.members['b']!.ready, isTrue);
// bob's reports resume: he must be gated again without pressing Space.
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isTrue);
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').inSet,
isTrue);
// No media up: nothing to load, bob is `in` at once and gated again
// without pressing Space.
engine.applyTakeover('b', seq: 1);
expect(engine.members['b']!.presence, Presence.watching);
expect(engine.broadcast().members.firstWhere((m) => m.id == 'm2').inSet, isTrue);
});
test('unready late joiner does not join on reports', () {
@@ -616,34 +634,184 @@ void main() {
buffer('b', 6.0);
expect(engine.members['b']!.inSet, isFalse);
});
test('away survives a drop: back away, not joining', () {
join('a', 'alice');
final bob = join('b', 'bob');
engine.applyPresence('b', bob, Presence.away);
engine.applyGone('b');
expect(engine.members['b']!.presence, Presence.gone);
engine.applyTakeover('b', seq: 2);
expect(engine.members['b']!.presence, Presence.away);
});
test('the gone line waits; a quick return says nothing, a late one says return', () {
join('a', 'alice');
join('b', 'bob');
engine.feed.drain();
engine.applyGone('b');
t = t.add(const Duration(seconds: 9));
buffer('a', 0);
engine.tick();
expect(engine.feed.drain().where((f) => f.action == 'gone'), isEmpty);
engine.applyTakeover('b', seq: 1);
expect(engine.feed.drain().map((f) => f.action).where((a) => a != 'pause' && a != 'resume'), isEmpty,
reason: 'a quick return is silent');
engine.applyGone('b');
t = t.add(const Duration(seconds: 10));
buffer('a', 0);
engine.tick();
final gone = engine.feed.drain().where((f) => f.action == 'gone').toList();
expect([gone.single.actor, gone.single.actorId], ['bob', 'm2']);
engine.applyTakeover('b', seq: 2);
expect(engine.feed.drain().map((f) => f.action), contains('return'));
});
});
group('D2: joiners', () {
const ep = HelloMedia(name: 'Ep01.mkv', size: 9, duration: 600);
test('a joiner while playing never changes the transport, even ready', () {
engine.media = ep;
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 6.0);
expect(engine.transport, Transport.playing);
final events = engine.eventId;
join('b', 'bob');
final b = engine.members['b']!;
expect(b.presence, Presence.joining);
engine.applyReady('b', true);
buffer('b', 0);
t = t.add(const Duration(seconds: 10));
engine.applyReport('b', 0, 30, engine.eventId);
expect(b.presence, Presence.joining, reason: 'its buffer is at 0, not at the server position');
buffer('a', 6.0);
expect([engine.transport, engine.eventId], [Transport.playing, events]);
buffer('b', 6.0);
expect(b.presence, Presence.watching);
expect([engine.transport, engine.eventId], [Transport.playing, events], reason: 'in, already in sync');
expect(engine.feed.drain().map((f) => f.action), isNot(contains('pause')));
});
test('while held a joiner holds at most joinerHold, then the others start', () {
engine.media = ep;
join('a', 'alice');
buffer('a', 6.0);
engine.applyReady('a', true);
expect(engine.transport, Transport.playing);
engine.applyReady('a', false);
expect(engine.transport, Transport.held);
join('b', 'bob');
engine.applyReady('a', true);
expect(engine.transport, Transport.held, reason: 'bob joined while held: he is waited for');
final hold = engine.holdInfo()!;
expect([hold.reason, hold.members, hold.until], [HoldReason.joining, ['m2'], t.add(const Duration(seconds: 15))]);
t = t.add(const Duration(seconds: 14));
buffer('a', 6.0);
engine.tick();
expect(engine.transport, Transport.held);
t = t.add(const Duration(seconds: 1));
buffer('a', 6.0);
engine.tick();
expect(engine.transport, Transport.playing, reason: 'bounded hold (D2)');
expect(engine.members['b']!.presence, Presence.joining);
});
test('joining ends on a report error or the timeout', () {
engine.media = ep;
join('a', 'alice');
join('b', 'bob');
engine.applyReport('a', 0, 0, engine.eventId, mediaId: engine.mediaId, error: 'Not found (404)');
expect(engine.members['a']!.presence, Presence.watching);
expect(engine.countsForGate(engine.members['a']!), isFalse, reason: 'the error keeps it out');
for (var i = 0; i < 59; i++) {
t = t.add(const Duration(seconds: 1));
buffer('b', 0); // still loading
engine.tick();
}
expect(engine.members['b']!.presence, Presence.joining);
t = t.add(const Duration(seconds: 1));
buffer('b', 0);
engine.tick();
expect(engine.members['b']!.presence, Presence.watching);
});
test('hold reasons: ready, buffer, source, start', () {
engine.media = ep;
join('a', 'alice');
buffer('a', 6.0);
expect(engine.holdInfo()!.reason, HoldReason.ready);
engine.applyReady('a', true);
engine.applySwap('a', ep);
expect([engine.holdInfo()!.reason, engine.holdInfo()!.members], [HoldReason.buffer, ['m1']]);
expect(engine.holdInfo(sourceGone: true)!.reason, HoldReason.source);
engine.applyPresence('a', '', Presence.away);
expect(engine.holdInfo()!.reason, HoldReason.start);
expect(engine.broadcast().toJson()['hold'], {'reason': 'start', 'members': <String>[]});
});
});
group('feed', () {
test('lines carry ids and a rising seq; a replay keeps the last lines after a seq', () {
join('a', 'alice');
final bob = join('b', 'bob');
engine.applyForce('a', bob, true);
final lines = engine.feed.drain();
expect([for (final f in lines) f.seq], [1, 2, 3]);
expect([lines.last.actorId, lines.last.targetId, lines.last.target], ['m1', 'm2', 'bob']);
for (var i = 0; i < 60; i++) {
engine.applySeek('a', i.toDouble());
}
engine.feed.drain();
expect(engine.feed.after(2), hasLength(RelayConstants.feedReplayLines), reason: 'bounded');
final replay = engine.feed.after(60);
expect([for (final f in replay) f.seq], [61, 62, 63]);
expect(replay.every((f) => f.replayed), isTrue);
final json = replay.first.toJson();
expect([json['seq'], json['actorId'], json['replayed']], [61, 'm1', true]);
expect(FeedMessage.fromJson(json).replayed, isTrue);
});
});
group('liveness on the engine clock', () {
test('stale and drop follow Liveness and the fake clock, not wall time', () {
test('silence, gone notice, grace follow Liveness and the fake clock, not wall time', () {
engine = SyncEngine(
clock: clock,
liveness: const Liveness(staleAfter: Duration(minutes: 5), dropAfter: Duration(minutes: 10)));
liveness: const Liveness(
silence: Duration(minutes: 5), goneNotice: Duration(minutes: 1), grace: Duration(minutes: 10)));
join('a', 'alice');
engine.applyReady('a', true);
engine.feed.drain();
t = t.add(const Duration(minutes: 5));
engine.tick();
expect(engine.members['a']!.inSet, isTrue, reason: 'not past staleAfter yet');
expect(engine.members['a']!.presence, Presence.watching, reason: 'not past the silence yet');
t = t.add(const Duration(seconds: 1));
engine.tick();
expect(engine.members['a']!.inSet, isFalse);
expect(engine.feed.drain().map((f) => f.action), ['stale']);
t = t.add(const Duration(minutes: 4, seconds: 59));
expect(engine.sweepDropped(), isEmpty, reason: 'exactly dropAfter is not past it');
expect(engine.members['a']!.presence, Presence.gone);
expect(engine.feed.drain(), isEmpty);
t = t.add(const Duration(minutes: 1));
engine.tick();
expect(engine.feed.drain().map((f) => f.action), ['gone']);
t = t.add(const Duration(minutes: 8, seconds: 59));
engine.tick();
expect(engine.members, isNotEmpty);
t = t.add(const Duration(seconds: 1));
expect(engine.sweepDropped(), ['a']);
engine.tick();
expect(engine.members, isEmpty);
expect(engine.feed.drain().map((f) => f.action), ['leave']);
});
test('defaults are today\'s 15 s / 30 s', () {
expect(engine.staleAfter, const Duration(seconds: 15));
expect(engine.dropAfter, const Duration(seconds: 30));
expect(engine.liveness.pingInterval, const Duration(seconds: 5));
test('defaults: 15 s silence, 10 min grace, 10 s gone notice, 15 s joiner hold', () {
final l = engine.liveness;
expect([l.silence, l.grace, l.goneNotice, l.joinerHold, l.joiningTimeout, l.pingInterval], const [
Duration(seconds: 15),
Duration(minutes: 10),
Duration(seconds: 10),
Duration(seconds: 15),
Duration(seconds: 60),
Duration(seconds: 5),
]);
});
});
@@ -687,6 +855,7 @@ void main() {
engine.applyReady('a', true);
buffer('a', 6.0);
t = t.add(const Duration(seconds: 21));
buffer('a', 6.0); // a frame a second keeps it present
engine.tick();
expect(engine.transport, Transport.finished);
engine.applySeek('a', 5);
@@ -700,6 +869,7 @@ void main() {
engine.applyReady('a', true);
buffer('a', 6.0);
t = t.add(const Duration(seconds: 21));
buffer('a', 6.0); // a frame a second keeps it present
engine.tick();
expect(engine.transport, Transport.finished);
engine.applySwap(