Initial commit: watch-together phases 0-4 + presence hardening, internet-tested

This commit is contained in:
foxyz committed 2026-09-12 00:13:43 +03:00
commit 427d851a5b
101 files changed
+11326

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
import 'dart:io';
import 'package:sync_relay_core/core.dart';
int _failures = 0;
void check(String name, bool ok, [String detail = '']) {
// ignore: avoid_print
print('${ok ? 'PASS' : 'FAIL'} $name${detail.isEmpty ? '' : ' ($detail)'}');
if (!ok) _failures++;
}
class FakeViewer {
final RoomEngine engine;
final String token;
double ownPosition;
double buffered;
final double lagSecs;
int seenEventId = 0;
FakeViewer(this.engine, this.token,
{this.ownPosition = 0, this.buffered = 6.0, this.lagSecs = 0});
void tick() {
seenEventId = engine.eventId;
final edge = engine.positionAt();
if ((ownPosition - edge).abs() > 0.5) {
ownPosition = edge - lagSecs;
if (ownPosition < 0) ownPosition = 0;
} else {
ownPosition = edge - lagSecs;
if (ownPosition < 0) ownPosition = 0;
}
engine.applyReport(token, ownPosition, buffered, seenEventId);
}
}
Future<void> main(List<String> args) async {
var simTime = DateTime.utc(2026, 1, 1);
final engine = RoomEngine(clock: () => simTime);
engine.media = const HelloMedia(name: 'Movie.mkv', size: 4000000000, duration: 7200.0);
void step([int seconds = 1]) => simTime = simTime.add(Duration(seconds: seconds));
engine.applyJoin('a', 'alice', '');
engine.applyJoin('b', 'bob', '');
engine.applyJoin('c', 'cara', '');
for (final t in ['a', 'b', 'c']) {
engine.applyReady(t, true);
engine.applyReport(t, 0, 6.0, engine.eventId);
}
check('room playing after all ready+buffered', engine.transport == Transport.playing);
final alice = FakeViewer(engine, 'a');
final bob = FakeViewer(engine, 'b');
final cara = FakeViewer(engine, 'c', lagSecs: 8.0);
var viewers = [alice, bob, cara];
for (var s = 0; s < 3600; s++) {
step();
for (final v in viewers) {
v.tick();
}
}
final edge1h = engine.positionAt();
check('1h in, edge advanced ~3600s', (edge1h - 3600).abs() < 2.0, 'edge=$edge1h');
check('lagging cara never dragged clock', (engine.positionAt() - 3600).abs() < 2.0,
'edge=${engine.positionAt()}');
final eidBeforeSeek = engine.eventId;
engine.applySeek('a', edge1h + 600);
check('seek jumps edge once', (engine.positionAt() - (edge1h + 600)).abs() < 0.01);
check('seek bumps eventId', engine.eventId == eidBeforeSeek + 1);
engine.applyReport('c', edge1h - 8.0, 6.0, eidBeforeSeek);
check('stale-event report causes no ping-pong',
(engine.positionAt() - (edge1h + 600)).abs() < 0.5, 'edge=${engine.positionAt()}');
for (var s = 0; s < 1800; s++) {
step();
for (final v in viewers) {
v.tick();
}
}
engine.applyJoin('d', 'dan', '');
check('mid-play join does not halt room', engine.transport == Transport.playing);
final dan = FakeViewer(engine, 'd');
dan.ownPosition = engine.positionAt();
dan.tick();
engine.applyReady('d', true);
engine.applyReport('d', dan.ownPosition, 6.0, engine.eventId);
viewers.add(dan);
check('late joiner lands in set + in sync',
engine.members['d']!.inSet && (dan.ownPosition - engine.positionAt()).abs() < 1.0);
for (var s = 0; s < 600; s++) {
step();
for (final v in viewers) {
v.tick();
}
}
engine.applyReady('b', false);
final frozen = engine.positionAt();
check('unready pauses room', engine.transport == Transport.held);
for (var s = 0; s < 30; s++) {
step();
engine.applyReport('a', frozen, 6.0, engine.eventId);
engine.applyReport('c', frozen - 8.0, 6.0, engine.eventId);
engine.applyReport('d', frozen, 6.0, engine.eventId);
}
check('held edge frozen for 30s', (engine.positionAt() - frozen).abs() < 0.01);
engine.applyReady('b', true);
for (final v in viewers) {
engine.applyReport(v.token, frozen, 6.0, engine.eventId);
}
check('unpause resumes', engine.transport == Transport.playing);
check('resume aligned within RTT tolerance', (engine.positionAt() - frozen).abs() < 1.0,
'edge=${engine.positionAt()} frozen=$frozen');
for (var i = 0; i < 20; i++) {
engine.applyReady('c', false);
engine.applyReady('c', true);
engine.applyReport('c', engine.positionAt(), 6.0, engine.eventId);
}
check('post-flap room converges to playing', engine.transport == Transport.playing);
engine.applySwap('a', const HelloMedia(name: 'Ep02.mkv', size: 100, duration: 1200.0));
check('swap holds + resets edge', engine.transport == Transport.held && engine.positionAt() == 0);
check('swap clears sets', engine.members.values.every((m) => !m.inSet && !m.ready));
// ignore: avoid_print
print(_failures == 0 ? 'E2E ALL PASS' : 'E2E $_failures FAILURES');
exitCode = _failures == 0 ? 0 : 1;
}
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:sync_relay_core/core.dart';
int _failures = 0;
void check(String name, bool ok, [String detail = '']) {
// ignore: avoid_print
print('${ok ? 'PASS' : 'FAIL'} $name${detail.isEmpty ? '' : ' ($detail)'}');
if (!ok) _failures++;
}
bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Future<void> main(List<String> args) async {
var server = 'http://127.0.0.1:8080';
var secret = 'change-me';
var name = 'viewer';
String? expectFile;
for (var i = 0; i < args.length; i++) {
switch (args[i]) {
case '--server':
server = args[++i];
case '--secret':
secret = args[++i];
case '--name':
name = args[++i];
case '--file':
expectFile = args[++i];
}
}
final base = Uri.parse(server);
final wsUri = base.replace(scheme: base.scheme == 'https' ? 'wss' : 'ws', path: '/sync');
final ch = WebSocketChannel.connect(wsUri);
final firstCompleter = Completer<String>();
final sub = ch.stream.listen(
(m) {
if (!firstCompleter.isCompleted) firstCompleter.complete('$m');
},
onError: (Object e) {
if (!firstCompleter.isCompleted) firstCompleter.completeError(e);
},
);
ch.sink.add(jsonEncode({
't': 'hello',
'proto': RelayConstants.protoVersion,
'secret': secret,
'user': {'name': name},
'media': {'name': '', 'size': 0, 'duration': 0.0},
}));
late Map<String, Object?> welcome;
try {
final raw = await firstCompleter.future.timeout(const Duration(seconds: 10));
welcome = Map<String, Object?>.from(jsonDecode(raw) as Map);
} catch (e) {
check('hello -> welcome', false, '$e');
exitCode = 1;
return;
}
check('hello -> welcome', welcome['t'] == 'welcome', '${welcome['t']}');
final token = '${welcome['token']}';
check('token issued', token.length == 32, 'len=${token.length}');
final mediaUrl = welcome['mediaUrl'] as String?;
check('mediaUrl present', mediaUrl != null && mediaUrl.contains(token));
if (mediaUrl == null) {
exitCode = 1;
return;
}
final mediaUri = Uri.parse(mediaUrl);
Uint8List? expected;
if (expectFile != null) expected = await File(expectFile).readAsBytes();
final client = HttpClient();
try {
Future<(HttpClientResponse, List<int>)> get({String? range}) async {
final req = await client.getUrl(mediaUri);
if (range != null) req.headers.set('range', range);
final resp = await req.close();
final body = <int>[];
await for (final c in resp) {
body.addAll(c);
}
return (resp, body);
}
var (resp, body) = await get();
check('no-range -> 200', resp.statusCode == 200, '${resp.statusCode}');
check('accept-ranges', resp.headers.value('accept-ranges') == 'bytes');
if (expected != null) {
check('full body byte-exact', _bytesEqual(body, expected),
'got=${body.length} want=${expected.length}');
}
final size = expected?.length ?? int.parse(resp.headers.value('content-length') ?? '0');
check('size known', size > 0, 'size=$size');
(resp, body) = await get(range: 'bytes=0-99');
check('closed range -> 206', resp.statusCode == 206, '${resp.statusCode}');
check('content-range', resp.headers.value('content-range') == 'bytes 0-99/$size');
if (expected != null) check('closed bytes exact', _bytesEqual(body, expected.sublist(0, 100)));
(resp, body) = await get(range: 'bytes=${size - 1000}-');
check('open-ended -> 206', resp.statusCode == 206, '${resp.statusCode}');
if (expected != null) {
check('open-ended bytes exact', _bytesEqual(body, expected.sublist(size - 1000)));
}
(resp, body) = await get(range: 'bytes=-256');
check('suffix tail -> 206', resp.statusCode == 206, '${resp.statusCode}');
if (expected != null) {
check('suffix bytes exact', _bytesEqual(body, expected.sublist(size - 256)));
}
(resp, body) = await get(range: 'bytes=${size + 100}-${size + 200}');
check('out-of-range -> 416', resp.statusCode == 416, '${resp.statusCode}');
final badUri = mediaUri.replace(
pathSegments: [...mediaUri.pathSegments.take(2), '0' * 32, ...mediaUri.pathSegments.skip(3)]);
final badReq = await client.getUrl(badUri);
final badResp = await badReq.close();
await badResp.drain();
check('bad token -> 401', badResp.statusCode == 401, '${badResp.statusCode}');
} finally {
client.close();
try {
await sub.cancel();
} catch (_) {}
try {
await ch.sink.close();
} catch (_) {}
}
// ignore: avoid_print
print(_failures == 0 ? 'ALL PASS' : '$_failures FAILURES');
exitCode = _failures == 0 ? 0 : 1;
}
+219
View File
@@ -0,0 +1,219 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
lints:
dependency: "direct dev"
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
relic:
dependency: "direct main"
description:
name: relic
sha256: d917b188dd1b6838c630d1904a1e26b040e6647406f2aab01b10fdf0ca051c12
url: "https://pub.dev"
source: hosted
version: "1.2.0"
relic_core:
dependency: transitive
description:
name: relic_core
sha256: c2f7638d56fdf144a035d17eb8e93fb30fe53bd105fd81c31393f8be4f43ecdc
url: "https://pub.dev"
source: hosted
version: "1.2.0"
relic_io:
dependency: transitive
description:
name: relic_io
sha256: d64a9f309943200ec7a45b2a127cbc5abf4dd6603159a179396d66b932a15f1f
url: "https://pub.dev"
source: hosted
version: "1.2.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
sync_relay_core:
dependency: "direct main"
description:
path: "../packages/core"
relative: true
source: path
version: "0.1.0"
sync_relay_server:
dependency: "direct main"
description:
path: "../packages/server"
relative: true
source: path
version: "0.1.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.8.0 <4.0.0"
+15
View File
@@ -0,0 +1,15 @@
name: sync_relay_tool
description: Spikes and harnesses.
publish_to: none
environment:
sdk: ^3.0.0
dependencies:
sync_relay_core:
path: ../packages/core
sync_relay_server:
path: ../packages/server
relic: ^1.2.0
args: ^2.4.0
web_socket_channel: ^3.0.0
dev_dependencies:
lints: ^5.0.0
+44
View File
@@ -0,0 +1,44 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:relic/relic.dart';
import 'package:sync_relay_server/server.dart';
Future<void> main(List<String> args) async {
final parser = ArgParser()
..addOption('file', abbr: 'f', help: 'media file to serve')
..addOption('port', abbr: 'p', defaultsTo: '8080')
..addOption('route', defaultsTo: '/m/file');
final parsed = parser.parse(args);
final fileArg = parsed['file'] as String?;
if (fileArg == null) {
stderr.writeln('usage: range_logger.dart --file <path> [--port 8080]');
exit(2);
}
final file = File(fileArg);
if (!await file.exists()) {
stderr.writeln('file not found: $fileArg');
exit(2);
}
final size = await file.length();
final app = RelicApp();
mountMediaRoute(app, file,
route: parsed['route'] as String,
onLog: (log) {
// ignore: avoid_print
print(log);
});
app.fallback = (final req) {
// ignore: avoid_print
print('${req.method.name} ${req.url} -> 404');
return Response.notFound(body: Body.fromString('not found'));
};
final server = await app.serve(
address: InternetAddress.anyIPv4,
port: int.tryParse('${parsed['port']}') ?? 8080,
);
// ignore: avoid_print
print('SERVING file=$fileArg size=$size on :${server.port}${parsed['route']}');
await ProcessSignal.sigint.watch().first;
await app.close();
}
+81
View File
@@ -0,0 +1,81 @@
import 'dart:async';
import 'dart:io';
Future<void> main(List<String> args) async {
var listenPort = 8090;
var targetHost = '127.0.0.1';
var targetPort = 8080;
var kbps = 1024;
var latencyMs = 0;
var killAfterSecs = 0;
for (var i = 0; i < args.length; i++) {
switch (args[i]) {
case '--listen':
listenPort = int.parse(args[++i]);
case '--target':
final parts = args[++i].split(':');
targetHost = parts[0];
targetPort = int.parse(parts[1]);
case '--kbps':
kbps = int.parse(args[++i]);
case '--latency-ms':
latencyMs = int.parse(args[++i]);
case '--kill-after':
killAfterSecs = int.parse(args[++i]);
}
}
final server = await ServerSocket.bind(InternetAddress.anyIPv4, listenPort);
// ignore: avoid_print
print('SHAPER :$listenPort -> $targetHost:$targetPort ${kbps}KB/s latency=${latencyMs}ms'
'${killAfterSecs > 0 ? ' kill-after=${killAfterSecs}s' : ''}');
var dead = false;
if (killAfterSecs > 0) {
Timer(Duration(seconds: killAfterSecs), () {
dead = true;
// ignore: avoid_print
print('SHAPER kill switch fired, closing listener');
server.close();
});
}
await for (final client in server) {
if (dead) {
client.close();
continue;
}
Socket? upstream;
try {
upstream = await Socket.connect(targetHost, targetPort);
} catch (e) {
// ignore: avoid_print
print('SHAPER upstream refused: $e');
client.close();
continue;
}
unawaited(_pipe(client, upstream, kbps, latencyMs, 'c->s'));
unawaited(_pipe(upstream, client, kbps, latencyMs, 's->c'));
}
}
Future<void> _pipe(Socket from, Socket to, int kbps, int latencyMs, String tag) async {
final chunkBudget = kbps * 1024 ~/ 10;
try {
await for (final data in from) {
if (latencyMs > 0) await Future.delayed(Duration(milliseconds: latencyMs));
var offset = 0;
while (offset < data.length) {
final end = (offset + chunkBudget).clamp(0, data.length);
to.add(data.sublist(offset, end));
await to.flush();
offset = end;
if (offset < data.length) await Future.delayed(const Duration(milliseconds: 100));
}
}
await to.close();
} catch (e) {
// ignore: avoid_print
print('SHAPER $tag closed: $e');
try {
await to.close();
} catch (_) {}
}
}