Initial commit: watch-together phases 0-4 + presence hardening, internet-tested
This commit is contained in:
commit
427d851a5b
101 files changed
+11326
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user