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,
);
}
}