forked from Foxyz/sync_replay_project
Server on an isolate, dedicated server binary, room -> server (proto 5)
- serve(Map config, events) is the one server entrypoint: the app hosts it via Isolate.spawn (crash surfaced on the Host card, logs forwarded as [server]); bin/server.dart is the dedicated server, compiled into the bundle as watch-together-server. --headless gone. - Host identity via a per-hosting hostKey in hello, replacing the loopback heuristic. - Rename room -> server across code, files and wire (t:"state", /health name); protocol bumped to 5. - CI compiles the server binary; isolate smoke tests in server and desktop suites. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8a0729029
commit
81cac1214d
48 files changed
+1073
-423
No files matched your search
@@ -16,14 +16,15 @@ class _Peer {
|
||||
}
|
||||
|
||||
static Future<_Peer> join(String host, int port, String name,
|
||||
{String secret = ''}) async {
|
||||
{String secret = '', String hostKey = ''}) async {
|
||||
final ws = await WebSocket.connect('ws://$host:$port/sync');
|
||||
final p = _Peer(ws);
|
||||
ws.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: secret,
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey)
|
||||
.toJson()));
|
||||
await p.next('welcome');
|
||||
return p;
|
||||
@@ -57,13 +58,13 @@ Future<String?> _lan() async {
|
||||
}
|
||||
|
||||
void main() {
|
||||
late RoomServer server;
|
||||
late SyncServer server;
|
||||
late File file;
|
||||
|
||||
setUp(() async {
|
||||
file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin')
|
||||
..writeAsBytesSync(List.filled(64, 1));
|
||||
server = RoomServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
@@ -122,22 +123,37 @@ void main() {
|
||||
await a.ws.close();
|
||||
});
|
||||
|
||||
test('host (loopback) cannot be kicked; others can', () async {
|
||||
final lan = await _lan();
|
||||
if (lan == null) {
|
||||
markTestSkipped('no LAN address');
|
||||
return;
|
||||
}
|
||||
final host = await _Peer.join('127.0.0.1', server.boundPort, 'host');
|
||||
final friend = await _Peer.join(lan, server.boundPort, 'friend');
|
||||
final hostId = server.hub.engine.members.values.firstWhere((m) => m.name == 'host').id;
|
||||
friend.send(jsonEncode(KickRequest(target: hostId).toJson()));
|
||||
test('host (hostKey) cannot be kicked; loopback without it can', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
final host =
|
||||
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
||||
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
||||
final guesser =
|
||||
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
|
||||
String idOf(String name) =>
|
||||
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
|
||||
final err = await friend.next('err');
|
||||
expect(err['message'], "can't kick the host");
|
||||
expect(host.closed, isFalse);
|
||||
await expectLater(server.hub.kickById(hostId, 'x'), throwsA(isA<AuthException>()));
|
||||
await expectLater(
|
||||
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
|
||||
// Same machine, no key (or a wrong one): an ordinary member.
|
||||
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
|
||||
await guesser.next('kicked');
|
||||
await server.hub.kickById(idOf('friend'), 'x');
|
||||
await friend.next('kicked');
|
||||
await host.ws.close();
|
||||
await friend.ws.close();
|
||||
});
|
||||
|
||||
test('no hostKey configured: nobody is the host', () async {
|
||||
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: '');
|
||||
final b = await _Peer.join('127.0.0.1', server.boundPort, 'b', hostKey: 'x');
|
||||
expect(server.session.tokens.values.where((t) => t.isHost), isEmpty);
|
||||
await a.ws.close();
|
||||
await b.ws.close();
|
||||
});
|
||||
|
||||
test('empty secret accepts any hello secret', () async {
|
||||
|
||||
@@ -60,10 +60,10 @@ Future<void> _pollBool(
|
||||
|
||||
void main() {
|
||||
group('presence lifecycle live', () {
|
||||
late RoomServer server;
|
||||
late SyncServer server;
|
||||
|
||||
setUp(() async {
|
||||
server = RoomServer(
|
||||
server = SyncServer(
|
||||
const AppConfig(port: 0, secret: 'cycle', name: 'cycle'));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
Future<void> _run((Map<String, Object?>, SendPort) args) =>
|
||||
serve(args.$1, events: args.$2);
|
||||
|
||||
void main() {
|
||||
test('serve in a real isolate: ready, /health, stop, clean exit', () async {
|
||||
final events = ReceivePort();
|
||||
final exit = ReceivePort();
|
||||
final errors = ReceivePort();
|
||||
final config = const AppConfig(port: 0, secret: '', name: 'iso', hostKey: 'hk').toMap();
|
||||
await Isolate.spawn(_run, (config, events.sendPort),
|
||||
onExit: exit.sendPort, onError: errors.sendPort);
|
||||
final crashes = <Object?>[];
|
||||
errors.listen(crashes.add);
|
||||
final logs = <Map>[];
|
||||
final ready = Completer<Map>();
|
||||
events.listen((m) {
|
||||
if (m is! Map) return;
|
||||
if (m['t'] == 'log') logs.add(m);
|
||||
if (m['t'] == 'ready' && !ready.isCompleted) ready.complete(m);
|
||||
if (m['t'] == 'error' && !ready.isCompleted) ready.completeError('${m['message']}');
|
||||
});
|
||||
final r = await ready.future.timeout(const Duration(seconds: 10));
|
||||
final port = r['port'] as int;
|
||||
expect(port, greaterThan(0));
|
||||
|
||||
final http = HttpClient();
|
||||
final resp = await (await http.get('127.0.0.1', port, '/health')).close();
|
||||
final body = jsonDecode(await resp.transform(utf8.decoder).join()) as Map;
|
||||
http.close();
|
||||
expect(body['ok'], isTrue);
|
||||
expect(body['name'], 'iso');
|
||||
expect(body['proto'], RelayConstants.protoVersion);
|
||||
expect(logs.any((l) => '${l['message']}'.contains('listening on $port')), isTrue);
|
||||
|
||||
(r['stop'] as SendPort).send({'t': 'stop'});
|
||||
await exit.first.timeout(const Duration(seconds: 10));
|
||||
expect(crashes, isEmpty);
|
||||
// The port is free again.
|
||||
final again = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||
await again.close();
|
||||
events.close();
|
||||
errors.close();
|
||||
});
|
||||
|
||||
test('serve reports a bind failure as an error event', () async {
|
||||
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
final events = ReceivePort();
|
||||
final exit = ReceivePort();
|
||||
await Isolate.spawn(
|
||||
_run,
|
||||
(AppConfig(port: blocker.port, secret: '', name: 'x').toMap(),
|
||||
events.sendPort),
|
||||
onExit: exit.sendPort);
|
||||
final first = await events
|
||||
.firstWhere((m) => m is Map && m['t'] != 'log')
|
||||
.timeout(const Duration(seconds: 10)) as Map;
|
||||
expect(first['t'], 'error');
|
||||
await exit.first.timeout(const Duration(seconds: 10));
|
||||
await blocker.close();
|
||||
});
|
||||
}
|
||||
+7
-22
@@ -8,28 +8,19 @@ import 'package:sync_relay_server/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
Future<String?> _lanAddress() async {
|
||||
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||
for (final a in i.addresses) {
|
||||
if (!a.isLoopback) return a.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('room auth live', () {
|
||||
group('server auth live', () {
|
||||
late File file;
|
||||
late Uint8List bytes;
|
||||
late RoomServer server;
|
||||
late SyncServer server;
|
||||
late HttpClient http;
|
||||
|
||||
setUp(() async {
|
||||
bytes = Uint8List.fromList(List<int>.generate(8192, (i) => i % 251));
|
||||
file = File(
|
||||
'${Directory.systemTemp.path}/room_test_${DateTime.now().microsecondsSinceEpoch}.bin');
|
||||
'${Directory.systemTemp.path}/server_test_${DateTime.now().microsecondsSinceEpoch}.bin');
|
||||
await file.writeAsBytes(bytes);
|
||||
server = RoomServer(AppConfig(
|
||||
server = SyncServer(AppConfig(
|
||||
port: 0, secret: 'shh', name: 'test', mediaPath: file.path));
|
||||
await server.start();
|
||||
http = HttpClient();
|
||||
@@ -71,14 +62,14 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
test('health reports room, proto, viewers, bytes', () async {
|
||||
test('health reports name, proto, viewers, bytes', () async {
|
||||
final req = await http.get('127.0.0.1', server.boundPort, '/health');
|
||||
final resp = await req.close();
|
||||
expect(resp.statusCode, 200);
|
||||
final body = await resp.transform(utf8.decoder).join();
|
||||
final decoded = jsonDecode(body) as Map;
|
||||
expect(decoded['ok'], isTrue);
|
||||
expect(decoded['room'], 'test');
|
||||
expect(decoded['name'], 'test');
|
||||
expect(decoded['proto'], RelayConstants.protoVersion);
|
||||
expect(decoded['viewers'], 0);
|
||||
expect(decoded['bytesServed'], 0);
|
||||
@@ -247,12 +238,6 @@ void main() {
|
||||
});
|
||||
|
||||
test('kick via socket: peer kicked, unknown target errors', () async {
|
||||
final lan = await _lanAddress();
|
||||
if (lan == null) {
|
||||
markTestSkipped('no non-loopback IPv4 to act as a non-host peer');
|
||||
return;
|
||||
}
|
||||
// Loopback peers are the host and unkickable, so bob dials the LAN address.
|
||||
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
||||
[String host = '127.0.0.1']) async {
|
||||
final ch = WebSocketChannel.connect(
|
||||
@@ -304,7 +289,7 @@ void main() {
|
||||
}
|
||||
|
||||
final (a, _, _, messagesA) = await join('alice');
|
||||
final (b, tokenB, idB, messagesB) = await join('bob', lan);
|
||||
final (b, tokenB, idB, messagesB) = await join('bob');
|
||||
try {
|
||||
a.sink.add(jsonEncode(
|
||||
KickRequest(target: idB, reason: 'afk?').toJson()));
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('server starts, answers /health, stops cleanly', () async {
|
||||
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||
await server.start();
|
||||
expect(server.isRunning, isTrue);
|
||||
final port = server.boundPort;
|
||||
@@ -45,7 +45,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('unknown path returns 404', () async {
|
||||
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||
await server.start();
|
||||
final client = HttpClient();
|
||||
try {
|
||||
|
||||
@@ -80,8 +80,8 @@ void main() {
|
||||
});
|
||||
|
||||
group('core welcome + proto', () {
|
||||
test('proto bumped to 4 for readiness + room fan-out', () {
|
||||
expect(RelayConstants.protoVersion, 4);
|
||||
test('proto 5: state broadcast + hello hostKey', () {
|
||||
expect(RelayConstants.protoVersion, 5);
|
||||
});
|
||||
|
||||
test('welcome round-trips with token + member id', () {
|
||||
|
||||
Reference in new issue
Block a user