Sync fixes: clean seeks, clock offset, end of file, 2.5 min read-ahead (proto 6)
- User seeks are intercepted at the player (SyncPlayer), previewed locally, sent once 300 ms after the drag ends; own echoes are matched by seekSeq and never re-applied; the landed check can't reapply a stale target. 1 s position poll removed. - ping/pong clock offset (min-RTT ClockSync); positions use server time. - Reports carry duration; buffer gate is min(4 s, remaining - 2 s); new finished transport instead of a HELD stall at the end. - cache/readahead 150 s, 256 MiB demuxer cache, pins read back. - Resumed reports put a stale ready member back in the set; hello.resume takes over the old member after an unclean drop (no ghost). - Docs: roadmap rendering findings (HW path works; shaders verified). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0669ec0fc2
commit
d8d487c189
28 files changed
+1592
-237
No files matched your search
@@ -1,6 +1,7 @@
|
||||
export 'src/constants.dart';
|
||||
export 'src/errors.dart';
|
||||
export 'src/logging.dart';
|
||||
export 'src/clock_sync.dart';
|
||||
export 'src/config.dart';
|
||||
export 'src/protocol.dart';
|
||||
export 'src/media_source.dart';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/// Estimates how far the server's clock is from ours (NTP style).
|
||||
///
|
||||
/// Each ping/pong round trip gives one sample: the client sent at `t0`, the
|
||||
/// server stamped `s`, the reply arrived at `t3` (client clock). Assuming the
|
||||
/// two legs take equally long, `offset = s - (t0 + t3) / 2` with an error of
|
||||
/// at most `rtt / 2`. The sample with the smallest round trip among the last
|
||||
/// [window] is the most trustworthy, so that one is used.
|
||||
class ClockSync {
|
||||
final int window;
|
||||
final List<({int rttMs, double offsetMs})> _samples = [];
|
||||
|
||||
ClockSync({this.window = 16});
|
||||
|
||||
/// Server clock minus local clock, in ms; 0 until the first sample.
|
||||
double get offsetMs {
|
||||
if (_samples.isEmpty) return 0;
|
||||
var best = _samples.first;
|
||||
for (final s in _samples) {
|
||||
if (s.rttMs < best.rttMs) best = s;
|
||||
}
|
||||
return best.offsetMs;
|
||||
}
|
||||
|
||||
/// Round trip of the sample in use, null without samples.
|
||||
int? get rttMs {
|
||||
if (_samples.isEmpty) return null;
|
||||
return _samples.map((s) => s.rttMs).reduce((a, b) => a < b ? a : b);
|
||||
}
|
||||
|
||||
bool get hasSample => _samples.isNotEmpty;
|
||||
|
||||
/// [sentMs] and [receivedMs] are local epoch ms, [serverMs] the server's.
|
||||
void add({required int sentMs, required int serverMs, required int receivedMs}) {
|
||||
final rtt = receivedMs - sentMs;
|
||||
if (rtt < 0) return;
|
||||
_samples.add((rttMs: rtt, offsetMs: serverMs - (sentMs + receivedMs) / 2));
|
||||
if (_samples.length > window) _samples.removeAt(0);
|
||||
}
|
||||
|
||||
void reset() => _samples.clear();
|
||||
|
||||
/// The server's clock now, given the local clock [local].
|
||||
DateTime serverNow(DateTime local) =>
|
||||
local.add(Duration(microseconds: (offsetMs * 1000).round()));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract class RelayConstants {
|
||||
static const int protoVersion = 5;
|
||||
static const int protoVersion = 6;
|
||||
|
||||
static const int chunkBytes = 256 * 1024;
|
||||
static const int creditWindowChunks = 8;
|
||||
@@ -10,6 +10,10 @@ abstract class RelayConstants {
|
||||
static const double slowDownThresholdSecs = 1.5;
|
||||
static const double slowDownRate = 0.95;
|
||||
static const double minBufferSecs = 4.0;
|
||||
/// Near the end the buffer requirement is what is left minus this, so a
|
||||
/// client slightly ahead of the clock (or whose last packet ends a bit
|
||||
/// early) still passes.
|
||||
static const double endSlackSecs = 2.0;
|
||||
static const double resumeHysteresisSecs = 4.0;
|
||||
|
||||
static const int stateHeartbeatSecs = 1;
|
||||
|
||||
@@ -70,12 +70,17 @@ class HelloMessage {
|
||||
/// 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;
|
||||
const HelloMessage(
|
||||
{required this.proto,
|
||||
required this.secret,
|
||||
required this.user,
|
||||
required this.media,
|
||||
this.hostKey = ''});
|
||||
this.hostKey = '',
|
||||
this.resume = ''});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'hello',
|
||||
@@ -84,6 +89,7 @@ class HelloMessage {
|
||||
'user': user.toJson(),
|
||||
'media': media.toJson(),
|
||||
if (hostKey.isNotEmpty) 'hostKey': hostKey,
|
||||
if (resume.isNotEmpty) 'resume': resume,
|
||||
};
|
||||
|
||||
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
||||
@@ -97,6 +103,7 @@ class HelloMessage {
|
||||
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?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,13 +219,19 @@ class ForceIntent {
|
||||
|
||||
class SeekIntent {
|
||||
final double position;
|
||||
const SeekIntent({required this.position});
|
||||
/// Client-chosen, increasing per connection; echoed in `state.seekSeq` so
|
||||
/// the sender recognises its own seek.
|
||||
final int seq;
|
||||
const SeekIntent({required this.position, this.seq = 0});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'seek', 'position': position};
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'seek', 'position': position, if (seq != 0) 'seq': seq};
|
||||
|
||||
factory SeekIntent.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'seek') throw const ProtocolException('not a seek intent');
|
||||
return SeekIntent(position: finiteDouble(json['position'], 'position'));
|
||||
return SeekIntent(
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
seq: finiteInt(json['seq'], 'seq'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +253,25 @@ class ReportMessage {
|
||||
final double position;
|
||||
final double buffered;
|
||||
final int eventId;
|
||||
const ReportMessage({required this.position, required this.buffered, required this.eventId});
|
||||
/// Length of the open media in seconds, 0 while unknown.
|
||||
final double duration;
|
||||
/// The `mediaId` the client has open; [duration] belongs to it.
|
||||
final int mediaId;
|
||||
const ReportMessage(
|
||||
{required this.position,
|
||||
required this.buffered,
|
||||
required this.eventId,
|
||||
this.duration = 0,
|
||||
this.mediaId = 0});
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'t': 'report', 'position': position, 'buffered': buffered, 'eventId': eventId};
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'report',
|
||||
'position': position,
|
||||
'buffered': buffered,
|
||||
'eventId': eventId,
|
||||
if (duration > 0) 'duration': duration,
|
||||
if (mediaId != 0) 'mediaId': mediaId,
|
||||
};
|
||||
|
||||
factory ReportMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'report') throw const ProtocolException('not a report');
|
||||
@@ -251,10 +279,40 @@ class ReportMessage {
|
||||
position: finiteDouble(json['position'], 'position'),
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||
duration: finiteDouble(json['duration'], 'duration'),
|
||||
mediaId: finiteInt(json['mediaId'], 'mediaId'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clock probe: the client sends its own clock in [clientMs]; the server
|
||||
/// answers with a [PongMessage] carrying it back plus its clock.
|
||||
class PingMessage {
|
||||
final int clientMs;
|
||||
const PingMessage({required this.clientMs});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'ping', 'c': clientMs};
|
||||
|
||||
factory PingMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'ping') throw const ProtocolException('not a ping');
|
||||
return PingMessage(clientMs: finiteInt(json['c'], 'c'));
|
||||
}
|
||||
}
|
||||
|
||||
class PongMessage {
|
||||
final int clientMs;
|
||||
final int serverMs;
|
||||
const PongMessage({required this.clientMs, required this.serverMs});
|
||||
|
||||
Map<String, Object?> toJson() => {'t': 'pong', 'c': clientMs, 's': serverMs};
|
||||
|
||||
factory PongMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'pong') throw const ProtocolException('not a pong');
|
||||
return PongMessage(
|
||||
clientMs: finiteInt(json['c'], 'c'), serverMs: finiteInt(json['s'], 's'));
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMessage {
|
||||
final String text;
|
||||
const ChatMessage({required this.text});
|
||||
@@ -314,6 +372,10 @@ class StateBroadcast {
|
||||
final int mediaId;
|
||||
final Map<String, Object?>? media;
|
||||
final List<MemberView> members;
|
||||
/// Member id and seq of the seek that made [eventId]; null when the
|
||||
/// current event is not a seek.
|
||||
final String? seekBy;
|
||||
final int? seekSeq;
|
||||
const StateBroadcast({
|
||||
required this.eventId,
|
||||
required this.transport,
|
||||
@@ -322,6 +384,8 @@ class StateBroadcast {
|
||||
required this.mediaId,
|
||||
this.media,
|
||||
required this.members,
|
||||
this.seekBy,
|
||||
this.seekSeq,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -333,6 +397,8 @@ class StateBroadcast {
|
||||
'mediaId': mediaId,
|
||||
if (media != null) 'media': media,
|
||||
'members': members.map((m) => m.toJson()).toList(),
|
||||
if (seekBy != null) 'seekBy': seekBy,
|
||||
if (seekSeq != null) 'seekSeq': seekSeq,
|
||||
};
|
||||
|
||||
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||
@@ -352,6 +418,8 @@ class StateBroadcast {
|
||||
.map((m) => MemberView.fromJson(Map<String, Object?>.from(m)))
|
||||
.toList()
|
||||
: const [],
|
||||
seekBy: json['seekBy'] as String?,
|
||||
seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'protocol.dart';
|
||||
|
||||
typedef SyncClock = DateTime Function();
|
||||
|
||||
enum Transport { playing, held }
|
||||
enum Transport { playing, held, finished }
|
||||
|
||||
enum Correction { none, slowDown, seek }
|
||||
|
||||
@@ -66,6 +66,10 @@ class SyncEngine {
|
||||
int mediaId = 1;
|
||||
HelloMedia? media;
|
||||
int _nextId = 1;
|
||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||
String? _seekBy;
|
||||
int _seekSeq = 0;
|
||||
int _seekEventId = -1;
|
||||
|
||||
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
|
||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||
@@ -84,6 +88,36 @@ class SyncEngine {
|
||||
bool isStale(EngineMember member, [DateTime? at]) =>
|
||||
(at ?? now).difference(member.lastSeen) > staleAfter;
|
||||
|
||||
/// Media length in seconds, null while nobody has reported it.
|
||||
double? get duration {
|
||||
final d = media?.duration ?? 0;
|
||||
return d > 0 ? d : null;
|
||||
}
|
||||
|
||||
/// Buffer every in-set member needs: [RelayConstants.minBufferSecs], or
|
||||
/// less near the end where that much is not left to buffer.
|
||||
double requiredBufferSecs([DateTime? at]) {
|
||||
final d = duration;
|
||||
const full = RelayConstants.minBufferSecs;
|
||||
if (d == null) return full;
|
||||
final left = d - positionAt(at) - RelayConstants.endSlackSecs;
|
||||
return left.clamp(0.0, full).toDouble();
|
||||
}
|
||||
|
||||
/// Any frame from a member: fresh again, and a ready member the stale
|
||||
/// sweep took out of the set goes back in (ready && !inSet only arises
|
||||
/// from that sweep).
|
||||
void _seen(EngineMember member) {
|
||||
member.lastSeen = now;
|
||||
if (member.ready && !member.inSet) member.inSet = true;
|
||||
}
|
||||
|
||||
/// Heartbeat: stale sweep, end of media.
|
||||
void tick() {
|
||||
sweepStale();
|
||||
evaluate();
|
||||
}
|
||||
|
||||
String applyJoin(String token, String name, String avatar) {
|
||||
final id = 'm${_nextId++}';
|
||||
members[token] =
|
||||
@@ -92,6 +126,14 @@ class SyncEngine {
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final removed = members.remove(token);
|
||||
if (removed == null) return null;
|
||||
@@ -110,7 +152,7 @@ class SyncEngine {
|
||||
void applyReady(String token, bool ready, {String source = 'manual'}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
if (member.ready == ready && member.inSet) {
|
||||
evaluate();
|
||||
return;
|
||||
@@ -153,15 +195,23 @@ class SyncEngine {
|
||||
evaluate();
|
||||
}
|
||||
|
||||
void applySeek(String token, double seekPosition) {
|
||||
void applySeek(String token, double seekPosition, {int seq = 0}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
if (!seekPosition.isFinite || seekPosition < 0) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
position = seekPosition;
|
||||
serverTime = now;
|
||||
eventId++;
|
||||
_seekBy = member.id;
|
||||
_seekSeq = seq;
|
||||
_seekEventId = eventId;
|
||||
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
|
||||
// Seeking back from the end: held, and resumes once the gate passes.
|
||||
if (transport == Transport.finished) {
|
||||
transport = Transport.held;
|
||||
evaluate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false (no state change) for media with non-finite numbers.
|
||||
@@ -183,12 +233,19 @@ class SyncEngine {
|
||||
return true;
|
||||
}
|
||||
|
||||
void applyReport(String token, double reportPosition, double buffered, int reportEventId) {
|
||||
void applyReport(String token, double reportPosition, double buffered, int reportEventId,
|
||||
{double duration = 0, int mediaId = 0}) {
|
||||
final member = members[token];
|
||||
if (member == null) return;
|
||||
if (!reportPosition.isFinite) return;
|
||||
member.lastSeen = now;
|
||||
_seen(member);
|
||||
member.buffered = !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 = HelloMedia(name: m.name, size: m.size, duration: duration, url: m.url);
|
||||
}
|
||||
if (reportEventId < eventId) return;
|
||||
evaluate();
|
||||
}
|
||||
@@ -221,10 +278,21 @@ class SyncEngine {
|
||||
|
||||
void evaluate() {
|
||||
final t = now;
|
||||
if (transport == Transport.finished) return;
|
||||
final need = requiredBufferSecs(t);
|
||||
if (transport == Transport.playing) {
|
||||
final d = duration;
|
||||
if (d != null && positionAt(t) >= d) {
|
||||
position = d;
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
transport = Transport.finished;
|
||||
feed.add(FeedMessage(actor: 'server', action: 'finished', at: t));
|
||||
return;
|
||||
}
|
||||
final blocked = members.values
|
||||
.where((m) => m.inSet)
|
||||
.any((m) => !m.ready || m.buffered < RelayConstants.minBufferSecs || isStale(m, t));
|
||||
.any((m) => !m.ready || m.buffered < need || isStale(m, t));
|
||||
if (blocked) {
|
||||
position = positionAt(t);
|
||||
serverTime = t;
|
||||
@@ -240,7 +308,7 @@ class SyncEngine {
|
||||
members.values.where((m) => !isStale(m, t)).length;
|
||||
final quorum = inSet.length >= 2 || freshCount <= 1;
|
||||
final allGood = inSet.every(
|
||||
(m) => m.ready && m.buffered >= RelayConstants.minBufferSecs && !isStale(m, t));
|
||||
(m) => m.ready && m.buffered >= need && !isStale(m, t));
|
||||
if (allGood && quorum) {
|
||||
serverTime = t;
|
||||
eventId++;
|
||||
@@ -249,13 +317,18 @@ class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
StateBroadcast broadcast() => StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport == Transport.playing ? 'playing' : 'held',
|
||||
position: position,
|
||||
serverTime: serverTime,
|
||||
mediaId: mediaId,
|
||||
media: media?.toJson(),
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
);
|
||||
StateBroadcast broadcast() {
|
||||
final bySeek = _seekEventId == eventId;
|
||||
return StateBroadcast(
|
||||
eventId: eventId,
|
||||
transport: transport.name,
|
||||
position: position,
|
||||
serverTime: serverTime,
|
||||
mediaId: mediaId,
|
||||
media: media?.toJson(),
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
seekBy: bySeek ? _seekBy : null,
|
||||
seekSeq: bySeek ? _seekSeq : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -130,4 +130,67 @@ void main() {
|
||||
expect(RangeRequest.parse('bytes=5000-6000', 1000), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ClockSync', () {
|
||||
test('uses the min-RTT sample, not the average', () {
|
||||
final c = ClockSync();
|
||||
expect(c.offsetMs, 0);
|
||||
// Server is 5000 ms ahead. Symmetric 10 ms trip: exact.
|
||||
c.add(sentMs: 1000, serverMs: 6010, receivedMs: 1020);
|
||||
// Asymmetric slow trips (300 ms out, 10 back) would skew an average.
|
||||
c.add(sentMs: 2000, serverMs: 7300, receivedMs: 2310);
|
||||
c.add(sentMs: 3000, serverMs: 8400, receivedMs: 3410);
|
||||
expect(c.rttMs, 20);
|
||||
expect(c.offsetMs, 5000);
|
||||
final local = DateTime.utc(2026, 1, 1);
|
||||
expect(c.serverNow(local), local.add(const Duration(seconds: 5)));
|
||||
});
|
||||
|
||||
test('old samples age out of the window', () {
|
||||
final c = ClockSync(window: 2);
|
||||
c.add(sentMs: 0, serverMs: 105, receivedMs: 10); // offset 100
|
||||
c.add(sentMs: 0, serverMs: 250, receivedMs: 100);
|
||||
c.add(sentMs: 0, serverMs: 250, receivedMs: 100);
|
||||
expect(c.offsetMs, 200);
|
||||
});
|
||||
|
||||
test('negative round trip ignored', () {
|
||||
final c = ClockSync()..add(sentMs: 10, serverMs: 0, receivedMs: 5);
|
||||
expect(c.hasSample, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('proto 6 fields', () {
|
||||
Map<String, Object?> rt(Map<String, Object?> j) =>
|
||||
Map<String, Object?>.from(jsonDecode(jsonEncode(j)) as Map);
|
||||
|
||||
test('round-trip', () {
|
||||
final hello = HelloMessage.fromJson(rt(const HelloMessage(
|
||||
proto: 6,
|
||||
secret: '',
|
||||
user: HelloUser(name: 'a'),
|
||||
media: HelloMedia(name: '', size: 0, duration: 0),
|
||||
resume: 'tok')
|
||||
.toJson()));
|
||||
expect(hello.resume, 'tok');
|
||||
final seek = SeekIntent.fromJson(rt(const SeekIntent(position: 3, seq: 9).toJson()));
|
||||
expect(seek.seq, 9);
|
||||
final report = ReportMessage.fromJson(rt(const ReportMessage(
|
||||
position: 1, buffered: 2, eventId: 3, duration: 1440.5, mediaId: 4)
|
||||
.toJson()));
|
||||
expect(report.duration, 1440.5);
|
||||
expect(report.mediaId, 4);
|
||||
final pong = PongMessage.fromJson(
|
||||
rt(const PongMessage(clientMs: 5, serverMs: 7).toJson()));
|
||||
expect((pong.clientMs, pong.serverMs), (5, 7));
|
||||
expect(PingMessage.fromJson(rt(const PingMessage(clientMs: 5).toJson())).clientMs, 5);
|
||||
});
|
||||
|
||||
test('older frames without the new fields still parse', () {
|
||||
expect(SeekIntent.fromJson({'t': 'seek', 'position': 1}).seq, 0);
|
||||
final r = ReportMessage.fromJson(
|
||||
{'t': 'report', 'position': 1, 'buffered': 2, 'eventId': 3});
|
||||
expect((r.duration, r.mediaId), (0.0, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -402,4 +402,139 @@ void main() {
|
||||
expect(rpBack.eventId, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('stale ready rejoins', () {
|
||||
test('ready member swept while stale is back in the set when reports resume', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applyReady('a', true);
|
||||
engine.applyReady('b', true);
|
||||
buffer('a', 6.0);
|
||||
buffer('b', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
// bob goes silent past staleAfter; alice keeps reporting.
|
||||
for (var i = 0; i < 16; i++) {
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
buffer('a', 6.0);
|
||||
engine.sweepStale();
|
||||
}
|
||||
expect(engine.members['b']!.inSet, isFalse);
|
||||
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);
|
||||
});
|
||||
|
||||
test('unready member does not rejoin on reports', () {
|
||||
join('a', 'alice');
|
||||
buffer('a', 6.0);
|
||||
expect(engine.members['a']!.inSet, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('end of file', () {
|
||||
setUp(() {
|
||||
engine.media = const HelloMedia(name: 'f', size: 1, duration: 20);
|
||||
});
|
||||
|
||||
void playTo(double secs, {required double Function(double pos) buffered}) {
|
||||
while (engine.positionAt() < secs) {
|
||||
t = t.add(const Duration(milliseconds: 500));
|
||||
final pos = engine.positionAt();
|
||||
engine.applyReport('a', pos, buffered(pos), engine.eventId);
|
||||
engine.tick();
|
||||
}
|
||||
}
|
||||
|
||||
test('last seconds play out with less than 4 s left, then finished', () {
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
// mpv's read-ahead cannot exceed what is left of the file.
|
||||
double left(double pos) => (20 - pos).clamp(0, 6).toDouble();
|
||||
playTo(19.5, buffered: left);
|
||||
expect(engine.transport, Transport.playing);
|
||||
t = t.add(const Duration(seconds: 1));
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
expect(engine.positionAt(), 20);
|
||||
expect(engine.broadcast().transport, 'finished');
|
||||
// Finished never auto-resumes, however ready everyone is.
|
||||
t = t.add(const Duration(seconds: 5));
|
||||
buffer('a', 0);
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
});
|
||||
|
||||
test('seek back from finished resumes for ready members', () {
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
t = t.add(const Duration(seconds: 21));
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
engine.applySeek('a', 5);
|
||||
buffer('a', 6.0);
|
||||
expect(engine.transport, Transport.playing);
|
||||
expect(engine.positionAt(), 5);
|
||||
});
|
||||
|
||||
test('swap after finished resets to held', () {
|
||||
join('a', 'alice');
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
t = t.add(const Duration(seconds: 21));
|
||||
engine.tick();
|
||||
expect(engine.transport, Transport.finished);
|
||||
engine.applySwap(
|
||||
'a', const HelloMedia(name: 'g', size: 1, duration: 0, url: 'http://x/g'));
|
||||
expect(engine.transport, Transport.held);
|
||||
expect(engine.media!.duration, 0);
|
||||
});
|
||||
|
||||
test('duration learned from reports for the current media only', () {
|
||||
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
|
||||
join('a', 'alice');
|
||||
engine.applyReport('a', 0, 1, engine.eventId,
|
||||
duration: 99, mediaId: engine.mediaId - 1);
|
||||
expect(engine.media!.duration, 0);
|
||||
engine.applyReport('a', 0, 1, engine.eventId,
|
||||
duration: 42.5, mediaId: engine.mediaId);
|
||||
expect(engine.media!.duration, 42.5);
|
||||
expect(engine.broadcast().media!['duration'], 42.5);
|
||||
});
|
||||
|
||||
test('requirement is min(4 s, remaining) with slack', () {
|
||||
join('a', 'alice');
|
||||
engine.applySeek('a', 18);
|
||||
expect(engine.requiredBufferSecs(), lessThan(RelayConstants.minBufferSecs));
|
||||
engine.applySeek('a', 5);
|
||||
expect(engine.requiredBufferSecs(), RelayConstants.minBufferSecs);
|
||||
engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
|
||||
expect(engine.requiredBufferSecs(), RelayConstants.minBufferSecs);
|
||||
});
|
||||
});
|
||||
|
||||
group('seek attribution', () {
|
||||
test('state carries who sent the seek and its seq, only for that event', () {
|
||||
join('a', 'alice');
|
||||
engine.applySeek('a', 10, seq: 7);
|
||||
var s = engine.broadcast();
|
||||
expect(s.seekBy, 'm1');
|
||||
expect(s.seekSeq, 7);
|
||||
final back = StateBroadcast.fromJson(Map<String, Object?>.from(
|
||||
jsonDecode(jsonEncode(s.toJson())) as Map));
|
||||
expect(back.seekBy, 'm1');
|
||||
expect(back.seekSeq, 7);
|
||||
// A later event (gate) is not attributed to the seek.
|
||||
engine.applyReady('a', true);
|
||||
buffer('a', 6.0);
|
||||
s = engine.broadcast();
|
||||
expect(s.transport, 'playing');
|
||||
expect(s.seekBy, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,15 @@ class ServerHub {
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
/// still known and not kicked.
|
||||
EngineMember? resumable(String token) {
|
||||
if (token.isEmpty) return null;
|
||||
final viewer = session.tokens[token];
|
||||
if (viewer == null || viewer.kicked) return null;
|
||||
return engine.members[token];
|
||||
}
|
||||
|
||||
Session get session => sessions.getOrCreate('default', expectedSecret);
|
||||
|
||||
void broadcastState() {
|
||||
@@ -90,6 +99,13 @@ 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) {
|
||||
if (!identical(sockets[token], ws)) return;
|
||||
forgetSocket(token);
|
||||
}
|
||||
|
||||
void forgetSocket(String token) {
|
||||
engine.applyLeave(token);
|
||||
final viewer = session.tokens[token];
|
||||
@@ -175,7 +191,7 @@ void mountSyncRoute(
|
||||
case 'seek':
|
||||
if (token == null) return;
|
||||
final intent = SeekIntent.fromJson(decoded);
|
||||
hub.engine.applySeek(token, intent.position);
|
||||
hub.engine.applySeek(token, intent.position, seq: intent.seq);
|
||||
hub.changed();
|
||||
case 'swap':
|
||||
if (token == null) return;
|
||||
@@ -219,8 +235,15 @@ void mountSyncRoute(
|
||||
if (token == null) return;
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
hub.engine.applyReport(
|
||||
token, report.position, report.buffered, report.eventId);
|
||||
hub.broadcastState();
|
||||
token, report.position, report.buffered, report.eventId,
|
||||
duration: report.duration, mediaId: report.mediaId);
|
||||
hub.changed();
|
||||
case 'ping':
|
||||
final ping = PingMessage.fromJson(decoded);
|
||||
ws.trySendText(jsonEncode(PongMessage(
|
||||
clientMs: ping.clientMs,
|
||||
serverMs: hub.engine.now.millisecondsSinceEpoch)
|
||||
.toJson()));
|
||||
case 'chat':
|
||||
final chat = ChatMessage.fromJson(decoded);
|
||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||
@@ -258,13 +281,13 @@ void mountSyncRoute(
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
if (token != null) hub.forgetSocket(token);
|
||||
if (token != null) hub.forgetSocketOf(token, ws);
|
||||
},
|
||||
onDone: () {
|
||||
if (!firstText.isCompleted) {
|
||||
firstText.completeError(StateError('closed before hello'));
|
||||
} else if (token != null) {
|
||||
hub.forgetSocket(token);
|
||||
hub.forgetSocketOf(token, ws);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -300,23 +323,36 @@ void mountSyncRoute(
|
||||
await close();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
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)');
|
||||
} 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();
|
||||
return;
|
||||
}
|
||||
memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||
}
|
||||
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
session.tokens[activeToken]!.onKick = (reason) async {
|
||||
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
await ws.tryClose();
|
||||
hub.forgetSocket(activeToken);
|
||||
hub.forgetSocketOf(activeToken, ws);
|
||||
};
|
||||
final media = session.media;
|
||||
final welcome = WelcomeMessage(
|
||||
|
||||
@@ -101,7 +101,7 @@ class SyncServer {
|
||||
);
|
||||
_startedAt = DateTime.now();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
hub.engine.sweepStale();
|
||||
hub.engine.tick();
|
||||
hub.sweepClosedSockets();
|
||||
hub.dropSilentSockets();
|
||||
hub.broadcastState();
|
||||
|
||||
@@ -80,8 +80,8 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto 5: state broadcast + hello hostKey', () {
|
||||
expect(RelayConstants.protoVersion, 5);
|
||||
test('proto 6: state broadcast + hello hostKey', () {
|
||||
expect(RelayConstants.protoVersion, 6);
|
||||
});
|
||||
|
||||
test('welcome round-trips with token + member id', () {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// TCP relay that can go silent without closing: the server sees a socket
|
||||
/// that is open but never talks again (an unclean drop it cannot detect).
|
||||
class _FreezableProxy {
|
||||
final ServerSocket _listener;
|
||||
final List<Socket> _open = [];
|
||||
bool frozen = false;
|
||||
_FreezableProxy._(this._listener);
|
||||
|
||||
int get port => _listener.port;
|
||||
|
||||
static Future<_FreezableProxy> start(int target) async {
|
||||
final l = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final p = _FreezableProxy._(l);
|
||||
l.listen((client) async {
|
||||
final upstream = await Socket.connect(InternetAddress.loopbackIPv4, target);
|
||||
p._open..add(client)..add(upstream);
|
||||
client.listen((d) {
|
||||
if (!p.frozen) upstream.add(d);
|
||||
}, onError: (_) {}, onDone: () {});
|
||||
upstream.listen((d) {
|
||||
if (!p.frozen) client.add(d);
|
||||
}, onError: (_) {}, onDone: () {});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
for (final s in _open) {
|
||||
s.destroy();
|
||||
}
|
||||
await _listener.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _Client {
|
||||
final WebSocket ws;
|
||||
final frames = <Map<String, Object?>>[];
|
||||
final _welcome = Completer<Map<String, Object?>>();
|
||||
_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: () {});
|
||||
}
|
||||
|
||||
StateBroadcast? get lastState {
|
||||
for (final f in frames.reversed) {
|
||||
if (f['t'] == 'state') return StateBroadcast.fromJson(f);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
|
||||
|
||||
static Future<(_Client, WelcomeMessage)> join(int port, String name,
|
||||
{String resume = ''}) 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));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _until(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 10)}) async {
|
||||
final end = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(end)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late SyncServer server;
|
||||
setUp(() async {
|
||||
server = SyncServer(const AppConfig(port: 0, secret: 'w', name: 'wave'));
|
||||
await server.start();
|
||||
});
|
||||
tearDown(() => server.stop());
|
||||
|
||||
test('reconnect after an unclean drop takes over the old member, 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');
|
||||
first.send(const ReadyIntent(ready: true).toJson());
|
||||
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);
|
||||
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.
|
||||
await proxy.close();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
expect(server.hub.engine.members.containsKey(welcome.token), isTrue);
|
||||
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'),
|
||||
'chat from the resumed member');
|
||||
await other.ws.close();
|
||||
await second.ws.close();
|
||||
});
|
||||
|
||||
test('unknown or kicked resume token joins as a new member', () async {
|
||||
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
|
||||
expect(wa.token, isNot('nope'));
|
||||
final (b, wb) = await _Client.join(server.boundPort, 'b');
|
||||
await server.hub.kickById(wb.memberId, 'bye');
|
||||
final (c, wc) = await _Client.join(server.boundPort, 'b', resume: wb.token);
|
||||
expect(wc.token, isNot(wb.token));
|
||||
expect(wc.memberId, isNot(wb.memberId));
|
||||
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 {
|
||||
final (c, _) = await _Client.join(server.boundPort, 'p');
|
||||
final before = DateTime.now().millisecondsSinceEpoch;
|
||||
c.send(const PingMessage(clientMs: 1234).toJson());
|
||||
await _until(() => c.frames.any((f) => f['t'] == 'pong'), 'pong');
|
||||
final pong = PongMessage.fromJson(c.frames.firstWhere((f) => f['t'] == 'pong'));
|
||||
expect(pong.clientMs, 1234);
|
||||
expect(pong.serverMs, greaterThanOrEqualTo(before));
|
||||
await c.ws.close();
|
||||
});
|
||||
|
||||
test('seek echo carries seekBy/seekSeq; report teaches the duration',
|
||||
() async {
|
||||
final (c, w) = await _Client.join(server.boundPort, 's');
|
||||
c.send(const SeekIntent(position: 12, seq: 3).toJson());
|
||||
await _until(() => c.lastState?.seekSeq == 3, 'seek echo');
|
||||
expect(c.lastState!.seekBy, w.memberId);
|
||||
server.hub.engine.media = const HelloMedia(name: 'f', size: 1, duration: 0);
|
||||
c.send(ReportMessage(
|
||||
position: 12,
|
||||
buffered: 5,
|
||||
eventId: c.lastState!.eventId,
|
||||
duration: 1440,
|
||||
mediaId: c.lastState!.mediaId)
|
||||
.toJson());
|
||||
await _until(() => c.lastState?.media?['duration'] == 1440, 'duration');
|
||||
await c.ws.close();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user