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:
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

+1
View File
@@ -4,6 +4,7 @@ export 'src/logging.dart';
export 'src/diag_share.dart';
export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/presence.dart';
export 'src/protocol.dart';
export 'src/queue.dart';
export 'src/relay.dart';
+27 -5
View File
@@ -1,5 +1,12 @@
abstract class RelayConstants {
static const int protoVersion = 15;
/// The wire version this build speaks best (`hello.proto`,
/// `welcome.proto`); always [protoMax].
static const int protoVersion = protoMax;
/// The range of wire versions this build accepts (`hello.protoMin` ..
/// `hello.proto` on a client, `/health.protoMin` .. `protoMax` on a
/// server). Additions within a version family raise [protoMax] only.
static const int protoMin = 16;
static const int protoMax = 16;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -44,10 +51,25 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5;
/// Server liveness defaults ([Liveness]): a member silent this long is
/// stale, then removed.
static const int staleAfterSecs = 15;
static const int dropAfterSecs = 30;
/// Server liveness defaults ([Liveness], PROTOCOL.md "Presence"): a
/// socket without a frame for [silenceSecs] is closed and its member is
/// `gone`; a `gone` member is kept [graceSecs] (`welcome.grace`), then
/// it leaves; the feed says `gone` only after [goneNoticeSecs] (a quick
/// return says nothing). A joiner holds a HELD gate at most
/// [joinerHoldSecs] (D2) and stays `joining` at most [joiningTimeoutSecs].
static const int silenceSecs = 15;
static const int graceSecs = 600;
static const int goneNoticeSecs = 10;
static const int joinerHoldSecs = 15;
static const int joiningTimeoutSecs = 60;
/// Feed lines the server keeps for `hello.feedAfter` (the replay in
/// `welcome.feed`).
static const int feedReplayLines = 50;
/// `hello.client`: an opaque per-install id, base64url, this long.
static const int minClientIdLength = 16;
static const int maxClientIdLength = 64;
/// `media.ref` ([MediaRef]) as JSON: at most this many bytes.
static const int maxMediaRefBytes = 2048;
// Base64-char count of the avatar string, enforced at hello and
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
+3 -2
View File
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|'
r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|'
r'sessdata|bili_jct|client_?secret)';
r'sessdata|bili_jct|client_?secret|client_?id|client)';
final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false);
// Header-like `Name: value` runs to the end of the line (a cookie
@@ -135,7 +135,8 @@ final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
/// [s] with nothing secret left: every URL's query string down to its
/// parameter names (signed CDN links, `api_key`), user:password@ in URLs,
/// member tokens in `/r/ /m/ /j/ /a/ /i/ /l/` paths, and the values of
/// passwords, tokens, API keys, cookies and auth headers. Applied to
/// passwords, tokens, API keys, cookies, auth headers and the per-install
/// client id (`hello.client`). Applied to
/// every log line ([formatLogLine]).
String redactLog(String s) {
if (s.isEmpty) return s;
+124
View File
@@ -0,0 +1,124 @@
import 'constants.dart';
// Presence (D1, PROTOCOL.md "Presence"): where a member stands, and one
// table of what each standing counts for. Pure, so the server's engine and
// the app read the same rules.
/// A member's presence, `members[].presence` on the wire ([wire]).
enum Presence {
/// New, back from `gone`, or back from `away`: loading the current media
/// in the background until its reports hold it with `bufferNeed`, then
/// [watching]. Wire `joining`.
joining,
/// Watching, in full (D1 "In"). Wire `in` (a Dart keyword, hence the name).
watching,
/// Sat out (by itself or by someone else): still in the server (chat,
/// feed, chosen notifications), never counted for the gate, the ready
/// counter or watched tracking. Wire `away`.
away,
/// Disconnected and kept for the grace period (`welcome.grace`) with
/// everything it had (ready flag, queue items, relay, picture). Wire
/// `gone`.
gone;
String get wire => this == watching ? 'in' : name;
/// Null for anything else.
static Presence? parse(Object? v) {
if (v == 'in') return watching;
for (final p in values) {
if (p != watching && p.name == v) return p;
}
return null;
}
}
/// Why a member set itself (or someone) `away` or `in`
/// (`presence.reason`): by hand, idle, its window minimised, or the app
/// closing (`bye`, reserved for the app's own record).
enum PresenceReason {
manual,
idle,
minimised,
bye;
static PresenceReason parse(Object? v) {
for (final r in values) {
if (r.name == v) return r;
}
return manual;
}
}
/// What a presence is asked about ([PresencePolicy.countsFor]).
enum Purpose {
/// The ready gate: who the server waits for before it plays.
gate,
/// "2/3 ready": who the ready counter counts.
readyCounter,
/// Watched tracking (Jellyfin played, history): who is watching.
tracking,
/// Getting Up next ready (warm relay readers, preloads).
preload,
/// Social notifications (joins, queue adds, chat pop-ups) in full.
notifySocial,
}
/// The one table of what a presence counts for (D1, D2):
///
/// | purpose | joining | in | away | gone |
/// |----------------|---------|-----|------|------|
/// | gate | D2 * | yes | no | no |
/// | readyCounter | yes | yes | no | no |
/// | tracking | no | yes | no | no |
/// | preload | yes | yes | no | no |
/// | notifySocial | yes | yes | no | no |
///
/// \* A `joining` member never holds a PLAYING gate (it loads in the
/// background and comes in already in sync); while HELD it holds at most
/// [joinerHold] from the moment it started joining, then the others go on
/// without it.
class PresencePolicy {
/// How long a joiner may hold a HELD gate (D2).
final Duration joinerHold;
const PresencePolicy({this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs)});
/// Whether a member with [presence] counts for [purpose]. For
/// [Purpose.gate] a `joining` member needs the gate's context: [playing]
/// (the transport is `playing`) and [joiningFor] (how long it has been
/// joining, on the server's clock).
bool countsFor(Presence presence, Purpose purpose,
{bool playing = false, Duration joiningFor = Duration.zero}) {
switch (presence) {
case Presence.watching:
return true;
case Presence.away:
case Presence.gone:
return false;
case Presence.joining:
switch (purpose) {
case Purpose.gate:
return !playing && joiningFor < joinerHold;
case Purpose.tracking:
return false;
case Purpose.readyCounter:
case Purpose.preload:
case Purpose.notifySocial:
return true;
}
}
}
}
/// D9 "alone": no other member is connected. [others] are the presences
/// of everyone but oneself; `in`, `joining` and `away` are company, `gone`
/// is not. Auto sit-out (never when alone) and the social notifications
/// read this one definition.
bool isAlone(Iterable<Presence> others) => others.every((p) => p == Presence.gone);
+576 -61
View File
@@ -1,5 +1,8 @@
import 'dart:convert';
import 'constants.dart';
import 'errors.dart';
import 'presence.dart';
import 'queue.dart';
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
@@ -97,6 +100,89 @@ class JellyfinRef {
Object.hash(serverId, itemId, mediaSourceId, title, subtitle, runtimeSecs);
}
/// What a media is, independent of where its bytes come from (`media.ref`):
/// an episode, a movie or an extra, by AniList and/or TVDB ids. Carried
/// as is in `swap.media`, `state.media` and `queue[].media` (the server
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing
/// produces it yet (step 4).
class MediaRef {
static const kinds = {'episode', 'movie', 'extra'};
/// `episode`, `movie` or `extra`.
final String kind;
final String title;
final int? anilistId;
/// The episode number within the AniList entry.
final int? anilistEp;
final int? tvdbId;
/// TVDB season (null: unknown or none, e.g. absolute numbering).
final int? season;
final int? ep;
/// Absolute episode number across seasons.
final int? absolute;
const MediaRef(
{required this.kind,
this.title = '',
this.anilistId,
this.anilistEp,
this.tvdbId,
this.season,
this.ep,
this.absolute});
Map<String, Object?> toJson() => {
'kind': kind,
if (title.isNotEmpty) 'title': title,
if (anilistId != null) 'anilistId': anilistId,
if (anilistEp != null) 'anilistEp': anilistEp,
if (tvdbId != null) 'tvdbId': tvdbId,
if (season != null) 'season': season,
if (ep != null) 'ep': ep,
if (absolute != null) 'absolute': absolute,
};
/// Null when [j] is not a ref (no known `kind`); throws
/// [ProtocolException] when it is larger than
/// [RelayConstants.maxMediaRefBytes] as JSON. Ids that are not
/// non-negative integers are dropped; the title is one line of at most
/// 200 characters.
static MediaRef? fromJson(Object? j) {
if (j is! Map) return null;
if (utf8.encode(jsonEncode(j)).length > RelayConstants.maxMediaRefBytes) {
throw const ProtocolException('media.ref too large');
}
final kind = j['kind'];
if (kind is! String || !kinds.contains(kind)) return null;
int? n(Object? v) => v is num && v.isFinite && v >= 0 && v == v.roundToDouble() ? v.toInt() : null;
final title = j['title'] is String ? (j['title'] as String).replaceAll(RegExp(r'\s+'), ' ').trim() : '';
return MediaRef(
kind: kind,
title: title.length > 200 ? title.substring(0, 200) : title,
anilistId: n(j['anilistId']),
anilistEp: n(j['anilistEp']),
tvdbId: n(j['tvdbId']),
season: n(j['season']),
ep: n(j['ep']),
absolute: n(j['absolute']),
);
}
@override
bool operator ==(Object other) =>
other is MediaRef &&
other.kind == kind &&
other.title == title &&
other.anilistId == anilistId &&
other.anilistEp == anilistEp &&
other.tvdbId == tvdbId &&
other.season == season &&
other.ep == ep &&
other.absolute == absolute;
@override
int get hashCode => Object.hash(kind, title, anilistId, anilistEp, tvdbId, season, ep, absolute);
}
class HelloMedia {
final String name;
final int size;
@@ -106,13 +192,16 @@ class HelloMedia {
final JellyfinRef? jellyfin;
/// Relayed media (bytes from the source member through the server).
final MediaRelay? relay;
/// What it is ([MediaRef]); null when unknown.
final MediaRef? ref;
const HelloMedia(
{required this.name,
required this.size,
required this.duration,
this.url,
this.jellyfin,
this.relay});
this.relay,
this.ref});
HelloMedia copyWith({double? duration, int? size, MediaRelay? relay}) => HelloMedia(
name: name,
@@ -120,7 +209,8 @@ class HelloMedia {
duration: duration ?? this.duration,
url: url,
jellyfin: jellyfin,
relay: relay ?? this.relay);
relay: relay ?? this.relay,
ref: ref);
Map<String, Object?> toJson() => {
'name': name,
@@ -129,6 +219,7 @@ class HelloMedia {
if (url != null) 'url': url,
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
if (relay != null) 'relay': relay!.toJson(),
if (ref != null) 'ref': ref!.toJson(),
};
factory HelloMedia.fromJson(Map<String, Object?> json) {
@@ -141,6 +232,7 @@ class HelloMedia {
url: json['url'] as String?,
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
relay: relay is Map ? MediaRelay.fromJson(Map<String, Object?>.from(relay)) : null,
ref: MediaRef.fromJson(json['ref']),
);
}
}
@@ -185,7 +277,10 @@ class RelayTrackSpec {
/// buffer asks for bytes, and the media's average bitrate. [queueId]:
/// the Up next item this relay was started for (its warm relay) before
/// it went up; viewers open its `/r/.../<queueId>/...` URLs, so their
/// preloads of it carry on.
/// preloads of it carry on. [sourceGone]: the source member is `gone`
/// (since [sourceGoneSince]): the relay and its buffer stay, viewers play
/// what is buffered; it stays set once the source left for good (the
/// relay has ended then: `live` false, `/r` answers 404).
class MediaRelay {
final String source;
final bool fetch;
@@ -194,6 +289,8 @@ class MediaRelay {
final int rate;
final int need;
final String queueId;
final bool sourceGone;
final DateTime? sourceGoneSince;
const MediaRelay(
{this.source = '',
this.fetch = false,
@@ -201,7 +298,9 @@ class MediaRelay {
this.tracks = const [],
this.rate = 0,
this.need = 0,
this.queueId = ''});
this.queueId = '',
this.sourceGone = false,
this.sourceGoneSince});
/// The source is measured slower than the media plays (both known):
/// below 85 % of [need], so a rate close to it does not flicker.
@@ -214,14 +313,26 @@ class MediaRelay {
static bool validTracks(List<RelayTrackSpec> t) =>
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks, int? rate, int? need}) => MediaRelay(
source: source,
fetch: fetch,
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need,
queueId: queueId);
/// [sourceGoneSince] null with [sourceGone] true: since now is unknown,
/// keep the earlier one; pass [sourceGone] false to clear both.
MediaRelay copyWith(
{bool? live,
List<RelayTrackSpec>? tracks,
int? rate,
int? need,
bool? sourceGone,
DateTime? sourceGoneSince}) =>
MediaRelay(
source: source,
fetch: fetch,
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need,
queueId: queueId,
sourceGone: sourceGone ?? this.sourceGone,
sourceGoneSince:
(sourceGone ?? this.sourceGone) ? (sourceGoneSince ?? this.sourceGoneSince) : null);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
@@ -231,6 +342,8 @@ class MediaRelay {
if (rate > 0) 'rate': rate,
if (need > 0) 'need': need,
if (queueId.isNotEmpty) 'queueId': queueId,
if (sourceGone) 'sourceGone': true,
if (sourceGone && sourceGoneSince != null) 'sourceGoneSince': sourceGoneSince!.toUtc().toIso8601String(),
};
/// Unknown kinds, a single byte track or too many make it one file.
@@ -238,6 +351,7 @@ class MediaRelay {
final t = json['tracks'];
final specs = t is List ? [for (final e in t) RelayTrackSpec.fromJson(e)] : const <RelayTrackSpec?>[];
final ok = !specs.contains(null) && validTracks(specs.cast<RelayTrackSpec>());
final gone = json['sourceGone'] == true;
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
@@ -245,59 +359,167 @@ class MediaRelay {
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
rate: _byteRate(json['rate']),
need: _byteRate(json['need']),
queueId: json['queueId'] is String ? json['queueId'] as String : '');
queueId: json['queueId'] is String ? json['queueId'] as String : '',
sourceGone: gone,
sourceGoneSince: gone ? DateTime.tryParse('${json['sourceGoneSince']}') : null);
}
/// A bytes-per-second field: a finite non-negative number, else 0.
static int _byteRate(Object? v) => v is num && v.isFinite && v > 0 ? v.round() : 0;
}
/// The highest wire version both sides speak: the client's
/// [clientMin]..[clientMax] (`hello.protoMin`..`hello.proto`) against this
/// build's [RelayConstants.protoMin]..[RelayConstants.protoMax]; null when
/// the ranges do not meet.
int? pickProto(int clientMin, int clientMax,
{int serverMin = RelayConstants.protoMin, int serverMax = RelayConstants.protoMax}) {
final hi = clientMax < serverMax ? clientMax : serverMax;
final lo = clientMin > serverMin ? clientMin : serverMin;
return hi >= lo ? hi : null;
}
/// What this app can do (`hello.caps`, `members[].caps`): serve a relay
/// ([source]), resolve links with yt-dlp ([resolve]), play Jellyfin with
/// its own account ([jellyfin]). Unknown entries are ignored. Wire: an
/// object of `true` flags, e.g. `{"source":true,"resolve":true}`.
class ClientCaps {
final bool source;
final bool resolve;
final bool jellyfin;
const ClientCaps({this.source = false, this.resolve = false, this.jellyfin = false});
bool get isEmpty => !source && !resolve && !jellyfin;
Map<String, Object?> toJson() => {
if (source) 'source': true,
if (resolve) 'resolve': true,
if (jellyfin) 'jellyfin': true,
};
factory ClientCaps.fromJson(Object? j) => j is Map
? ClientCaps(source: j['source'] == true, resolve: j['resolve'] == true, jellyfin: j['jellyfin'] == true)
: const ClientCaps();
@override
bool operator ==(Object other) =>
other is ClientCaps && other.source == source && other.resolve == resolve && other.jellyfin == jellyfin;
@override
int get hashCode => Object.hash(source, resolve, jellyfin);
}
/// `hello.client`: an opaque per-install id ([RelayConstants.minClientIdLength]
/// to [RelayConstants.maxClientIdLength] characters of base64url). Private:
/// never broadcast, never logged.
bool validClientId(String id) =>
id.length >= RelayConstants.minClientIdLength &&
id.length <= RelayConstants.maxClientIdLength &&
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id);
/// The first frame of a `/sync` socket. A proto 16 app sends `media: {}`
/// (ignored) so a proto 15 server, which requires it, answers with its
/// "proto mismatch" instead of a parse error.
class HelloMessage {
/// The client's highest wire version (`hello.proto`).
final int proto;
/// The client's lowest wire version (`hello.protoMin`); defaults to
/// [proto] when absent.
final int protoMin;
final String secret;
final HelloUser user;
final HelloMedia media;
/// The hosting app's own client proves it is the host with this; empty
/// for everyone else.
final String hostKey;
/// Token from this client's previous `welcome`, sent on reconnect: if the
/// server still has that member (an unclean drop it has not noticed), the
/// connection takes it over instead of leaving a ghost.
final String resume;
/// Per-install id ([validClientId]): a hello with a known one takes that
/// member over. Empty: a fresh member every time (tools, tests).
final String client;
/// Increasing per [client] (every connect of this install sends a higher
/// one): an older hello than the member's last gets `err stale`.
final int seq;
/// App version (`"1.3.0"`), shown as `members[].app`.
final String app;
/// `linux`, `windows`, `macos` or `android`; logged, not broadcast.
final String platform;
final ClientCaps caps;
/// The last `feed.seq` this client saw from this server: `welcome.feed`
/// replays what came after it (at most [RelayConstants.feedReplayLines]).
/// Null: no replay.
final int? feedAfter;
const HelloMessage(
{required this.proto,
{this.proto = RelayConstants.protoMax,
int? protoMin,
required this.secret,
required this.user,
required this.media,
this.hostKey = '',
this.resume = ''});
this.client = '',
this.seq = 0,
this.app = '',
this.platform = '',
this.caps = const ClientCaps(),
this.feedAfter})
: protoMin = protoMin ?? (proto < RelayConstants.protoMin ? proto : RelayConstants.protoMin);
Map<String, Object?> toJson() => {
't': 'hello',
'protoMin': protoMin,
'proto': proto,
'secret': secret,
'user': user.toJson(),
'media': media.toJson(),
'media': const <String, Object?>{},
if (hostKey.isNotEmpty) 'hostKey': hostKey,
if (resume.isNotEmpty) 'resume': resume,
if (client.isNotEmpty) 'client': client,
if (seq != 0) 'seq': seq,
if (app.isNotEmpty) 'app': app,
if (platform.isNotEmpty) 'platform': platform,
if (!caps.isEmpty) 'caps': caps.toJson(),
if (feedAfter != null) 'feedAfter': feedAfter,
};
/// The client's version range read leniently from a raw hello, before
/// [HelloMessage.fromJson]: a hello of another version may not parse.
/// Null when it names none.
static ({int min, int max})? protoRangeOf(Map<String, Object?> json) {
final p = json['proto'];
if (p is! num || !p.isFinite) return null;
final m = json['protoMin'];
final max = p.toInt();
final min = m is num && m.isFinite && m.toInt() <= max ? m.toInt() : max;
return (min: min, max: max);
}
factory HelloMessage.fromJson(Map<String, Object?> json) {
if (json['t'] != 'hello') throw const ProtocolException('not a hello message');
final user = json['user'];
final media = json['media'];
if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media');
if (user is! Map) throw const ProtocolException('hello missing user');
final client = json['client'] ?? '';
if (client is! String || (client.isNotEmpty && !validClientId(client))) {
throw const ProtocolException('bad client id');
}
final proto = finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoMax);
final feedAfter = json['feedAfter'];
return HelloMessage(
proto: finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoVersion),
proto: proto,
protoMin: finiteInt(json['protoMin'], 'protoMin', fallback: proto),
secret: (json['secret'] as String?) ?? '',
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
hostKey: (json['hostKey'] as String?) ?? '',
resume: (json['resume'] as String?) ?? '',
client: client,
seq: finiteInt(json['seq'], 'seq'),
app: _short(json['app'], 32),
platform: _short(json['platform'], 16),
caps: ClientCaps.fromJson(json['caps']),
feedAfter: feedAfter == null ? null : finiteInt(feedAfter, 'feedAfter'),
);
}
}
/// A short plain string field: one line, at most [max] characters.
String _short(Object? v, int max) {
if (v is! String) return '';
final line = v.replaceAll(RegExp(r'\s+'), ' ').trim();
return line.length > max ? line.substring(0, max) : line;
}
class SyncState {
final double position;
final bool paused;
@@ -328,46 +550,210 @@ class WelcomeMessage {
/// server can relay it (`state.relay`); private like [token].
final String? relayUrl;
final Map<String, Object?>? state;
/// The wire version of this connection ([pickProto]).
final int proto;
/// The hello took over this client's member (same [memberId] and
/// [token], everything it had kept); false for a new member.
final bool resumed;
/// Random per server boot: a client that held a member of another
/// instance knows the server restarted (not that its grace ran out).
final String instance;
/// Seconds a `gone` member is kept ([RelayConstants.graceSecs]).
final int grace;
/// Feed lines after `hello.feedAfter`, oldest first, each `replayed`.
final List<FeedMessage> feed;
const WelcomeMessage(
{required this.token, this.memberId = '', this.mediaUrl, this.relayUrl, this.state});
{required this.token,
this.memberId = '',
this.mediaUrl,
this.relayUrl,
this.state,
this.proto = RelayConstants.protoMax,
this.resumed = false,
this.instance = '',
this.grace = RelayConstants.graceSecs,
this.feed = const []});
Map<String, Object?> toJson() => {
't': 'welcome',
'proto': proto,
'token': token,
'memberId': memberId,
'resumed': resumed,
'instance': instance,
'grace': grace,
if (mediaUrl != null) 'mediaUrl': mediaUrl,
if (relayUrl != null) 'relayUrl': relayUrl,
if (state != null) 'state': state,
if (feed.isNotEmpty) 'feed': [for (final f in feed) f.toJson()],
};
factory WelcomeMessage.fromJson(Map<String, Object?> json) {
if (json['t'] != 'welcome') throw const ProtocolException('not a welcome message');
final state = json['state'];
final feed = json['feed'];
return WelcomeMessage(
token: (json['token'] as String?) ?? '',
memberId: (json['memberId'] as String?) ?? '',
mediaUrl: json['mediaUrl'] as String?,
relayUrl: json['relayUrl'] as String?,
state: state is Map ? Map<String, Object?>.from(state) : null,
proto: finiteInt(json['proto'], 'proto', fallback: 0),
resumed: json['resumed'] == true,
instance: json['instance'] is String ? json['instance'] as String : '',
grace: finiteInt(json['grace'], 'grace', fallback: 0),
feed: feed is List
? [
for (final f in feed)
if (f is Map && f['t'] == 'feed') FeedMessage.fromJson(Map<String, Object?>.from(f)),
]
: const [],
);
}
}
/// Sits [target] (a member id) out, or with [out] false brings oneself
/// back. Out: connected, but not in the ready gate and not playing.
class OutIntent {
final String target;
final bool out;
const OutIntent({required this.target, required this.out});
/// Error codes of `err.code` (PROTOCOL.md "Errors"). Terminal ones
/// ([version], [secret], [replaced], [stale], a refused `hello` with
/// [rejected] or [bad]) are followed by a close ([CloseCodes]).
enum ErrCode {
/// No common wire version: `min`/`max`/`server` say the server's.
version,
/// Wrong `hello.secret`.
secret,
/// Understood but refused (an avatar too large, a fetch the server
/// cannot do).
rejected,
/// Malformed (a bad hello, a non-finite number, a bad swap or queue item).
bad,
/// Another hello with this client id took the member over (the client
/// stops: "opened in another window").
replaced,
/// This hello is older (`hello.seq`) than the member's current one.
stale,
/// Not allowed for this member (e.g. setting someone else `in`).
forbidden,
/// Over a rate limit (reserved; nudges over the cap are dropped silently).
rate,
/// A list is full (Up next at [RelayConstants.maxQueueItems]).
full;
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
factory OutIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
static ErrCode? parse(Object? v) {
for (final c in values) {
if (c.name == v) return c;
}
return null;
}
}
/// Close codes of the server's sockets (PROTOCOL.md "Close codes").
abstract class CloseCodes {
/// Relay channel: not (or no longer) the source; the source stops.
static const relayNotSource = 4001;
/// Relay channel: its `info` differs from the media's first one (another
/// file); the relay ends and the source stops.
static const relayOtherMedia = 4002;
/// Relay channel: a newer channel for the same relay replaced it; the
/// source stops.
static const relayReplaced = 4003;
/// `/sync`: refused at hello, `err` `version`.
static const version = 4010;
/// `/sync`: refused at hello, `err` `secret`.
static const secret = 4011;
/// `/sync`: refused at hello, `err` `rejected` or `bad`.
static const refused = 4012;
/// `/sync`: another hello took the member over (`err` `replaced`).
static const replaced = 4013;
/// `/sync`: an older hello than the member's (`err` `stale`).
static const stale = 4014;
/// `/sync`: no frame for the silence limit; the member is `gone`.
static const silent = 4015;
/// `/sync`: the member left (`bye`, or grace over).
static const left = 1000;
}
/// Server to client: `{"t":"err","code":..., "message":..., "for":...}`.
/// [message] is plain text for people (a proto 15 app shows it as is);
/// [code] is for programs. For [ErrCode.version]: [min]/[max] the server's
/// range and [server] its app version. [forType]: the `t` of the frame
/// that failed, when there is one (`for` on the wire).
class ErrorFrame {
final ErrCode? code;
final String message;
final String? forType;
final int? min;
final int? max;
final String? server;
const ErrorFrame({required this.code, required this.message, this.forType, this.min, this.max, this.server});
/// The stable refusal of a hello whose versions do not meet ours:
/// `{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0",
/// "message":"proto mismatch: got 15, want 16..16"}`.
factory ErrorFrame.version({required int got, required String server}) => ErrorFrame(
code: ErrCode.version,
message: 'proto mismatch: got $got, want ${RelayConstants.protoMin}..${RelayConstants.protoMax}',
forType: 'hello',
min: RelayConstants.protoMin,
max: RelayConstants.protoMax,
server: server);
Map<String, Object?> toJson() => {
't': 'err',
if (code != null) 'code': code!.name,
'message': message,
if (forType != null) 'for': forType,
if (min != null) 'min': min,
if (max != null) 'max': max,
if (server != null) 'server': server,
};
/// A proto 15 server's `err` has no [code].
factory ErrorFrame.fromJson(Map<String, Object?> json) {
if (json['t'] != 'err') throw const ProtocolException('not an err frame');
final min = json['min'], max = json['max'];
return ErrorFrame(
code: ErrCode.parse(json['code']),
message: json['message'] is String ? json['message'] as String : '',
forType: json['for'] is String ? json['for'] as String : null,
min: min is num && min.isFinite ? min.toInt() : null,
max: max is num && max.isFinite ? max.toInt() : null,
server: json['server'] is String ? json['server'] as String : null,
);
}
}
/// Client to server: set [target] (a member id; empty = oneself) [state]
/// `away` (anyone may, on anyone) or `in` (only oneself; else `err`
/// `forbidden`). Coming back `in` goes through `joining`.
class PresenceIntent {
final String target;
/// [Presence.watching] (`in`) or [Presence.away].
final Presence state;
final PresenceReason reason;
const PresenceIntent({this.target = '', required this.state, this.reason = PresenceReason.manual});
Map<String, Object?> toJson() =>
{'t': 'presence', if (target.isNotEmpty) 'target': target, 'state': state.wire, 'reason': reason.name};
factory PresenceIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'presence') throw const ProtocolException('not a presence intent');
final state = Presence.parse(json['state']);
if (state != Presence.watching && state != Presence.away) {
throw const ProtocolException('presence state must be in or away');
}
return PresenceIntent(
target: json['target'] is String ? json['target'] as String : '',
state: state!,
reason: PresenceReason.parse(json['reason']));
}
}
/// Client to server: leave now (the Leave button, the app closing). The
/// member leaves at once (feed `leave`, no grace) and the socket closes.
class ByeMessage {
const ByeMessage();
Map<String, Object?> toJson() => {'t': 'bye'};
}
/// Client to server: poke [target] (a member id). The server passes it on
/// to that member only, as a [NudgeNotice].
class NudgeIntent {
@@ -632,21 +1018,28 @@ class MemberView {
/// none.
final String avatarHash;
final bool ready;
/// Counts for the ready gate now: in the set and its presence counts
/// ([PresencePolicy], [Purpose.gate]).
final bool inSet;
final double buffered;
final bool stale;
/// Where it stands ([Presence]).
final Presence presence;
/// When it went `gone`; null otherwise.
final DateTime? goneSince;
/// Last `report.jf` of this member; empty when never reported.
final Map<String, bool> jf;
/// The hosting app's own member (sent the server's host key).
final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
final bool out;
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
/// Bytes per second the server sent this member lately (its file, the
/// Jellyfin relay or the relay); 0 when none.
final int rate;
/// Its app version (`hello.app`); empty when it sent none.
final String app;
/// What its app can do (`hello.caps`).
final ClientCaps caps;
const MemberView({
required this.id,
required this.name,
@@ -654,12 +1047,14 @@ class MemberView {
required this.ready,
required this.inSet,
required this.buffered,
required this.stale,
this.presence = Presence.watching,
this.goneSince,
this.jf = const {},
this.host = false,
this.out = false,
this.error = '',
this.rate = 0,
this.app = '',
this.caps = const ClientCaps(),
});
Map<String, Object?> toJson() => {
@@ -669,28 +1064,105 @@ class MemberView {
'ready': ready,
'inSet': inSet,
'buffered': buffered,
'stale': stale,
'presence': presence.wire,
if (presence == Presence.gone && goneSince != null) 'goneSince': goneSince!.toUtc().toIso8601String(),
if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
if (rate > 0) 'rate': rate,
if (app.isNotEmpty) 'app': app,
if (!caps.isEmpty) 'caps': caps.toJson(),
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
out: json['out'] == true,
error: memberErrorFromJson(json['error']),
rate: MediaRelay._byteRate(json['rate']),
);
factory MemberView.fromJson(Map<String, Object?> json) {
final presence = Presence.parse(json['presence']) ?? Presence.watching;
return MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
presence: presence,
goneSince: presence == Presence.gone ? DateTime.tryParse('${json['goneSince']}') : null,
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
error: memberErrorFromJson(json['error']),
rate: MediaRelay._byteRate(json['rate']),
app: _short(json['app'], 32),
caps: ClientCaps.fromJson(json['caps']),
);
}
}
/// Why the server is HELD (`state.hold`), most pressing first: a joiner
/// still loading within its bound ([joining], [until] when the bound
/// ends), someone not ready ([ready]), someone's buffer short ([buffer]),
/// the same while a relayed media's source is `gone` ([source]), or
/// nobody in the set to start ([start]: someone has to ready up).
enum HoldReason {
joining,
ready,
buffer,
source,
start;
static HoldReason? parse(Object? v) {
for (final r in values) {
if (r.name == v) return r;
}
return null;
}
}
/// `state.hold`: present while `transport` is `held` with media up.
/// [members]: the member ids the gate waits for (empty with
/// [HoldReason.start]). [until]: when that wait ends by itself (the
/// joiners' bound), on the server's clock.
class HoldInfo {
final HoldReason reason;
final List<String> members;
final DateTime? until;
const HoldInfo({required this.reason, this.members = const [], this.until});
Map<String, Object?> toJson() => {
'reason': reason.name,
'members': members,
if (until != null) 'until': until!.toUtc().toIso8601String(),
};
static HoldInfo? fromJson(Object? j) {
if (j is! Map) return null;
final reason = HoldReason.parse(j['reason']);
if (reason == null) return null;
final m = j['members'];
return HoldInfo(
reason: reason,
members: m is List ? m.whereType<String>().toList() : const [],
until: j['until'] == null ? null : DateTime.tryParse('${j['until']}'));
}
}
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
/// first item when the media finishes. At [at] + [secs] (server clock) the
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
/// it for everyone. The server parses and broadcasts it but never starts
/// one yet.
class QueueAdvance {
final String id;
final DateTime at;
final int secs;
const QueueAdvance({required this.id, required this.at, required this.secs});
Map<String, Object?> toJson() => {'id': id, 'at': at.toUtc().toIso8601String(), 'secs': secs};
static QueueAdvance? fromJson(Object? j) {
if (j is! Map || j['id'] is! String) return null;
final at = DateTime.tryParse('${j['at']}');
final secs = j['secs'];
if (at == null || secs is! num || !secs.isFinite || secs < 0) return null;
return QueueAdvance(id: j['id'] as String, at: at, secs: secs.toInt());
}
}
class StateBroadcast {
@@ -733,6 +1205,10 @@ class StateBroadcast {
/// The queued item someone asked to play now, waiting for the member
/// who can put it up; null when none.
final String? queuePlay;
/// Why it is held ([HoldInfo]); null unless `held` with media up.
final HoldInfo? hold;
/// The shared countdown to Up next (reserved, [QueueAdvance]).
final QueueAdvance? advance;
const StateBroadcast({
required this.eventId,
required this.transport,
@@ -747,6 +1223,8 @@ class StateBroadcast {
this.relayNext = false,
this.queue = const [],
this.queuePlay,
this.hold,
this.advance,
});
Map<String, Object?> toJson() => {
@@ -764,6 +1242,8 @@ class StateBroadcast {
if (relayNext) 'relayNext': true,
if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()],
if (queuePlay != null) 'queuePlay': queuePlay,
if (hold != null) 'hold': hold!.toJson(),
if (advance != null) 'advance': advance!.toJson(),
};
factory StateBroadcast.fromJson(Map<String, Object?> json) {
@@ -791,31 +1271,62 @@ class StateBroadcast {
? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList()
: const [],
queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null,
hold: HoldInfo.fromJson(json['hold']),
advance: QueueAdvance.fromJson(json['advance']),
);
}
}
/// One line of the activity feed. [actor] / [target] are display names
/// (as they were then); [actorId] / [targetId] the member ids (`server`
/// for the server's own lines, empty when none), so clients match people
/// by id, not by name. [seq] increases by one per line for the server's
/// life; [replayed] marks lines repeated in `welcome.feed` (log them, do
/// not toast them).
class FeedMessage {
final String actor;
final String action;
final String? target;
final String reason;
final DateTime at;
final String actorId;
final String targetId;
final int seq;
final bool replayed;
const FeedMessage({
required this.actor,
required this.action,
this.target,
this.reason = '',
required this.at,
this.actorId = '',
this.targetId = '',
this.seq = 0,
this.replayed = false,
});
FeedMessage copyWith({int? seq, bool? replayed}) => FeedMessage(
actor: actor,
action: action,
target: target,
reason: reason,
at: at,
actorId: actorId,
targetId: targetId,
seq: seq ?? this.seq,
replayed: replayed ?? this.replayed);
Map<String, Object?> toJson() => {
't': 'feed',
'seq': seq,
'actor': actor,
if (actorId.isNotEmpty) 'actorId': actorId,
'action': action,
if (target != null) 'target': target,
if (targetId.isNotEmpty) 'targetId': targetId,
'reason': reason,
'at': at.toIso8601String(),
if (replayed) 'replayed': true,
};
factory FeedMessage.fromJson(Map<String, Object?> json) {
@@ -826,6 +1337,10 @@ class FeedMessage {
target: json['target'] as String?,
reason: (json['reason'] as String?) ?? '',
at: DateTime.tryParse('${json['at']}') ?? DateTime.now().toUtc(),
actorId: json['actorId'] is String ? json['actorId'] as String : '',
targetId: json['targetId'] is String ? json['targetId'] as String : '',
seq: finiteInt(json['seq'], 'seq'),
replayed: json['replayed'] == true,
);
}
}
+100 -18
View File
@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'constants.dart';
import 'errors.dart';
import 'presence.dart';
import 'protocol.dart';
import 'sync_engine.dart';
@@ -36,7 +37,10 @@ enum QueueHow {
/// One entry of Up next as everyone sees it. [media] is what the swap
/// puts up, without a URL for [QueueHow.server], [QueueHow.through] and
/// [QueueHow.file] (the server, or the adder's app, keeps it). [key]: the
/// adder's own handle for what it keeps (adder-only items).
/// adder's own handle for what it keeps (adder-only items). [auto]: the
/// continuation of media [forMedia] (the next episode): at most one, always
/// after the others, replaced by a newer one, dropped by any swap that does
/// not put it up (wire `auto` and `for`).
class QueueItem {
final String id;
final HelloMedia media;
@@ -52,6 +56,9 @@ class QueueItem {
final String by;
final String byName;
final String key;
final bool auto;
/// The `mediaId` an [auto] item continues; 0 otherwise.
final int forMedia;
const QueueItem({
this.id = '',
required this.media,
@@ -63,10 +70,12 @@ class QueueItem {
this.by = '',
this.byName = '',
this.key = '',
this.auto = false,
this.forMedia = 0,
});
QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration,
String? thumb, String? by, String? byName, String? key}) =>
String? thumb, String? by, String? byName, String? key, bool? auto, int? forMedia}) =>
QueueItem(
id: id ?? this.id,
media: media ?? this.media,
@@ -78,6 +87,8 @@ class QueueItem {
by: by ?? this.by,
byName: byName ?? this.byName,
key: key ?? this.key,
auto: auto ?? this.auto,
forMedia: forMedia ?? this.forMedia,
);
Map<String, Object?> toJson() => {
@@ -91,6 +102,8 @@ class QueueItem {
if (by.isNotEmpty) 'by': by,
if (byName.isNotEmpty) 'byName': byName,
if (key.isNotEmpty) 'key': key,
if (auto) 'auto': true,
if (auto) 'for': forMedia,
};
/// Null when [j] is not an item this side can read.
@@ -111,6 +124,8 @@ class QueueItem {
by: j['by'] is String ? j['by'] as String : '',
byName: j['byName'] is String ? j['byName'] as String : '',
key: j['key'] is String ? j['key'] as String : '',
auto: j['auto'] == true,
forMedia: j['auto'] == true && j['for'] is num && (j['for'] as num).isFinite ? (j['for'] as num).toInt() : 0,
);
}
}
@@ -118,8 +133,9 @@ class QueueItem {
/// Client to server: one change to Up next. [op] `add` (with [item],
/// [next] = right after what plays instead of at the end, [thumb] base64
/// picture), `move` ([id] to index [to]), `remove` ([id]; [why] set when it
/// is skipped because it cannot play), `play` ([id] now) or `skip` (the
/// first item now).
/// is skipped because it cannot play), `play` ([id] now), `skip` (the
/// first item now) or `cancel` (drops the [QueueItem.auto] item and the
/// countdown, `state.advance`, for everyone).
class QueueIntent {
final String op;
final QueueItem? item;
@@ -137,8 +153,9 @@ class QueueIntent {
const QueueIntent.remove(String id, {String why = ''}) : this._('remove', id: id, why: why);
const QueueIntent.play(String id) : this._('play', id: id);
const QueueIntent.skip() : this._('skip');
const QueueIntent.cancel() : this._('cancel');
static const ops = {'add', 'move', 'remove', 'play', 'skip'};
static const ops = {'add', 'move', 'remove', 'play', 'skip', 'cancel'};
Map<String, Object?> toJson() => {
't': 'queue',
@@ -210,26 +227,54 @@ class EngineQueue {
return i < 0 ? null : _items[i].privateUrl;
}
/// Adds [item] (already cleaned) with a fresh id, at the end or, with
/// [next], first. Null when full ([RelayConstants.maxQueueItems]).
/// The continuation item ([QueueItem.auto]), always last; null when none.
QueueItem? get auto {
final i = _autoIndex;
return i < 0 ? null : _items[i].item;
}
int get _autoIndex => _items.indexWhere((e) => e.item.auto);
/// Adds [item] (already cleaned) with a fresh id, at the end (before the
/// [auto] item) or, with [next], first. An [QueueItem.auto] item goes
/// last and replaces the earlier one. Null when full
/// ([RelayConstants.maxQueueItems]).
QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) {
if (item.auto) dropAuto();
if (_items.length >= RelayConstants.maxQueueItems) return null;
final t = thumb == null ? null : ArtImage(thumb);
final added = item.copyWith(id: 'q${_nextId++}', thumb: t?.hash ?? '');
final e = _Entry(added, privateUrl, t == null ? null : (hash: t.hash, bytes: t.bytes));
if (next) {
if (item.auto) {
_items.add(e);
} else if (next) {
_items.insert(0, e);
} else {
_items.add(e);
final a = _autoIndex;
_items.insert(a < 0 ? _items.length : a, e);
}
return added;
}
/// Drops the [auto] item; returns it (null when there was none).
QueueItem? dropAuto() {
final i = _autoIndex;
if (i < 0) return null;
final a = _items.removeAt(i).item;
if (play == a.id) _clearPlay();
return a;
}
/// Moves [id] to [to] (clamped). False when unknown or unchanged.
bool move(String id, int to) {
final i = _index(id);
if (i < 0) return false;
final target = to.clamp(0, _items.length - 1);
// The auto item stays after the others: it does not move, nothing
// moves past it.
final a = _autoIndex;
if (i == a) return false;
final last = _items.length - (a < 0 ? 1 : 2);
final target = to.clamp(0, last);
if (target == i) return false;
final e = _items.removeAt(i);
_items.insert(target, e);
@@ -257,8 +302,10 @@ class EngineQueue {
}
/// A swap happened: [id] (when it came from Up next) leaves the list,
/// and any pending play request is over.
/// the [auto] item goes too unless it is [id], and any pending play
/// request is over.
void swapped(String? id) {
if (auto?.id != id) dropAuto();
if (id != null) {
final i = _index(id);
if (i >= 0) _items.removeAt(i);
@@ -290,20 +337,40 @@ class EngineQueue {
}
}
/// Why a `queue` message was refused: the `err` the sender gets.
typedef QueueRefusal = ({ErrCode code, String message});
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each
/// returns whether the queue changed; [applyQueueAdd] an error text for the
/// sender instead (null when added).
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
/// `advance-cancel`). Each returns whether the queue changed;
/// [applyQueueAdd] a refusal for the sender instead (null when added).
extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`).
String? applyQueueAdd(String token, QueueItem item,
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
/// [QueueItem.auto] item must continue the current media
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
/// first member who is.
QueueRefusal? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token];
if (member == null) return null;
if (item.auto) {
if (media == null || item.forMedia != mediaId) {
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
}
final swapper = members[swapBy];
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
final allowed = token == swapBy ||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
if (!allowed) {
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
}
}
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)';
feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now));
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
return null;
}
@@ -319,6 +386,7 @@ extension EngineQueueOps on SyncEngine {
if (gone == null) return false;
feed.add(FeedMessage(
actor: member.name,
actorId: member.id,
action: why.isEmpty ? 'queue-remove' : 'queue-skip',
target: gone.title,
reason: why,
@@ -333,4 +401,18 @@ extension EngineQueueOps on SyncEngine {
if (target == null || queue.play == target) return false;
return queue.requestPlay(target, now);
}
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
bool applyQueueCancel(String token) {
final member = members[token];
if (member == null) return false;
final dropped = queue.dropAuto();
final hadAdvance = advance != null;
advance = null;
if (dropped == null && !hadAdvance) return false;
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
return true;
}
}
+21 -6
View File
@@ -335,16 +335,26 @@ class _Pull {
/// Close code the server uses for "you are not (or no longer) the source of
/// the current media": the source stops instead of reconnecting.
const relayCloseNotSource = 4001;
const relayCloseNotSource = CloseCodes.relayNotSource;
/// Close codes after which a source stops for good: not the source
/// (4001), another media than the relay's (4002), replaced by a newer
/// channel (4003).
const relayStopCodes = {CloseCodes.relayNotSource, CloseCodes.relayOtherMedia, CloseCodes.relayReplaced};
/// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// until [close]. Reconnects with backoff when the channel drops; stops for
/// good when the server closes it with [relayCloseNotSource].
/// until [close]. Reconnects with backoff when the channel drops (the
/// server keeps the relay through the source's grace and pulls what it
/// still lacks); stops for good on [relayStopCodes]. [name] and
/// [duration] (seconds, 0 unknown) go in its `info`, so a re-dial is
/// recognised as the same media.
class RelaySource {
final Uri url;
final RelayMedia media;
final String name;
final double duration;
final Duration Function(int attempt) backoff;
RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
RelaySource(this.url, this.media, {this.name = '', this.duration = 0, Duration Function(int attempt)? backoff})
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
final window = RelayWindow();
@@ -384,6 +394,8 @@ class RelaySource {
unawaited(_loop({
't': 'info',
if (relayTrackSpecs(tracks).isEmpty) 'size': infos.single['size'] else 'tracks': infos,
if (name.isNotEmpty) 'name': name,
if (duration > 0 && duration.isFinite) 'duration': duration,
}));
}
@@ -398,14 +410,17 @@ class RelaySource {
break;
}
_ws = ws;
// A half-open channel (a dead link without a FIN) is noticed by
// its missing pong, closed, and dialled again.
ws.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs);
_log.info('relay channel up: $where${attempt > 0 ? ' (after $attempt retries)' : ''}');
attempt = 0;
ws.add(jsonEncode(info));
await for (final m in ws) {
if (m is String) _onText(ws, m);
}
if (ws.closeCode == relayCloseNotSource) {
_log.info('relay channel: not the source any more, stopping ($where, sent $bytesSent bytes)');
if (relayStopCodes.contains(ws.closeCode)) {
_log.info('relay channel: ${ws.closeCode == CloseCodes.relayNotSource ? 'not the source any more' : ws.closeCode == CloseCodes.relayOtherMedia ? 'another media than the relay\'s' : 'replaced by a newer channel'}, stopping ($where, sent $bytesSent bytes)');
_closed = true;
} else if (!_closed) {
_log.warning('relay channel down: $where, code ${ws.closeCode} ${ws.closeReason ?? ''}; reconnecting');
+366 -148
View File
@@ -4,26 +4,38 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'presence.dart';
import 'protocol.dart';
import 'queue.dart';
typedef SyncClock = DateTime Function();
/// How long the server bears a silent member. [staleAfter] and [dropAfter]
/// are measured on the engine's clock ([SyncEngine.clock]) by its 1 s
/// sweep, so a test with a fake clock steps through them without waiting;
/// How the server treats silence, drops and joiners (PROTOCOL.md
/// "Presence"). Everything but [pingInterval] is measured on the engine's
/// clock ([SyncEngine.clock]) by its 1 s sweep ([SyncEngine.tick]), so a
/// test with a fake clock steps through them without waiting;
/// [pingInterval] is the WebSocket's own timer (real time).
class Liveness {
/// No frame for longer than this: stale (out of the set, feed `stale`).
final Duration staleAfter;
/// No frame for longer than this: the member is removed (feed `leave`)
/// and its socket closed.
final Duration dropAfter;
/// No frame for longer than this: the socket is closed and the member
/// is `gone`.
final Duration silence;
/// A `gone` member is kept this long (`welcome.grace`), then it leaves.
final Duration grace;
/// The feed says `gone` only once a member has been gone this long (a
/// quick return says nothing).
final Duration goneNotice;
/// A `joining` member holds a HELD gate at most this long (D2).
final Duration joinerHold;
/// A `joining` member is `in` after this at the latest.
final Duration joiningTimeout;
/// WebSocket ping on every `/sync` socket.
final Duration pingInterval;
const Liveness({
this.staleAfter = const Duration(seconds: RelayConstants.staleAfterSecs),
this.dropAfter = const Duration(seconds: RelayConstants.dropAfterSecs),
this.silence = const Duration(seconds: RelayConstants.silenceSecs),
this.grace = const Duration(seconds: RelayConstants.graceSecs),
this.goneNotice = const Duration(seconds: RelayConstants.goneNoticeSecs),
this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs),
this.joiningTimeout = const Duration(seconds: RelayConstants.joiningTimeoutSecs),
this.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs),
});
}
@@ -63,24 +75,41 @@ String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substri
class EngineMember {
final String id;
final String token;
/// `hello.client` (empty: none, the member cannot be taken over).
/// Private: never broadcast, never logged.
final String client;
/// The `hello.seq` of the connection that holds it now.
int seq;
String name;
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
Uint8List? avatarBytes;
String avatarHash = '';
bool ready = false;
/// In the ready gate's set (joined it while not playing, readied, or
/// was there at a swap); whether it counts also depends on [presence]
/// and [error] ([SyncEngine.countsForGate]).
bool inSet = false;
double buffered = 0;
/// Last reported Jellyfin access by ServerId (`report.jf`).
Map<String, bool> jf = const {};
/// The hosting app's own member (set by the server from the host key).
bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false;
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
/// Bytes per second the server sent it lately (set by the server).
int rate = 0;
/// `hello.app`, `hello.platform`, `hello.caps` of its last hello.
String app;
String platform;
ClientCaps caps;
Presence presence;
/// When [presence] began (joining since, gone since, ...).
DateTime presenceSince;
/// Gone: whether the feed has said so yet (after [Liveness.goneNotice]).
bool goneNoticed = false;
/// Gone: it was `away` before, and comes back `away`.
bool awayBeforeGone = false;
DateTime lastSeen;
EngineMember({
required this.id,
@@ -88,7 +117,14 @@ class EngineMember {
required this.name,
String avatar = '',
required DateTime now,
}) : lastSeen = now {
this.client = '',
this.seq = 0,
this.app = '',
this.platform = '',
this.caps = const ClientCaps(),
this.presence = Presence.watching,
}) : lastSeen = now,
presenceSince = now {
setAvatar(avatar);
}
@@ -111,19 +147,24 @@ class EngineMember {
return true;
}
MemberView view({required bool stale}) => MemberView(
/// When it went `gone`; null otherwise.
DateTime? get goneSince => presence == Presence.gone ? presenceSince : null;
MemberView view({required bool inGate}) => MemberView(
id: id,
name: name,
avatarHash: avatarHash,
ready: ready,
inSet: inSet,
inSet: inGate,
buffered: buffered,
stale: stale,
presence: presence,
goneSince: goneSince,
jf: jf,
host: host,
out: out,
error: error,
rate: rate,
app: app,
caps: caps,
);
}
@@ -148,22 +189,45 @@ class ArtImage {
ArtImage(this.bytes) : hash = avatarHashOf(bytes);
}
/// The feed lines the engine made: [pending] until the server sends them
/// ([drain]); each gets the next [FeedMessage.seq]. The last
/// [RelayConstants.feedReplayLines] stay for a reconnect's replay ([after]).
class EngineFeed {
final List<FeedMessage> pending = [];
void add(FeedMessage message) => pending.add(message);
final List<FeedMessage> _recent = [];
int _seq = 0;
/// The `seq` of the last line (0 before any).
int get lastSeq => _seq;
void add(FeedMessage message) {
final line = message.copyWith(seq: ++_seq);
pending.add(line);
_recent.add(line);
if (_recent.length > RelayConstants.feedReplayLines) _recent.removeAt(0);
}
List<FeedMessage> drain() {
final out = List<FeedMessage>.from(pending);
pending.clear();
return out;
}
/// Kept lines after [seq] (`hello.feedAfter`), oldest first, marked
/// `replayed`. Lines still [pending] are left out (they go out to
/// everyone anyway).
List<FeedMessage> after(int seq) => [
for (final f in _recent)
if (f.seq > seq && !pending.contains(f)) f.copyWith(replayed: true),
];
}
class SyncEngine {
final SyncClock clock;
final Liveness liveness;
Duration get staleAfter => liveness.staleAfter;
Duration get dropAfter => liveness.dropAfter;
final PresencePolicy policy;
final EngineFeed feed = EngineFeed();
/// Members by token, in join order.
final Map<String, EngineMember> members = {};
int eventId = 0;
@@ -181,6 +245,15 @@ class SyncEngine {
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue();
/// The shared countdown to Up next (`state.advance`); reserved: only
/// `cancel` touches it today.
QueueAdvance? advance;
/// A member went `gone` (its socket closed or fell silent): the server
/// closes the socket and marks a relay it serves.
void Function(EngineMember member)? onGone;
/// A member left for good (`bye`, grace over): the server forgets its
/// token and ends a relay it served.
void Function(EngineMember member)? onLeft;
int _nextId = 1;
// Who sent the seek that produced [_seekEventId], for the echo check.
String? _seekBy;
@@ -189,6 +262,7 @@ class SyncEngine {
SyncEngine({SyncClock? clock, this.liveness = const Liveness()})
: clock = clock ?? (() => DateTime.now().toUtc()),
policy = PresencePolicy(joinerHold: liveness.joinerHold),
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
DateTime get now => clock();
@@ -199,8 +273,13 @@ class SyncEngine {
return position + t.difference(serverTime).inMilliseconds / 1000.0;
}
bool isStale(EngineMember member, [DateTime? at]) =>
(at ?? now).difference(member.lastSeen) > staleAfter;
/// Whether [member] counts for the ready gate now: in the set, no
/// player error, and its presence counts ([PresencePolicy], D2).
bool countsForGate(EngineMember member, [DateTime? at]) =>
member.inSet &&
member.error.isEmpty &&
policy.countsFor(member.presence, Purpose.gate,
playing: transport == Transport.playing, joiningFor: (at ?? now).difference(member.presenceSince));
/// Media length in seconds, null while nobody has reported it.
double? get duration {
@@ -218,39 +297,160 @@ class SyncEngine {
return left.clamp(0.0, full).toDouble();
}
/// Any frame from a member: fresh again. Back in the set: a ready member
/// the stale sweep (or a swap while it was stale) took out (ready &&
/// !inSet only arises from those), and any member back from stale while
/// not playing (like a join then).
void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now;
if (!member.out &&
member.error.isEmpty &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
}
/// Any frame from a member.
void _seen(EngineMember member) => member.lastSeen = now;
/// Any frame from [token]'s socket (a ping, a chat): it is not silent.
void touch(String token) {
final m = members[token];
if (m != null) _seen(m);
}
/// Heartbeat: stale sweep, end of media.
FeedMessage _line(String action, {EngineMember? actor, EngineMember? target, String reason = ''}) => FeedMessage(
actor: actor?.name ?? 'server',
actorId: actor?.id ?? 'server',
action: action,
target: target?.name,
targetId: target?.id ?? '',
reason: reason,
at: now);
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
/// a stale play request, end of media.
void tick() {
sweepStale();
sweepSilent();
sweepPresence();
queue.tick(now);
evaluate();
}
String applyJoin(String token, String name, String avatar) {
/// Joining: loads in the background until its reports hold the media
/// (D2); with nothing up there is nothing to load.
Presence get _arriving => media == null ? Presence.watching : Presence.joining;
/// A new member ([client] empty: one that cannot be taken over).
String applyJoin(String token, String name, String avatar,
{String client = '', int seq = 0, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
final id = 'm${_nextId++}';
// Joining while not playing: in the set, so the start waits for them;
// while playing, out until they ready (a late joiner never pauses).
members[token] =
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
..inSet = transport != Transport.playing;
feed.add(FeedMessage(actor: name, action: 'join', at: now));
// Joining while not playing: in the set, so the start waits for them
// (at most [Liveness.joinerHold]); while playing, out until they ready
// (a late joiner never pauses).
final m = members[token] = EngineMember(
id: id,
token: token,
name: name,
avatar: avatar,
now: now,
client: client,
seq: seq,
app: app,
platform: platform,
caps: caps,
presence: _arriving)
..inSet = transport != Transport.playing;
feed.add(_line('join', actor: m));
evaluate();
return id;
}
/// The member with `hello.client` [client]; null for none (or empty).
EngineMember? byClient(String client) {
if (client.isEmpty) return null;
for (final m in members.values) {
if (m.client == client) return m;
}
return null;
}
/// A newer hello of the same client took [token]'s member over (see
/// `hello.client`): back from `gone` it is `joining` again (`away` if it
/// was), ready flag kept, feed `return` when the feed had said `gone`.
/// Returns whether it came back from `gone`.
bool applyTakeover(String token,
{required int seq, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
final m = members[token];
if (m == null) return false;
m
..seq = seq
..app = app
..platform = platform
..caps = caps;
_seen(m);
if (m.presence != Presence.gone) return false;
final noticed = m.goneNoticed;
m
..goneNoticed = false
..presence = m.awayBeforeGone ? Presence.away : _arriving
..presenceSince = now
..awayBeforeGone = false
..inSet = m.error.isEmpty && (m.ready || transport != Transport.playing);
if (noticed) feed.add(_line('return', actor: m));
evaluate();
return true;
}
/// [token]'s socket closed or fell silent: `gone`, kept for the grace
/// with everything it had. No feed line yet ([Liveness.goneNotice]).
void applyGone(String token) {
final m = members[token];
if (m == null || m.presence == Presence.gone) return;
m
..awayBeforeGone = m.presence == Presence.away
..presence = Presence.gone
..presenceSince = now
..goneNoticed = false;
evaluate();
onGone?.call(m);
}
/// Members with no frame for [Liveness.silence] go `gone`. Returns
/// their tokens.
List<String> sweepSilent() {
final t = now;
final silent = [
for (final m in members.values)
if (m.presence != Presence.gone && t.difference(m.lastSeen) > liveness.silence) m.token,
];
for (final token in silent) {
applyGone(token);
}
return silent;
}
/// Joining timeouts, the delayed `gone` line, grace expiry (leave).
void sweepPresence() {
final t = now;
for (final m in members.values.toList()) {
final since = t.difference(m.presenceSince);
switch (m.presence) {
case Presence.joining:
if (since >= liveness.joiningTimeout) _becomeIn(m);
case Presence.gone:
if (since >= liveness.grace) {
applyLeave(m.token, reason: 'grace');
} else if (!m.goneNoticed && since >= liveness.goneNotice) {
m.goneNoticed = true;
feed.add(_line('gone', actor: m));
}
case Presence.watching:
case Presence.away:
break;
}
}
}
/// Joining is over: `in`. In the set if it is ready, or (not playing)
/// if it was in it; while playing an unready member waits for its own
/// `ready`, so coming in never pauses anyone.
void _becomeIn(EngineMember m) {
if (m.presence != Presence.joining) return;
m
..presence = Presence.watching
..presenceSince = now
..inSet = m.error.isEmpty && (m.ready || (transport != Transport.playing && m.inSet));
evaluate();
}
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
/// empty [avatar] removes the picture. [name] is already cleaned.
void applyProfile(String token, {String? name, String? avatar}) {
@@ -262,9 +462,10 @@ class SyncEngine {
if (renamed) member.name = name;
final pictured = avatar != null && member.setAvatar(avatar);
if (renamed) {
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
feed.add(FeedMessage(
actor: name, actorId: member.id, action: 'rename', target: oldName, targetId: member.id, at: now));
} else if (pictured) {
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
feed.add(_line('avatar', actor: member));
}
}
@@ -309,19 +510,14 @@ class SyncEngine {
if (banner != null) 'banner': banner!.hash,
};
/// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) {
final member = members[token];
if (member == null) return;
_seen(member);
evaluate();
}
String? applyLeave(String token) {
/// [token]'s member leaves for good: `bye` ([reason] `bye`), or its
/// grace ran out (`grace`). Feed `leave`. Returns its id.
String? applyLeave(String token, {String reason = ''}) {
final removed = members.remove(token);
if (removed == null) return null;
feed.add(FeedMessage(actor: removed.name, action: 'leave', at: now));
feed.add(_line('leave', actor: removed, reason: reason));
evaluate();
onLeft?.call(removed);
return removed.id;
}
@@ -332,74 +528,80 @@ class SyncEngine {
return null;
}
/// `presence` from [actorToken] on [targetId] (empty: itself). `away`:
/// anyone may set anyone (the host too); `in`: only itself, through
/// `joining` while media is up (D2). Returns [ErrCode.forbidden] when
/// someone else is set `in`, else null (no-ops and unknown targets
/// included).
ErrCode? applyPresence(String actorToken, String targetId, Presence state,
{PresenceReason reason = PresenceReason.manual}) {
final actor = members[actorToken];
if (actor == null) return null;
_seen(actor);
final target = targetId.isEmpty ? actor : byId(targetId);
if (target == null) return null;
final self = identical(actor, target);
final why = reason == PresenceReason.manual ? '' : reason.name;
if (state == Presence.away) {
if (target.presence == Presence.gone) {
if (target.awayBeforeGone) return null;
target.awayBeforeGone = true;
} else {
if (target.presence == Presence.away) return null;
target
..presence = Presence.away
..presenceSince = now;
}
feed.add(_line('away', actor: actor, target: self ? null : target, reason: why));
evaluate();
return null;
}
if (state != Presence.watching) return null;
if (!self) return ErrCode.forbidden;
if (target.presence != Presence.away) return null;
target
..presence = _arriving
..presenceSince = now
..inSet = target.error.isEmpty && (target.ready || transport != Transport.playing);
feed.add(_line('in', actor: actor, reason: why));
evaluate();
return null;
}
void applyReady(String token, bool ready, {String source = 'manual'}) {
final member = members[token];
if (member == null) return;
_seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate();
return;
}
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: member.name,
action: ready ? 'ready' : 'unready',
reason: source,
at: now));
feed.add(_line(ready ? 'ready' : 'unready', actor: member, reason: source));
evaluate();
}
/// Ready (or unready) [target] (a member id) or `all`. Members `away`
/// are left alone.
void applyForce(String actorToken, String target, bool ready) {
final actor = members[actorToken];
final actorName = actor?.name ?? '?';
if (target == 'all') {
for (final member in members.values) {
if (member.out) continue;
if (member.presence == Presence.away) continue;
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
}
} else {
final member = byId(target);
if (member == null || member.out) return;
if (member == null || member.presence == Presence.away) return;
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-ready' : 'force-unready',
target: member.name,
at: now));
feed.add(_line(ready ? 'force-ready' : 'force-unready', actor: actor, target: member));
evaluate();
return;
}
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-play' : 'force-hold',
at: now));
evaluate();
}
/// Sits the member [targetId] out ([out] true; anyone may, the host
/// included), or brings [actorToken]'s own member back ([out] false;
/// only oneself). Out: not ready, not in the set, so it never blocks.
/// Back: in the set like a fresh join (unless playing), still unready:
/// its own `ready` follows once it has buffered.
void applyOut(String actorToken, String targetId, bool out) {
final actor = members[actorToken];
final member = byId(targetId);
if (actor == null || member == null) return;
_seen(actor);
final self = identical(actor, member);
if (!out && !self) return;
if (member.out == out) return;
member.out = out;
member.ready = false;
member.buffered = 0;
member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
feed.add(_line(ready ? 'force-play' : 'force-hold', actor: actor));
evaluate();
}
@@ -414,7 +616,7 @@ class SyncEngine {
_seekBy = member.id;
_seekSeq = seq;
_seekEventId = eventId;
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
feed.add(_line('seek', actor: member));
// Seeking back from the end: held, and resumes once the gate passes.
if (transport == Transport.finished) {
transport = Transport.held;
@@ -423,11 +625,13 @@ class SyncEngine {
}
/// Returns false (no state change) for media with non-finite numbers.
/// [queueId]: the Up next item this swap puts up; it leaves the queue.
/// [queueId]: the Up next item this swap puts up; it leaves the queue
/// (any other swap drops the continuation item, [EngineQueue.auto]).
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken];
queue.swapped(queueId);
advance = null;
mediaId++;
media = next;
swapBy = actorToken;
@@ -438,14 +642,13 @@ class SyncEngine {
eventId++;
transport = Transport.held;
// Ready flags stay; everyone here now waits for everyone else here now
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
// (who counts is up to presence: `away` and `gone` never do).
for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t);
member.inSet = true;
member.buffered = 0;
}
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
feed.add(_line('swap', actor: actor));
return true;
}
@@ -460,59 +663,46 @@ class SyncEngine {
// A report for an earlier media (sent before the swap reached the
// client) says nothing about this one's buffer.
final earlier = mediaId != 0 && mediaId != this.mediaId;
member.buffered =
member.out || earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
member.buffered = earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
final m = media;
// First report of the length wins; only for the media the client has open.
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
duration.isFinite && duration > 0) {
media = m.copyWith(duration: duration);
}
if (member.presence == Presence.joining) {
// Joining ends when it cannot open the media (it stays out of the
// gate by its error), or once it holds the media at the server's
// position with the buffer the gate needs: it comes in in sync.
final t = now;
if (member.error.isNotEmpty) {
_becomeIn(member);
} else if (!earlier &&
reportEventId >= eventId &&
(reportPosition - positionAt(t)).abs() <= RelayConstants.driftSeekThresholdSecs &&
member.buffered >= requiredBufferSecs(t)) {
_becomeIn(member);
}
}
if (reportEventId < eventId) return;
evaluate();
}
/// An error takes the member out of the set (the gate stops waiting for
/// it); clearing it puts it back like a join: in the set unless playing
/// (then from its next `ready`).
/// it); clearing it puts it back like a join: in the set unless playing,
/// then only if it is ready (the others hold for its buffer).
void _setError(EngineMember member, String error) {
if (member.error == error) return;
member.error = error;
if (error.isNotEmpty) {
member.inSet = false;
} else if (!member.out) {
member.inSet = transport != Transport.playing;
} else {
member.inSet = member.ready || transport != Transport.playing;
}
// Leaving the set may let the others start.
evaluate();
}
void sweepStale() {
var changed = false;
for (final member in members.values) {
if (isStale(member) && member.inSet) {
member.inSet = false;
changed = true;
feed.add(FeedMessage(actor: member.name, action: 'stale', reason: 'no signal', at: now));
}
}
if (changed) evaluate();
}
List<String> sweepDropped() {
final t = now;
final dropped = <String>[];
for (final member in members.values.toList()) {
if (t.difference(member.lastSeen) > dropAfter) {
members.remove(member.token);
dropped.add(member.token);
feed.add(FeedMessage(actor: member.name, action: 'leave', reason: 'silent too long', at: t));
}
}
if (dropped.isNotEmpty) evaluate();
return dropped;
}
void evaluate() {
final t = now;
if (transport == Transport.finished) return;
@@ -524,39 +714,65 @@ class SyncEngine {
serverTime = t;
eventId++;
transport = Transport.finished;
feed.add(FeedMessage(actor: 'server', action: 'finished', at: t));
feed.add(_line('finished'));
return;
}
final blocked = members.values
.where((m) => m.inSet)
.any((m) => !m.ready || m.buffered < need || isStale(m, t));
final blocked =
members.values.where((m) => countsForGate(m, t)).any((m) => !m.ready || m.buffered < need);
if (blocked) {
position = positionAt(t);
serverTime = t;
eventId++;
transport = Transport.held;
feed.add(FeedMessage(actor: 'server', action: 'pause', at: t));
feed.add(_line('pause'));
}
return;
}
final inSet = members.values.where((m) => m.inSet).toList();
final inSet = members.values.where((m) => countsForGate(m, t)).toList();
if (inSet.isEmpty) return;
final allGood = inSet.every(
(m) => m.ready && m.buffered >= need && !isStale(m, t));
if (allGood) {
if (inSet.every((m) => m.ready && m.buffered >= need)) {
serverTime = t;
eventId++;
transport = Transport.playing;
feed.add(FeedMessage(actor: 'server', action: 'resume', at: t));
feed.add(_line('resume'));
}
}
/// `state.hold` while HELD with media up: who the gate waits for and why
/// ([HoldReason]). [sourceGone]: the current media's relay source is
/// `gone` (a short buffer then reads [HoldReason.source]).
HoldInfo? holdInfo({bool sourceGone = false}) {
if (transport != Transport.held || media == null) return null;
final t = now;
final need = requiredBufferSecs(t);
final gate = members.values.where((m) => countsForGate(m, t)).toList();
if (gate.isEmpty) return const HoldInfo(reason: HoldReason.start);
final joining = gate.where((m) => m.presence == Presence.joining && (!m.ready || m.buffered < need)).toList();
if (joining.isNotEmpty) {
final until = joining.map((m) => m.presenceSince.add(liveness.joinerHold)).reduce((a, b) => a.isBefore(b) ? a : b);
return HoldInfo(reason: HoldReason.joining, members: [for (final m in joining) m.id], until: until);
}
final unready = gate.where((m) => !m.ready).toList();
if (unready.isNotEmpty) return HoldInfo(reason: HoldReason.ready, members: [for (final m in unready) m.id]);
final short = gate.where((m) => m.buffered < need).toList();
if (short.isNotEmpty) {
return HoldInfo(
reason: sourceGone ? HoldReason.source : HoldReason.buffer, members: [for (final m in short) m.id]);
}
return null;
}
/// [relay]: whether the server can relay the current Jellyfin media; the
/// engine does not know the server's credential.
/// [warm]: the server's warm relay for Up next's first item, shown as
/// that item's `media.relay`.
StateBroadcast broadcast({bool relay = false, bool relayNext = false, ({String id, MediaRelay relay})? warm}) {
/// that item's `media.relay`. [sourceGone]: see [holdInfo].
StateBroadcast broadcast(
{bool relay = false,
bool relayNext = false,
({String id, MediaRelay relay})? warm,
bool sourceGone = false}) {
final bySeek = _seekEventId == eventId;
final t = now;
return StateBroadcast(
eventId: eventId,
transport: transport.name,
@@ -570,7 +786,7 @@ class SyncEngine {
if (members[swapBy]?.id case final id?) 'swapBy': id,
if (artJson != null) 'art': artJson,
},
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
members: [for (final m in members.values) m.view(inGate: countsForGate(m, t))],
seekBy: bySeek ? _seekBy : null,
seekSeq: bySeek ? _seekSeq : null,
relay: relay,
@@ -580,6 +796,8 @@ class SyncEngine {
warm != null && q.id == warm.id ? q.copyWith(media: q.media.copyWith(relay: warm.relay)) : q,
],
queuePlay: queue.play,
hold: holdInfo(sourceGone: sourceGone),
advance: advance,
);
}
}
+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(
+20 -10
View File
@@ -30,25 +30,28 @@ bool _httpUrl(String? url) {
if (clean == null) throw const ProtocolException('bad queue item');
if (name.isEmpty) name = clean.title;
item = QueueItem(
media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean), how: QueueHow.jellyfin, title: '');
media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean, ref: media.ref),
how: QueueHow.jellyfin,
title: '');
} else {
switch (raw.how) {
case QueueHow.direct:
if (!_httpUrl(media.url)) throw const ProtocolException('a queued link needs an http(s) url');
item = QueueItem(media: HelloMedia(name: name, size: 0, duration: 0, url: media.url), how: raw.how, title: '');
item = QueueItem(
media: HelloMedia(name: name, size: 0, duration: 0, url: media.url, ref: media.ref), how: raw.how, title: '');
case QueueHow.server:
if (!_httpUrl(media.url)) throw const ProtocolException('relay fetch needs an http(s) url');
if (!serverHasPassword) throw const ProtocolException('server fetch needs a server password');
privateUrl = media.url;
item = QueueItem(
media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true)),
media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true), ref: media.ref),
how: raw.how,
title: '');
case QueueHow.through:
case QueueHow.file:
if (!_key.hasMatch(raw.key)) throw const ProtocolException('a queued item of yours needs a key');
item = QueueItem(
media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0),
media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0, ref: media.ref),
how: raw.how,
title: '',
key: raw.key);
@@ -60,7 +63,11 @@ bool _httpUrl(String? url) {
if (title.isEmpty) title = cleanQueueText(item.media.name.isEmpty ? 'media' : item.media.name);
return (
item: item.copyWith(
title: title, sub: cleanQueueText(sanitizeMediaName(raw.sub)), duration: raw.duration),
title: title,
sub: cleanQueueText(sanitizeMediaName(raw.sub)),
duration: raw.duration,
auto: raw.auto,
forMedia: raw.auto ? raw.forMedia : 0),
privateUrl: privateUrl,
);
}
@@ -75,14 +82,15 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
case 'add':
final cleaned = cleanQueueAdd(intent.item!, serverHasPassword: hub.session.secret.isNotEmpty);
final thumb = intent.thumb.isEmpty ? null : decodePicture(intent.thumb, RelayConstants.maxPosterBytes);
final error = engine.applyQueueAdd(token, cleaned.item,
final refused = engine.applyQueueAdd(token, cleaned.item,
next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb);
if (error != null) {
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}) refused: $error');
wsErr(ws, error);
if (refused != null) {
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}) refused: ${refused.message}');
wsErr(ws, refused.code, refused.message, forType: 'queue');
return;
}
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}${intent.next ? ', next' : ''})');
_log.info('${hub.who(token)}: add "${cleaned.item.title}" (${cleaned.item.how.name}'
'${cleaned.item.auto ? ', continues media ${cleaned.item.forMedia}' : intent.next ? ', next' : ''})');
changed = true;
case 'move':
changed = engine.applyQueueMove(token, intent.id, intent.to);
@@ -92,6 +100,8 @@ void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<Strin
changed = engine.applyQueuePlay(token, intent.id);
case 'skip':
changed = engine.applyQueuePlay(token, '');
case 'cancel':
changed = engine.applyQueueCancel(token);
}
if (intent.op != 'add') {
_log.info('${hub.who(token)}: ${intent.op}${intent.id.isEmpty ? '' : ' ${intent.id}'}'
+52 -4
View File
@@ -75,6 +75,10 @@ class ActiveRelay {
RelicWebSocket? _channel;
var _channelFeeds = const <ChannelRelayFeed>[];
List<_TrackInfo>? _infos;
/// The source's first `info` on this relay: a later channel (the source
/// back within its grace) must send the same one, else the relay ends
/// (4002).
_SourceInfo? _firstInfo;
/// Shared by the tracks: time any of them waits on the source.
final clock = FeedClock();
final _rate = SourceRate();
@@ -295,7 +299,9 @@ class ActiveRelay {
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
// A second channel for the same relay replaces the first (the
// source's app reconnected before the old link died).
if (old != null && !identical(old, ws)) unawaited(_closeWith(old, CloseCodes.relayReplaced, 'replaced'));
_channel = ws;
final named = tracks.isNotEmpty;
final feeds = _channelFeeds = [
@@ -317,6 +323,33 @@ class ActiveRelay {
}
}
/// A source's `info` as a whole: its tracks, and the optional `name` and
/// `duration` it gives. Two are the same media when the tracks agree
/// (kinds, byte sizes, piece shapes) and the name and duration agree
/// where both give one.
typedef _SourceInfo = ({List<_TrackInfo> tracks, String name, double duration});
bool _sameInfo(_SourceInfo a, _SourceInfo b) {
if (a.tracks.length != b.tracks.length) return false;
for (var i = 0; i < a.tracks.length; i++) {
final x = a.tracks[i], y = b.tracks[i];
if (x.kind != y.kind || x.size != y.size || (x.pieces == null) != (y.pieces == null)) return false;
if (x.pieces != null && !x.pieces!.sameShape(y.pieces!)) return false;
}
if (a.name.isNotEmpty && b.name.isNotEmpty && a.name != b.name) return false;
if (a.duration > 0 && b.duration > 0 && (a.duration - b.duration).abs() > 0.5) return false;
return true;
}
_SourceInfo _sourceInfo(Map j, List<_TrackInfo> tracks) {
final d = j['duration'];
return (
tracks: tracks,
name: j['name'] is String ? j['name'] as String : '',
duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0,
);
}
/// The tracks of a channel's `info` frame (`size`, or `tracks[]` each
/// with a `size` or, `"as":"pieces"`, a piece list), or null when
/// malformed.
@@ -342,9 +375,11 @@ List<_TrackInfo>? _infoTracks(Map j) {
return out;
}
Future<void> _closeNotSource(RelicWebSocket ws) async {
Future<void> _closeNotSource(RelicWebSocket ws) => _closeWith(ws, CloseCodes.relayNotSource, 'not the source');
Future<void> _closeWith(RelicWebSocket ws, int code, String reason) async {
try {
await ws.close(relayCloseNotSource, 'not the source');
await ws.close(code, reason);
} catch (_) {
await ws.tryClose();
}
@@ -357,12 +392,15 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
/// with tracks (a pieces track's media playlist), and
/// `.../<key>/<track>/p/<i>` piece `i` of a pieces track. `<key>` is the
/// media id, or the queue id of a warm (or promoted) relay. [relayAt]
/// finds the relay of a key, [sourceOf] the relay a token may feed.
/// finds the relay of a key, [sourceOf] the relay a token may feed;
/// [onOtherMedia] hears of a source back with another media than its
/// first `info` (the channel is closed with 4002; the relay should end).
void mountRelayRoutes(
RelicApp app,
SessionMap sessions, {
required ActiveRelay? Function(String key) relayAt,
required ActiveRelay? Function(String token, String? queueId) sourceOf,
void Function(ActiveRelay relay)? onOtherMedia,
void Function(String token, MediaLog log)? onLog,
}) {
app.get('/r/:sid/:token/source', (final req) {
@@ -397,12 +435,22 @@ void mountRelayRoutes(
case 'info':
if (attached) continue;
final infos = _infoTracks(j);
final first = r._firstInfo;
if (infos != null && first != null && !_sameInfo(first, _sourceInfo(j, infos))) {
// The source came back with another media: the relay
// ends (its buffer is of the first one).
_log.warning('relay channel: the source is back with another media (${r.name}); the relay ends');
await _closeWith(ws, CloseCodes.relayOtherMedia, 'other media');
onOtherMedia?.call(r);
return;
}
if (infos == null || !r._channelInfo(infos)) {
// Not the media this swap named: the source stops.
_log.warning('relay channel: info does not match the media (${r.name})');
await _closeNotSource(ws);
return;
}
r._firstInfo ??= _sourceInfo(j, infos);
attached = true;
_log.info('relay channel up (${r.name}${r.warm ? ', warm' : ''}, from ${r.sourceId}, '
'${infos.map((t) => t.size != null ? '${t.size} bytes' : '${t.pieces!.length} pieces').join(' + ')})');
+261 -89
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:relic/relic.dart';
@@ -19,15 +20,91 @@ class ServerHub {
/// See [AppConfig.hostKey]; empty means nobody is the host.
final String hostKey;
final SyncEngine engine;
/// The `/sync` socket of each member that has one, by token; a `gone`
/// member has none.
final Map<String, RelicWebSocket> sockets = {};
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
JellyfinCredential? jellyfin;
/// Reads web pages for "the server fetches it"; null = files only.
final YtDlp? ytDlp;
/// Random per server boot (`welcome.instance`).
final String instance = _randomId();
ServerHub(this.sessions, this.expectedSecret,
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
: engine = engine ?? SyncEngine();
: engine = engine ?? SyncEngine() {
this.engine
..onGone = _memberGone
..onLeft = _memberLeft;
}
static String _randomId() {
final r = Random.secure();
return base64Url.encode(List<int>.generate(12, (_) => r.nextInt(256))).replaceAll('=', '');
}
/// A member went `gone`: a socket still open (silent) is closed; a
/// relay it serves goes on from its buffer (`media.relay.sourceGone`).
void _memberGone(EngineMember m) {
_hubLog.info('gone ${m.name} (${m.id})');
final ws = sockets.remove(m.token);
if (ws != null) unawaited(closeWith(ws, CloseCodes.silent, 'silent'));
refreshRelaySource();
}
/// A member left for good (`bye`, grace over): its token stops working,
/// a relay it serves ends.
void _memberLeft(EngineMember m) {
_hubLog.info('leave ${m.name} (${m.id})');
final ws = sockets.remove(m.token);
if (ws != null) unawaited(closeWith(ws, CloseCodes.left, 'left'));
session.tokens.remove(m.token);
final r = relay;
if (r != null && !r.fetch && r.sourceToken == m.token) {
_endRelay(r, 'its source left', sourceGone: true, since: m.goneSince ?? engine.now);
}
}
/// The current relay ends (its source left, or sent another media):
/// the buffer goes, `/r` answers 404, `media.relay` stays with `live`
/// false.
void _endRelay(ActiveRelay r, String why, {bool sourceGone = false, DateTime? since}) {
if (!identical(relay, r)) return;
_hubLog.info('relay of "${r.name}" ended: $why');
setRelay(null);
final m = engine.media;
final was = m?.relay;
if (m != null && was != null) {
engine.media = m.copyWith(
relay: was.copyWith(live: false, rate: 0, sourceGone: sourceGone, sourceGoneSince: since));
}
}
/// The source of [r] sent an `info` unlike its first (another media):
/// the relay ends (warm: dropped, made again from the next `info`).
void relayOtherMedia(ActiveRelay r) {
if (identical(warm, r)) {
_setWarm(null);
} else {
_endRelay(r, 'its source sent another media');
}
changed();
}
/// `media.relay.sourceGone` / `sourceGoneSince` follow the current
/// relay's source member (`gone` or back).
void refreshRelaySource() {
final r = relay, m = engine.media;
final was = m?.relay;
if (r == null || m == null || was == null || r.fetch || r.sourceToken == null) return;
final src = engine.members[r.sourceToken];
final gone = src == null || src.presence == Presence.gone;
if (gone == was.sourceGone) return;
_hubLog.info('relay of "${r.name}": source ${gone ? 'gone, viewers play what is buffered' : 'back'}');
engine.media = m.copyWith(relay: was.copyWith(sourceGone: gone, sourceGoneSince: src?.goneSince ?? engine.now));
}
bool get _sourceGone => engine.media?.relay?.sourceGone ?? false;
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
bool get relayAvailable => _onLogin(session.media?.jellyfin);
@@ -61,8 +138,8 @@ class ServerHub {
: '$head: $why';
}
StateBroadcast stateBroadcast() =>
engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView());
StateBroadcast stateBroadcast() => engine.broadcast(
relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView(), sourceGone: _sourceGone);
/// The relay of the current media (`/r/...`), if it is relayed.
ActiveRelay? relay;
@@ -109,10 +186,17 @@ class ServerHub {
({String id, MediaRelay relay})? _warmView() {
final w = warm, id = w?.queueId;
if (w == null || id == null) return null;
final src = w.sourceToken == null ? null : engine.members[w.sourceToken];
final gone = src?.presence == Presence.gone;
return (
id: id,
relay: MediaRelay(
source: w.fetch ? '' : w.sourceId, fetch: w.fetch, live: w.live, tracks: w.live ? w.tracks : const []),
source: w.fetch ? '' : w.sourceId,
fetch: w.fetch,
live: w.live,
tracks: w.live ? w.tracks : const [],
sourceGone: gone,
sourceGoneSince: gone ? src?.goneSince : null),
);
}
@@ -130,13 +214,15 @@ class ServerHub {
if (warm == null && first != null) _setWarm(_newWarm(first));
}
bool _othersHere(String? memberId) => engine.members.values.any((m) => m.id != memberId);
bool _othersHere(String? memberId) =>
engine.members.values.any((m) => m.id != memberId && m.presence != Presence.gone);
/// A Jellyfin [ref] someone here cannot play while someone can, outside
/// the server's login.
bool _jellyfinNeedsWarm(JellyfinRef ref) =>
!_onLogin(ref) &&
engine.members.values.any((m) => !m.out && m.jf[ref.serverId] != true) &&
engine.members.values
.any((m) => engine.policy.countsFor(m.presence, Purpose.preload) && m.jf[ref.serverId] != true) &&
engine.members.values.any((m) => m.jf[ref.serverId] == true);
bool _keepWarm(ActiveRelay w, QueueItem first) {
@@ -302,7 +388,7 @@ class ServerHub {
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
/// A member for the log: `name (id)`, never its token.
/// A member for the log: `name (id)`, never its token or client id.
String who(String token) {
final m = engine.members[token];
if (m != null) return '${m.name} (${m.id})';
@@ -310,13 +396,6 @@ class ServerHub {
return v != null ? '${v.name} (no member)' : 'unknown member';
}
/// The member a reconnecting client may take over with its old token:
/// still known.
EngineMember? resumable(String token) {
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
return engine.members[token];
}
Session get session => sessions.getOrCreate('default', expectedSecret);
void broadcastState() {
@@ -326,6 +405,19 @@ class ServerHub {
}
}
/// Once a second: closed sockets, then the engine's sweep (silence,
/// joining, gone notices, grace), relay source, warm relay, rates, and
/// a state for everyone.
void heartbeat() {
sweepClosedSockets();
engine.tick();
refreshRelaySource();
syncWarm();
sampleRates();
broadcastState();
broadcastFeed();
}
void broadcastFeed() {
for (final feed in engine.feed.drain()) {
final raw = jsonEncode(feed.toJson());
@@ -335,14 +427,8 @@ class ServerHub {
}
}
void dropSilentSockets() {
for (final token in engine.sweepDropped()) {
final ws = sockets[token];
if (ws != null) unawaited(ws.tryClose());
forgetSocket(token);
}
}
/// Relic's server-side `onDone` does not fire for every dead peer:
/// sockets are polled with `isClosed` too.
void sweepClosedSockets() {
for (final entry in sockets.entries.toList()) {
var closed = false;
@@ -351,7 +437,7 @@ class ServerHub {
} catch (_) {
closed = true;
}
if (closed) forgetSocket(entry.key);
if (closed) socketClosed(entry.key, entry.value);
}
}
@@ -365,24 +451,23 @@ class ServerHub {
}
}
void broadcastChat(String from, String text) {
void broadcastChat(String from, String text, {String fromId = ''}) {
final clean = text.length > RelayConstants.maxChatLength
? text.substring(0, RelayConstants.maxChatLength)
: text;
final raw = jsonEncode(
ChatMessage(text: clean).toJson()..addAll({'from': from}));
ChatMessage(text: clean).toJson()..addAll({'from': from, if (fromId.isNotEmpty) 'fromId': fromId}));
for (final ws in sockets.values.toList()) {
ws.trySendText(raw);
}
engine.feed.add(
FeedMessage(actor: from, action: 'chat', at: engine.now));
engine.feed.add(FeedMessage(actor: from, actorId: fromId, action: 'chat', at: engine.now));
broadcastFeed();
}
/// The server's own fetch of [name] failed: everyone hears it (the
/// swapper also gets the reason as an error), not just "getting it…".
void fetchFailed(String name) {
engine.feed.add(FeedMessage(actor: 'server', action: 'fetch-fail', target: name, at: engine.now));
engine.feed.add(FeedMessage(actor: 'server', actorId: 'server', action: 'fetch-fail', target: name, at: engine.now));
broadcastFeed();
}
@@ -392,18 +477,19 @@ class ServerHub {
broadcastFeed();
}
/// Like [forgetSocket], but only while [ws] is still the member's socket:
/// after a resume the old socket's close must not remove the member.
void forgetSocketOf(String token, RelicWebSocket ws) {
/// [ws], [token]'s socket, closed: the member is `gone` (kept for the
/// grace). A socket that is no longer the member's (taken over) changes
/// nothing.
void socketClosed(String token, RelicWebSocket ws) {
if (!identical(sockets[token], ws)) return;
forgetSocket(token);
sockets.remove(token);
engine.applyGone(token);
changed();
}
void forgetSocket(String token) {
if (engine.members.containsKey(token)) _hubLog.info('leave ${who(token)}');
engine.applyLeave(token);
session.tokens.remove(token);
sockets.remove(token);
/// `bye`: [token]'s member leaves now.
void bye(String token) {
engine.applyLeave(token, reason: 'bye');
changed();
}
@@ -478,8 +564,20 @@ String sanitizeMediaName(String name) {
return 'media';
}
void wsErr(RelicWebSocket ws, String message) {
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
/// Sends `err` [code] with [message] (plain text for people) and, when a
/// frame failed, its type as `for`. The socket stays open.
void wsErr(RelicWebSocket ws, ErrCode code, String message, {String? forType}) {
ws.trySendText(jsonEncode(ErrorFrame(code: code, message: message, forType: forType).toJson()));
}
/// Closes [ws] with [code] (see [CloseCodes]); a socket that is already
/// gone is fine.
Future<void> closeWith(RelicWebSocket ws, int code, String reason) async {
try {
await ws.close(code, reason);
} catch (_) {
await ws.tryClose();
}
}
/// Jellyfin ids are path segments of upstream requests: keep them plain.
@@ -532,9 +630,12 @@ void mountSyncRoute(
}
void handleIntent(String text) {
// A socket another hello took over says nothing for the member.
if (token != null && !identical(hub.sockets[token], ws)) return;
final session = hub.session;
final viewer = token == null ? null : session.tokens[token];
viewer?.touch(hub.engine.now);
if (token != null) hub.engine.touch(token);
Map<String, Object?> decoded;
try {
final parsed = jsonDecode(text);
@@ -575,7 +676,12 @@ void mountSyncRoute(
final hidden = hub.engine.queue.privateUrlOf(queueId);
if (hidden != null && (parsed.url ?? '').isEmpty) {
parsed = HelloMedia(
name: parsed.name, size: parsed.size, duration: parsed.duration, url: hidden, relay: parsed.relay);
name: parsed.name,
size: parsed.size,
duration: parsed.duration,
url: hidden,
relay: parsed.relay,
ref: parsed.ref);
}
}
if (parsed.jellyfin != null) {
@@ -594,6 +700,7 @@ void mountSyncRoute(
size: parsed.size,
duration: parsed.duration,
jellyfin: ref,
ref: parsed.ref,
relay: relayReq == null
? null
: promoted != null
@@ -601,7 +708,7 @@ void mountSyncRoute(
: hub.relayFor(token, const MediaRelay()));
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
promoted?.dispose();
wsErr(ws, 'bad swap media');
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return;
}
session.media = SessionMedia(
@@ -627,14 +734,14 @@ void mountSyncRoute(
final link = Uri.tryParse(parsed.url ?? '');
if (relayReq.fetch &&
(link == null || !(link.isScheme('http') || link.isScheme('https')) || link.host.isEmpty)) {
wsErr(ws, 'relay fetch needs an http(s) url');
wsErr(ws, ErrCode.bad, 'relay fetch needs an http(s) url', forType: 'swap');
return;
}
// The server would fetch any address it can reach (its own
// network, a VPN) for whoever asks: only for members who
// know the password.
if (relayReq.fetch && session.secret.isEmpty) {
wsErr(ws, 'server fetch needs a server password');
wsErr(ws, ErrCode.rejected, 'server fetch needs a server password', forType: 'swap');
return;
}
// Up next's warm relay of this item carries on as its relay
@@ -645,16 +752,17 @@ void mountSyncRoute(
name: shown,
size: parsed.size,
duration: parsed.duration,
ref: parsed.ref,
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
promoted?.dispose();
wsErr(ws, 'bad swap media');
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return;
}
session.media = SessionMedia(
name: shown, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
void failed(String why) {
wsErr(ws, hub.fetchFailText(why));
wsErr(ws, ErrCode.rejected, hub.fetchFailText(why), forType: 'swap');
hub.fetchFailed(shown);
}
late final ActiveRelay active;
@@ -678,17 +786,18 @@ void mountSyncRoute(
name: sanitizeMediaName(parsed.name),
size: parsed.size,
duration: parsed.duration,
url: parsed.url));
url: parsed.url,
ref: parsed.ref));
final current = session.media;
final matchesFile = current?.file != null &&
current!.name == intent.media.name &&
current.size == intent.media.size;
if (!matchesFile && (intent.media.url ?? '').isEmpty) {
wsErr(ws, 'swap needs a url or the hosted file');
wsErr(ws, ErrCode.bad, 'swap needs a url or the hosted file', forType: 'swap');
return;
}
if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) {
wsErr(ws, 'bad swap media');
wsErr(ws, ErrCode.bad, 'bad swap media', forType: 'swap');
return;
}
hub.setRelay(null);
@@ -714,7 +823,7 @@ void mountSyncRoute(
final intent = ProfileIntent.fromJson(decoded);
final avatar = intent.avatar;
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
wsErr(ws, 'rejected: avatar too large');
wsErr(ws, ErrCode.rejected, 'rejected: avatar too large', forType: 'profile');
return;
}
final name = intent.name == null ? null : cleanMemberName(intent.name!);
@@ -749,12 +858,19 @@ void mountSyncRoute(
.toJson()));
case 'chat':
final chat = ChatMessage.fromJson(decoded);
hub.broadcastChat(viewer?.name ?? '?', chat.text);
case 'out':
hub.broadcastChat(viewer?.name ?? '?', chat.text, fromId: hub.engine.members[token]?.id ?? '');
case 'presence':
if (token == null) return;
final intent = OutIntent.fromJson(decoded);
hub.engine.applyOut(token, intent.target, intent.out);
final intent = PresenceIntent.fromJson(decoded);
final refused = hub.engine.applyPresence(token, intent.target, intent.state, reason: intent.reason);
if (refused != null) {
wsErr(ws, refused, 'only a member itself comes back in', forType: 'presence');
}
hub.changed();
case 'bye':
if (token == null) return;
_hubLog.info('bye from ${hub.who(token)}');
hub.bye(token);
case 'nudge':
if (token == null) return;
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
@@ -764,7 +880,7 @@ void mountSyncRoute(
}
} on ProtocolException catch (e) {
_hubLog.info('${token == null ? 'a socket' : hub.who(token)}: bad $type message: ${e.message}');
wsErr(ws, 'bad message: ${e.message}');
wsErr(ws, ErrCode.bad, 'bad message: ${e.message}', forType: type is String ? type : null);
} catch (e, st) {
_hubLog.warning('${token == null ? 'a socket' : hub.who(token)}: $type message failed: $e', e, st);
return;
@@ -781,13 +897,13 @@ void mountSyncRoute(
}
},
onError: (_) {
if (token != null) hub.forgetSocketOf(token, ws);
if (token != null) hub.socketClosed(token, ws);
},
onDone: () {
if (!firstText.isCompleted) {
firstText.completeError(StateError('closed before hello'));
} else if (token != null) {
hub.forgetSocketOf(token, ws);
hub.socketClosed(token, ws);
}
},
);
@@ -798,64 +914,121 @@ void mountSyncRoute(
await close();
return;
}
HelloMessage hello;
Future<void> refuse(ErrorFrame err, int code, String log) async {
_hubLog.warning('reject: $log');
ws.trySendText(jsonEncode(err.toJson()));
try {
await sub.cancel();
} catch (_) {}
await closeWith(ws, code, err.code?.name ?? 'refused');
}
// The version first, read leniently: another version's hello may
// not parse as ours.
final Map<String, Object?> raw0;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) throw const ProtocolException('hello must be an object');
hello = HelloMessage.fromJson(Map<String, Object?>.from(decoded));
raw0 = Map<String, Object?>.from(decoded);
} catch (e) {
_hubLog.warning('reject: bad hello: $e');
wsErr(ws, 'bad hello: $e');
await close();
await refuse(ErrorFrame(code: ErrCode.bad, message: 'bad hello: $e', forType: 'hello'), CloseCodes.refused,
'bad hello: $e');
return;
}
if (hello.proto != RelayConstants.protoVersion) {
_hubLog.warning(
'reject ${hello.user.name}: proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
wsErr(ws, 'proto mismatch: got ${hello.proto}, want ${RelayConstants.protoVersion}');
await close();
final range = HelloMessage.protoRangeOf(raw0);
final proto = range == null ? null : pickProto(range.min, range.max);
if (proto == null) {
final got = range?.max ?? 0;
await refuse(ErrorFrame.version(got: got, server: appVersion), CloseCodes.version,
'proto mismatch: got ${range == null ? 'none' : '${range.min}..${range.max}'}, '
'want ${RelayConstants.protoMin}..${RelayConstants.protoMax}');
return;
}
HelloMessage hello;
try {
hello = HelloMessage.fromJson(raw0);
} catch (e) {
await refuse(ErrorFrame(code: ErrCode.bad, message: 'bad hello: $e', forType: 'hello'), CloseCodes.refused,
'bad hello: $e');
return;
}
final session = hub.session;
if (session.secret.isNotEmpty && hello.secret != session.secret) {
_hubLog.warning('reject ${hello.user.name}: wrong secret');
wsErr(ws, 'wrong secret');
await close();
await refuse(const ErrorFrame(code: ErrCode.secret, message: 'wrong secret', forType: 'hello'),
CloseCodes.secret, '${cleanMemberName(hello.user.name)}: wrong secret');
return;
}
final resumed = hub.resumable(hello.resume);
String memberId;
if (resumed != null) {
// Unclean drop the server has not noticed yet: take the member over
// (same token, id, ready flag) and drop the dead socket quietly.
token = hello.resume;
memberId = resumed.id;
final old = hub.sockets.remove(token);
if (old != null) unawaited(old.tryClose());
hub.engine.applyResume(token);
_hubLog.info('resume ${hello.user.name} ($memberId)');
if (hello.user.avatar.length > RelayConstants.maxAvatarBytes) {
await refuse(
const ErrorFrame(code: ErrCode.rejected, message: 'rejected: avatar too large', forType: 'hello'),
CloseCodes.refused,
'${cleanMemberName(hello.user.name)}: avatar too large');
return;
}
final known = hub.engine.byClient(hello.client);
if (known != null && hello.seq < known.seq) {
await refuse(
const ErrorFrame(
code: ErrCode.stale, message: 'an older connection of this app (a newer one holds it)', forType: 'hello'),
CloseCodes.stale,
'${known.name} (${known.id}): an older hello than the member\'s');
return;
}
final String memberId;
final bool resumed;
final about = '${hello.app.isEmpty ? 'no version' : hello.app}${hello.platform.isEmpty ? '' : ' on ${hello.platform}'}';
if (known != null) {
// This app again (a reconnect, or opened anew): it takes its
// member over, same token and id; an older socket still open is
// told and closed.
token = known.token;
memberId = known.id;
resumed = true;
final old = hub.sockets[known.token];
hub.sockets[known.token] = ws;
if (old != null && !identical(old, ws)) {
old.trySendText(jsonEncode(const ErrorFrame(
code: ErrCode.replaced, message: 'this member was opened from another connection of the same app')
.toJson()));
unawaited(closeWith(old, CloseCodes.replaced, 'replaced'));
}
final back = hub.engine.applyTakeover(known.token,
seq: hello.seq, app: hello.app, platform: hello.platform, caps: hello.caps);
final name = cleanMemberName(hello.user.name);
session.tokens[known.token]?.name = name;
hub.engine.applyProfile(known.token, name: name, avatar: hello.user.avatar);
_hubLog.info('${back ? 'return' : 'takeover'} ${known.name} ($memberId), $about');
} else {
try {
token = session.issueToken(hello.user.name, hello.user.avatar);
} catch (e) {
_hubLog.warning('reject ${hello.user.name}: $e');
wsErr(ws, 'rejected: $e');
await close();
await refuse(ErrorFrame(code: ErrCode.rejected, message: 'rejected: $e', forType: 'hello'),
CloseCodes.refused, '${cleanMemberName(hello.user.name)}: $e');
return;
}
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar,
client: hello.client, seq: hello.seq, app: hello.app, platform: hello.platform, caps: hello.caps);
resumed = false;
hub.sockets[token] = ws;
_hubLog.info('join ${session.tokens[token]!.name} ($memberId), $about');
}
final isHost = hub.isHostKey(hello.hostKey);
session.tokens[token]!.isHost = isHost;
hub.engine.members[token]?.host = isHost;
final activeToken = token;
hub.sockets[activeToken] = ws;
final isHost = hub.isHostKey(hello.hostKey);
session.tokens[activeToken]!.isHost = isHost;
hub.engine.members[activeToken]?.host = isHost;
hub.refreshRelaySource();
final media = session.media;
final relay = hub.relayAvailable;
final relayed = hub.relay;
final feedAfter = hello.feedAfter;
final welcome = WelcomeMessage(
token: activeToken,
memberId: memberId,
proto: proto,
resumed: resumed,
instance: hub.instance,
grace: hub.engine.liveness.grace.inSeconds,
feed: feedAfter == null ? const [] : hub.engine.feed.after(feedAfter),
// Jellyfin media resolves per member (own account first); only
// relayed files and links start from the /r/ URL, and never for
// the source itself (it plays its own copy).
@@ -885,7 +1058,6 @@ void mountSyncRoute(
return;
}
welcomed = true;
_hubLog.info('join ${hello.user.name} ($memberId)');
hub.changed();
try {
await sub.asFuture();
+9 -9
View File
@@ -67,7 +67,7 @@ class SyncServer {
/// [ytDlp] replaces the server's own (tests). [clock] and [liveness]
/// go to the engine: a test passes a fake clock to step through the
/// silence limits ([Liveness]) without waiting.
/// silence, grace and joiner limits ([Liveness]) without waiting.
SyncServer(this.config,
{YtDlp? ytDlp, this.loopbackOnly = false, this.logDir, SyncClock? clock, Liveness liveness = const Liveness()}) {
hub = ServerHub(sessions, config.secret,
@@ -103,7 +103,11 @@ class SyncServer {
body: Body.fromString(jsonEncode({
'ok': true,
'name': config.name,
'proto': RelayConstants.protoVersion,
'proto': RelayConstants.protoMax,
'protoMin': RelayConstants.protoMin,
'protoMax': RelayConstants.protoMax,
'version': appVersion,
'tls': false,
'viewers': viewers,
'bytesServed': bytes,
'uptimeSecs': uptime,
@@ -113,7 +117,8 @@ class SyncServer {
final requests = MediaRequestLog();
void logRequest(String token, MediaLog log) => requests(hub.who(token), log);
mountSessionMediaRoute(_app, sessions, onLog: logRequest);
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf, onLog: logRequest);
mountRelayRoutes(_app, sessions,
relayAt: hub.relayAt, sourceOf: hub.sourceOf, onOtherMedia: hub.relayOtherMedia, onLog: logRequest);
mountAvatarRoute(_app, hub);
mountArtRoute(_app, hub);
mountSessionLogRoutes(_app, hub, () => _sessionLog);
@@ -174,12 +179,7 @@ class SyncServer {
);
_startedAt = DateTime.now();
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
hub.engine.tick();
hub.sweepClosedSockets();
hub.dropSilentSockets();
hub.sampleRates();
hub.broadcastState();
hub.broadcastFeed();
hub.heartbeat();
_sessionLog?.flush();
});
_log.info('server "${config.name}" listening on $boundPort');
+41 -21
View File
@@ -1,9 +1,6 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): both
// tests pin today's rules (a silent member is stale, then removed; a reset
// socket loses its member, so the redial is a new one, P1); with grace they
// expect `gone` and the same member back.
//
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests").
// 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';
@@ -22,7 +19,7 @@ class _Client {
Timer? _reports;
late final StreamSubscription<dynamic> _sub;
_Client(int port, {String resume = ''})
_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)),
@@ -31,11 +28,15 @@ class _Client {
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'alice'),
media: const HelloMedia(name: 'f.bin', size: 1, duration: 10.0),
resume: resume)
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;
@@ -73,7 +74,7 @@ void main() {
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(staleAfter: Duration(milliseconds: 600), dropAfter: Duration(milliseconds: 1500)));
liveness: const Liveness(silence: Duration(milliseconds: 600)));
await server.start();
proxy = await FlakyProxy.start(server.boundPort);
});
@@ -87,25 +88,34 @@ void main() {
await server.stop();
});
_Client dial({String resume = ''}) {
final c = _Client(proxy.port, resume: resume);
_Client dial({int seq = 1}) {
final c = _Client(proxy.port, seq: seq);
clients.add(c);
return c;
}
test('a blackholed client goes stale, then is removed; it never hears of it', () async {
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(engine.isStale(member()!), isFalse, reason: 'reports through the proxy keep it fresh');
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() != null && engine.isStale(member()!), 'stale');
await until(() => member() == null, 'removed');
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));
@@ -113,20 +123,30 @@ void main() {
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; today it comes back as a new member', () async {
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');
final first = c.memberId;
proxy.last!.reset();
await until(() => c.ended, 'client sees the reset');
await until(() => !server.hub.engine.members.containsKey(c.token), 'server forgets the socket');
await until(() => server.hub.engine.members[c.token]?.presence == Presence.gone, 'server sees it gone');
final again = dial(resume: c.token);
final again = dial(seq: 2);
await until(() => again.welcome != null, 'welcome after redial');
expect(proxy.links, hasLength(2));
expect(again.memberId, isNot(first), reason: 'P1: no grace yet');
expect([again.memberId, again.token], [c.memberId, c.token]);
expect(server.hub.engine.members, hasLength(1));
});
}
+16 -16
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): "anyone
// sits anyone out" (Away); the rest stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -25,7 +23,6 @@ class _Peer {
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0),
hostKey: hostKey)
.toJson()));
await p.next('welcome');
@@ -81,6 +78,7 @@ void main() {
a.send('{"t":"report","position":1e400,"buffered":1e400,"eventId":0}');
final err = await a.next('err');
expect('${err['message']}', contains('finite'));
expect([err['code'], err['for']], ['bad', 'seek']);
expect(server.hub.engine.position, 0);
// still alive: a valid seek round-trips through a broadcast
a.send('{"t":"seek","position":5}');
@@ -125,7 +123,7 @@ void main() {
await a.ws.close();
});
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
test('anyone sets anyone away (host too), only oneself comes back in; nudges reach only the target, capped', () async {
await server.stop();
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
await server.start();
@@ -137,19 +135,23 @@ void main() {
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
EngineMember of(String name) =>
server.hub.engine.members.values.firstWhere((m) => m.name == name);
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.away).toJson()));
var feed = await friend.next('feed');
while (feed['action'] != 'out') {
while (feed['action'] != 'away') {
feed = await friend.next('feed');
}
expect([feed['actor'], feed['target']], ['friend', 'host']);
expect(of('host').out, isTrue);
expect(of('host').inSet, isFalse);
expect([feed['actor'], feed['target'], feed['actorId'], feed['targetId']],
['friend', 'host', idOf('friend'), idOf('host')]);
expect(of('host').presence, Presence.away);
expect(server.hub.engine.countsForGate(of('host')), isFalse);
// Only oneself comes back.
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
friend.send(jsonEncode(PresenceIntent(target: idOf('host'), state: Presence.watching).toJson()));
final err = await friend.next('err');
expect([err['code'], err['for']], ['forbidden', 'presence']);
expect(of('host').presence, Presence.away);
host.send(jsonEncode(const PresenceIntent(state: Presence.watching).toJson()));
await Future.delayed(const Duration(milliseconds: 200));
expect(of('host').out, isFalse);
expect(of('host').presence, Presence.watching, reason: 'no media up: nothing to load');
// 5 at once: 3 pass (per second), only to the target.
for (var i = 0; i < 5; i++) {
@@ -224,8 +226,7 @@ void main() {
ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: const HelloUser(name: 'a'),
media: const HelloMedia(name: '', size: 0, duration: 0))
user: const HelloUser(name: 'a'))
.toJson()));
final w = await p.next('welcome');
expect('${w['mediaUrl']}', startsWith('http://127.0.0.1:${server.boundPort}/m/'));
@@ -239,8 +240,7 @@ void main() {
ws2.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: const HelloUser(name: 'c'),
media: const HelloMedia(name: '', size: 0, duration: 0))
user: const HelloUser(name: 'c'))
.toJson()));
final w2 = await p2.next('welcome');
expect('${w2['mediaUrl']}', startsWith('http://$lan:${server.boundPort}/m/'));
@@ -54,8 +54,7 @@ void main() {
ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: const HelloUser(name: 'live'),
media: const HelloMedia(name: '', size: 0, duration: 0))
user: const HelloUser(name: 'live'))
.toJson()));
final welcome = await frames.firstWhere((f) => f['t'] == 'welcome').timeout(const Duration(seconds: 5));
ws.add(jsonEncode(SwapIntent(
@@ -133,9 +133,7 @@ class _Client {
c.send(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0),
).toJson());
user: HelloUser(name: name)).toJson());
return (c, await c._welcome.future.timeout(const Duration(seconds: 5)));
}
}
+14 -8
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): a
// socket close no longer removes the member (explicit `bye`, grace).
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -74,14 +72,22 @@ void main() {
await server.stop();
});
test('clean client close removes member within seconds, not timeout',
() async {
test('a clean close is not a leave: the member is gone at once, kept', () async {
final client = await _ProbeClient.hello(server.boundPort, 'alice');
await _pollBool(
() => server.hub.engine.members.length == 1, 'join visible');
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
await client.ws.close();
await _pollBool(
() => server.hub.engine.members.isEmpty, 'member removed');
await _pollBool(() => server.hub.engine.members.values.single.presence == Presence.gone, 'gone');
expect(server.session.tokens, hasLength(1), reason: 'its token works through the grace');
expect(server.hub.sockets, isEmpty);
});
test('bye leaves at once: member, token and socket go', () async {
final client = await _ProbeClient.hello(server.boundPort, 'alice');
await _pollBool(() => server.hub.engine.members.length == 1, 'join visible');
client.ws.add(jsonEncode(const ByeMessage().toJson()));
final outcome = await client.closed.future.timeout(const Duration(seconds: 5));
expect(outcome, contains('code=${CloseCodes.left}'));
expect(server.hub.engine.members, isEmpty);
expect(server.session.tokens, isEmpty);
expect(server.hub.sockets, isEmpty);
});
+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');
});
}
+23 -4
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): queue
// `by` and adder-only keys follow the lasting client id.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -15,14 +13,15 @@ class _Peer {
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
}
static Future<_Peer> join(int port, String name) async {
static Future<_Peer> join(int port, String name, {String client = '', int seq = 0}) async {
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final p = _Peer(ws);
p.send(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 's',
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0))
client: client,
seq: seq)
.toJson());
await p.until((m) => m['t'] == 'welcome');
return p;
@@ -122,4 +121,24 @@ void main() {
expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link');
expect(s.queue.single.key, 'k1234567890');
});
test("an adder's items keep its lasting id through a drop and wait while it is gone", () async {
const install = 'queueTestInstallId0001';
final a = await _Peer.join(server.boundPort, 'alice', client: install, seq: 1);
final b = await _Peer.join(server.boundPort, 'bob');
a.send(const QueueIntent.add(QueueItem(
media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f', key: 'k1234567890'))
.toJson());
final added = (await b.state((s) => s.queue.isNotEmpty)).queue.single;
final aliceId = added.by;
await a.ws.close();
// Gone: still a member, so whoever would put the item up waits for it
// instead of skipping it.
final whileGone = await b.state((s) => s.members.any((m) => m.id == aliceId && m.presence == Presence.gone));
expect(whileGone.queue.single.by, aliceId);
final again = await _Peer.join(server.boundPort, 'alice', client: install, seq: 2);
final back = await again.state((s) => s.members.any((m) => m.id == aliceId && m.presence != Presence.gone));
expect(back.queue.single.by, aliceId, reason: 'the same member: its items are its own again');
expect(back.members.where((m) => m.name == 'alice'), hasLength(1));
});
}
+79 -6
View File
@@ -17,7 +17,7 @@ class _Member {
late WelcomeMessage welcome;
_Member(this.ws);
static Future<_Member> join(int port, String name, {String secret = 'pw'}) async {
static Future<_Member> join(int port, String name, {String secret = 'pw', String client = '', int seq = 0}) async {
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final m = _Member(ws);
final w = Completer<String>();
@@ -29,8 +29,8 @@ class _Member {
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0),
).toJson()));
client: client,
seq: seq).toJson()));
m.welcome = WelcomeMessage.fromJson(
Map<String, Object?>.from(jsonDecode(await w.future.timeout(const Duration(seconds: 5))) as Map));
return m;
@@ -86,8 +86,8 @@ void main() {
await tmp.delete(recursive: true);
});
Future<_Member> join(String name) async {
final m = await _Member.join(server.boundPort, name);
Future<_Member> join(String name, {String client = '', int seq = 0}) async {
final m = await _Member.join(server.boundPort, name, client: client, seq: seq);
cleanup.add(() => m.ws.close());
return m;
}
@@ -251,6 +251,75 @@ void main() {
expect(body, bytes.sublist(RelayBuffer.slice * 4));
});
group('the source drops (D6)', () {
const install = 'relaySourceInstall0001';
Map? relayOf(_Member m) => m.media?['relay'] as Map?;
test('gone: the relay stays, sourceGone, the buffer serves; back with the same info it continues', () async {
final alice = await join('alice', client: install, seq: 1);
final bob = await join('bob');
await swapRelay(alice);
final src = await startSource(alice);
final url = viewerUrl(bob, mediaIdOf(alice));
expect((await get(url, range: 'bytes=0-${RelayBuffer.slice * 2 - 1}')).$3, bytes.sublist(0, RelayBuffer.slice * 2));
await alice.ws.close();
await src.close();
await src.done;
await _until(() => relayOf(bob)?['sourceGone'] == true, 'sourceGone');
expect(relayOf(bob)!['sourceGoneSince'], isA<String>());
expect(server.hub.relay, isNotNull, reason: 'the relay lives through the grace');
expect((await get(url, range: 'bytes=0-99')).$3, bytes.sublist(0, 100), reason: 'what is buffered serves');
final pending = get(url, range: 'bytes=${RelayBuffer.slice * 4}-');
await Future<void>.delayed(const Duration(milliseconds: 300));
final again = await join('alice', client: install, seq: 2);
expect([again.welcome.resumed, again.welcome.token], [true, alice.welcome.token]);
await _until(() => relayOf(bob)?['sourceGone'] == null, 'source back');
await startSource(again);
final (status, _, body) = await pending.timeout(const Duration(seconds: 10));
expect(status, 206);
expect(body, bytes.sublist(RelayBuffer.slice * 4), reason: 'the rest is pulled from the source again');
});
test('back with another media: closed with 4002, the relay ends', () async {
final alice = await join('alice');
final bob = await join('bob');
await swapRelay(alice);
final src = await startSource(alice);
await _until(() => server.hub.relay?.live == true, 'live');
await src.close();
await src.done;
final other = File('${tmp.path}/other.mkv')..writeAsBytesSync(List.filled(1000, 3));
final wrong = await startSource(alice, from: RelayMedia.single(FileRelayBytes(other)));
await wrong.done.timeout(const Duration(seconds: 5));
await _until(() => server.hub.relay == null, 'relay ended');
await _until(() => relayOf(bob) != null && relayOf(bob)!['live'] == null, 'not live');
expect((await get(viewerUrl(bob, mediaIdOf(alice)), range: 'bytes=0-9')).$1, 404);
});
test('a second channel for the same relay replaces the first (4003)', () async {
final alice = await join('alice');
await swapRelay(alice);
final first = await startSource(alice);
await _until(() => server.hub.relay?.live == true, 'live');
await startSource(alice);
await first.done.timeout(const Duration(seconds: 5));
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(server.hub.relay?.live, isTrue, reason: 'the second one feeds it');
});
test('its source leaving for good ends the relay', () async {
final alice = await join('alice');
final bob = await join('bob');
await swapRelay(alice);
final src = await startSource(alice);
await _until(() => server.hub.relay?.live == true, 'live');
alice.send(const ByeMessage().toJson());
await src.done.timeout(const Duration(seconds: 5));
await _until(() => relayOf(bob)?['sourceGone'] == true && relayOf(bob)?['live'] == null, 'ended');
expect(server.hub.relay, isNull);
});
});
test('two tracks: each byte-exact at its own URL, welcome is an EDL of both', () async {
final alice = await join('alice');
final bob = await join('bob');
@@ -554,13 +623,17 @@ void main() {
expect(server.hub.warm!.queueId, isNot(id));
});
test('not for an adder alone; dropped when its adder leaves', () async {
test('not for an adder alone; kept while its adder is gone, dropped when it leaves', () async {
final alice = await join('alice');
await add(alice, through('k-abcdefgh'));
expect(headRelay(alice), isNull);
final bob = await join('bob');
await _until(() => headRelay(bob) != null, 'warm relay once someone could read it');
await alice.ws.close();
await _until(() => headRelay(bob)?['sourceGone'] == true, 'adder gone');
expect(server.hub.warm, isNotNull, reason: 'its adder may come back within the grace');
server.hub.engine.applyLeave(alice.welcome.token, reason: 'bye');
server.hub.changed();
await _until(() => bob.lastState!['members'] is List && (bob.lastState!['members'] as List).length == 1, 'left');
expect(queueOf(bob), hasLength(1));
expect(headRelay(bob), isNull);
+45 -32
View File
@@ -1,6 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence):
// "proto mismatch rejected" (min/max range, refusal frame) and "silent
// socket dropped and member removed" (Gone with grace); the rest stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -43,15 +40,15 @@ void main() {
Future<String> hello(String secret,
{int proto = RelayConstants.protoVersion,
int? protoMin,
Duration timeout = const Duration(seconds: 5)}) async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final msg = HelloMessage(
proto: proto,
protoMin: protoMin,
secret: secret,
user: const HelloUser(name: 'alice'),
media:
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
user: const HelloUser(name: 'alice')).toJson();
ch.sink.add(jsonEncode(msg));
try {
final first = await ch.stream.first.timeout(timeout);
@@ -73,7 +70,9 @@ void main() {
final decoded = jsonDecode(body) as Map;
expect(decoded['ok'], isTrue);
expect(decoded['name'], 'test');
expect(decoded['proto'], RelayConstants.protoVersion);
expect(decoded['proto'], RelayConstants.protoMax);
expect([decoded['protoMin'], decoded['protoMax'], decoded['version'], decoded['tls']],
[RelayConstants.protoMin, RelayConstants.protoMax, appVersion, false]);
expect(decoded['viewers'], 0);
expect(decoded['bytesServed'], 0);
expect(decoded.containsKey('media'), isFalse, reason: 'no media name leak');
@@ -81,7 +80,7 @@ void main() {
test('wrong secret gets err, right secret gets welcome+token', () async {
final bad = await hello('wrong');
expect(jsonDecode(bad)['t'], 'err');
expect([jsonDecode(bad)['t'], jsonDecode(bad)['code'], jsonDecode(bad)['message']], ['err', 'secret', 'wrong secret']);
final good = await hello('shh');
final decoded = jsonDecode(good) as Map;
@@ -96,10 +95,32 @@ void main() {
expect(server.session.tokens.length, 1);
});
test('proto mismatch rejected', () async {
final reply =
await hello('shh', proto: RelayConstants.protoVersion + 99);
expect(jsonDecode(reply)['t'], 'err');
test('version ranges: a common version is taken, none is refused with code version', () async {
final newer = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMin, proto: RelayConstants.protoMax + 3));
expect([newer['t'], newer['proto']], ['welcome', RelayConstants.protoMax], reason: 'the highest common one');
final ahead = jsonDecode(await hello('shh', protoMin: RelayConstants.protoMax + 1, proto: RelayConstants.protoMax + 2));
expect([ahead['t'], ahead['code'], ahead['min'], ahead['max'], ahead['server']],
['err', 'version', RelayConstants.protoMin, RelayConstants.protoMax, appVersion]);
expect(server.session.tokens, hasLength(1));
});
test('a proto 15 hello gets the stable refusal it can show, then the version close code', () async {
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
// What a 1.2.x app sends: no protoMin, a media object, a resume token.
ch.sink.add(jsonEncode({
't': 'hello',
'proto': 15,
'secret': 'shh',
'user': {'name': 'old', 'avatar': ''},
'media': {'name': '', 'size': 0, 'duration': 0},
'resume': 'deadbeef',
}));
final frames = await ch.stream.toList().timeout(const Duration(seconds: 5));
final err = jsonDecode('${frames.single}') as Map;
expect(err['t'], 'err');
expect(err['code'], 'version');
expect(err['message'], startsWith('proto mismatch: got 15'));
expect(ch.closeCode, CloseCodes.version);
expect(server.session.tokens, isEmpty);
});
@@ -136,7 +157,7 @@ void main() {
await resp.drain();
});
test('silent socket dropped and member removed', () async {
test('a silent socket is closed and its member is gone, not removed', () async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final messages = <String>[];
@@ -148,9 +169,7 @@ void main() {
final msg = HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'ghost'),
media: const HelloMedia(
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
user: const HelloUser(name: 'ghost')).toJson();
ch.sink.add(jsonEncode(msg));
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (messages.isEmpty && DateTime.now().isBefore(deadline)) {
@@ -160,18 +179,16 @@ void main() {
(jsonDecode(messages.firstWhere((m) => (jsonDecode(m) as Map)['t'] == 'welcome')) as Map)['token']
as String;
server.hub.engine.members[token]!.lastSeen =
DateTime.now().toUtc().subtract(const Duration(seconds: 61));
server.hub.dropSilentSockets();
DateTime.now().toUtc().subtract(const Duration(seconds: 16));
server.hub.engine.tick();
final closeDeadline = DateTime.now().add(const Duration(seconds: 5));
while ((!wsClosed ||
server.hub.engine.members.containsKey(token) ||
server.session.tokens.containsKey(token)) &&
DateTime.now().isBefore(closeDeadline)) {
while (!wsClosed && DateTime.now().isBefore(closeDeadline)) {
await Future.delayed(const Duration(milliseconds: 50));
}
expect(wsClosed, isTrue);
expect(server.hub.engine.members.containsKey(token), isFalse);
expect(server.session.tokens.containsKey(token), isFalse);
expect(ch.closeCode, CloseCodes.silent);
expect(server.hub.engine.members[token]!.presence, Presence.gone);
expect(server.session.tokens.containsKey(token), isTrue);
try {
await sub.cancel();
} catch (_) {}
@@ -193,9 +210,7 @@ void main() {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'hb'),
media: const HelloMedia(
name: 'f.bin', size: 8192, duration: 10.0))
user: const HelloUser(name: 'hb'))
.toJson()));
await Future.delayed(const Duration(seconds: 1));
final hbToken = server.session.tokens.keys
@@ -223,8 +238,7 @@ void main() {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: name),
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
user: HelloUser(name: name)).toJson()));
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (!messages.any((m) => m['t'] == 'welcome')) {
if (DateTime.now().isAfter(deadline)) fail('no welcome');
@@ -329,8 +343,7 @@ void main() {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
user: HelloUser(name: 'alice', avatar: base64Encode(png))).toJson()));
final welcome = await waitFor((m) => m['t'] == 'welcome');
final token = welcome['token'] as String;
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
@@ -376,7 +389,7 @@ void main() {
ch.sink.add(jsonEncode(
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
final err = await waitFor((m) => m['t'] == 'err');
expect(err['message'], 'rejected: avatar too large');
expect([err['code'], err['for'], err['message']], ['rejected', 'profile', 'rejected: avatar too large']);
} finally {
await sub.cancel();
try {
+1 -2
View File
@@ -71,8 +71,7 @@ void main() {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 's',
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0))
user: HelloUser(name: name))
.toJson()));
while (await msgs.moveNext()) {
final m = jsonDecode('${msgs.current}') as Map;
+5 -5
View File
@@ -64,17 +64,17 @@ void main() {
});
group('core welcome + proto', () {
test('proto 15: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 15);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
test('avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, RelayConstants.protoMax);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);
const h = MemberView(
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0);
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
// host: only on the hosting app's member, absent otherwise.
expect(v.toJson().containsKey('host'), isFalse);
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true);
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, host: true);
expect(host.toJson()['host'], isTrue);
expect(MemberView.fromJson(Map<String, Object?>.from(host.toJson())).host, isTrue);
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
+67 -33
View File
@@ -1,6 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
// resume-token takeover becomes a lasting client id; ping/pong and the
// seek echo stay.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -47,12 +44,15 @@ class _Client {
final WebSocket ws;
final frames = <Map<String, Object?>>[];
final _welcome = Completer<Map<String, Object?>>();
final closed = Completer<int?>();
_Client(this.ws) {
ws.listen((m) {
final d = Map<String, Object?>.from(jsonDecode('$m') as Map);
frames.add(d);
if (d['t'] == 'welcome' && !_welcome.isCompleted) _welcome.complete(d);
}, onError: (_) {}, onDone: () {});
}, onError: (_) {}, onDone: () {
if (!closed.isCompleted) closed.complete(ws.closeCode);
});
}
StateBroadcast? get lastState {
@@ -65,18 +65,24 @@ class _Client {
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
static Future<(_Client, WelcomeMessage)> join(int port, String name,
{String resume = ''}) async {
{String client = '', int seq = 0}) async {
final c = await dial(port, name, client: client, seq: seq);
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
return (c, WelcomeMessage.fromJson(w));
}
/// Sends the hello and returns at once (a refused one gets no welcome).
static Future<_Client> dial(int port, String name, {String client = '', int seq = 0}) async {
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final c = _Client(ws);
c.send(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'w',
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0),
resume: resume,
).toJson());
final w = await c._welcome.future.timeout(const Duration(seconds: 5));
return (c, WelcomeMessage.fromJson(w));
proto: RelayConstants.protoVersion,
secret: 'w',
user: HelloUser(name: name),
client: client,
seq: seq)
.toJson());
return c;
}
}
@@ -98,42 +104,70 @@ void main() {
});
tearDown(() => server.stop());
test('reconnect after an unclean drop takes over the old member, no ghost',
() async {
const alice = 'aliceInstallIdAAAAAAAA';
test('an unclean drop: the same app takes its member over (id, token, ready), no ghost', () async {
final proxy = await _FreezableProxy.start(server.boundPort);
final other = (await _Client.join(server.boundPort, 'other')).$1;
final (first, welcome) = await _Client.join(proxy.port, 'alice');
final (first, welcome) = await _Client.join(proxy.port, 'alice', client: alice, seq: 1);
expect(welcome.resumed, isFalse);
expect(welcome.instance, server.hub.instance);
expect(welcome.proto, RelayConstants.protoMax);
expect(welcome.grace, RelayConstants.graceSecs);
first.send(const ReadyIntent(ready: true).toJson());
await _until(() => server.hub.engine.members[welcome.token]?.ready == true,
'ready');
await _until(() => server.hub.engine.members[welcome.token]?.ready == true, 'ready');
proxy.frozen = true; // unclean drop: the server's socket stays open
final (second, again) =
await _Client.join(server.boundPort, 'alice', resume: welcome.token);
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 2);
expect(again.resumed, isTrue);
expect(again.memberId, welcome.memberId);
expect(again.token, welcome.token,
reason: 'same token keeps the media URL valid');
expect(server.hub.engine.members.length, 2,
reason: 'alice once (taken over) plus other, no ghost');
expect(server.hub.engine.members[welcome.token]!.ready, isTrue,
reason: 'ready survives the takeover');
// The frozen old socket closing later must not remove the new one.
expect(again.token, welcome.token, reason: 'same token keeps the media URL valid');
expect(server.hub.engine.members.length, 2, reason: 'alice once (taken over) plus other, no ghost');
expect(server.hub.engine.members[welcome.token]!.ready, isTrue, reason: 'ready survives the takeover');
// The frozen old socket closing later must not touch the member.
await proxy.close();
await Future.delayed(const Duration(seconds: 2));
expect(server.hub.engine.members.containsKey(welcome.token), isTrue);
expect(server.hub.engine.members[welcome.token]!.presence, isNot(Presence.gone));
expect(server.hub.sockets.containsKey(welcome.token), isTrue);
second.send(const ChatMessage(text: 'still here').toJson());
await _until(
() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
await _until(() => other.frames.any((f) => f['t'] == 'chat' && f['text'] == 'still here'),
'chat from the resumed member');
await other.ws.close();
await second.ws.close();
});
test('unknown resume token joins as a new member', () async {
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
expect(wa.token, isNot('nope'));
test('a takeover tells the older socket: err replaced, then its close code', () async {
final (first, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
final (second, again) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 1);
expect([again.resumed, again.memberId, again.token], [true, welcome.memberId, welcome.token],
reason: 'the same seq takes over too');
expect(await first.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.replaced);
final err = first.frames.lastWhere((f) => f['t'] == 'err');
expect(err['code'], 'replaced');
// Frames from the old socket (had it stayed open) no longer count.
expect(server.hub.sockets[welcome.token], isNotNull);
await second.ws.close();
});
test("an older hello than the member's gets err stale and is closed", () async {
final (current, welcome) = await _Client.join(server.boundPort, 'alice', client: alice, seq: 5);
final older = await _Client.dial(server.boundPort, 'alice', client: alice, seq: 4);
expect(await older.closed.future.timeout(const Duration(seconds: 5)), CloseCodes.stale);
expect(older.frames.single['code'], 'stale');
expect(server.hub.sockets.containsKey(welcome.token), isTrue, reason: 'the current one stays');
expect(server.hub.engine.members, hasLength(1));
await current.ws.close();
});
test('without a client id, or with another, it is a new member', () async {
final (a, wa) = await _Client.join(server.boundPort, 'a');
final (b, wb) = await _Client.join(server.boundPort, 'a');
final (c, wc) = await _Client.join(server.boundPort, 'a', client: 'someOtherInstallId0001');
expect({wa.memberId, wb.memberId, wc.memberId}, hasLength(3));
expect([wa.resumed, wb.resumed, wc.resumed], [false, false, false]);
await a.ws.close();
await b.ws.close();
await c.ws.close();
});
test('ping gets a pong with the client stamp and the server clock', () async {