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
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user