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:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:20:18 +03:00
1 parent 0669ec0fc2
commit d8d487c189
28 files changed
+1592 -237

No files matched your search

+1
View File
@@ -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';
+45
View File
@@ -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()));
}
+5 -1
View File
@@ -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;
+75 -7
View File
@@ -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'),
);
}
}
+90 -17
View File
@@ -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,
);
}
}
+63
View File
@@ -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));
});
});
}
+135
View File
@@ -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);
});
});
}
+50 -14
View File
@@ -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(
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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', () {
+171
View File
@@ -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();
});
}