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:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:55:14 +03:00
1 parent d8a0729029
commit 81cac1214d
48 files changed
+1073 -423

No files matched your search

+15 -14
View File
@@ -25,11 +25,11 @@ Future<void> _poll(
void main() {
test(
'dead server clears room state and reconnects instead of freezing',
'dead server clears server state and reconnects instead of freezing',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(
final server = SyncServer(
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
await server.start();
final coord = SessionCoordinator();
@@ -41,7 +41,7 @@ void main() {
name: 'watcher',
));
await coord.playback.player.setVolume(0);
await _poll(() => coord.room.peek() != null, 'room state to arrive');
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
expect(coord.reconnecting.peek(), isFalse);
await server.stop();
@@ -50,8 +50,8 @@ void main() {
'reconnect loop to engage',
);
await _poll(
() => coord.room.peek() == null,
'stale room (ghost members) to clear',
() => coord.serverState.peek() == null,
'stale state (ghost members) to clear',
);
coord.cancelReconnect();
expect(coord.reconnecting.peek(), isFalse);
@@ -66,12 +66,12 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = RoomServer(
final first = SyncServer(
const AppConfig(port: 0, secret: 'race', name: 'race'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
RoomServer? second;
SyncServer? second;
final observer = SyncClient();
try {
await coord.connect(ConnectParams(
@@ -79,7 +79,7 @@ void main() {
await coord.playback.player.setVolume(0);
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = RoomServer(
second = SyncServer(
AppConfig(port: port, secret: 'race', name: 'race'));
await second.start();
var joins = 0;
@@ -93,7 +93,7 @@ void main() {
// Within the 1.5 s delay: the first attempt must die with Cancel.
coord.cancelReconnect();
coord.retry();
await _poll(() => !coord.reconnecting.peek() && coord.room.peek() != null,
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
'reconnect to land');
await Future.delayed(const Duration(seconds: 4));
await sub.cancel();
@@ -116,13 +116,13 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = RoomServer(
final first = SyncServer(
const AppConfig(port: 0, secret: 'force', name: 'force'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
final friend = SyncClient();
RoomServer? second;
SyncServer? second;
try {
await coord.connect(ConnectParams(
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
@@ -138,7 +138,7 @@ void main() {
await friend.disconnect();
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = RoomServer(
second = SyncServer(
AppConfig(port: port, secret: 'force', name: 'force'));
await second.start();
await _poll(
@@ -158,7 +158,7 @@ void main() {
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(
final server = SyncServer(
const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
final coord = SessionCoordinator();
@@ -185,7 +185,8 @@ void main() {
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
Hive.init('/home/foxyz/.claude/jobs/4898998e/tmp/hive_coord_test');
Hive.init(
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_coord_test');
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = probe.port;
await probe.close();
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart';
Future<void> _poll(
FutureOr<bool> Function() cond,
String what, {
Duration timeout = const Duration(seconds: 15),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 100));
}
fail('timed out waiting for $what');
}
/// A bare WebSocket viewer: no player, just the wire.
class _Raw {
final WebSocket ws;
final msgs = <Map<String, Object?>>[];
_Raw(this.ws) {
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
}
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
r.ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: secret,
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
await r.next('welcome');
return r;
}
Future<Map<String, Object?>> next(String type, {bool Function(Map<String, Object?>)? where}) async {
late Map<String, Object?> found;
await _poll(() {
for (final m in msgs) {
if (m['t'] == type && (where == null || where(m))) {
found = m;
msgs.remove(m);
return true;
}
}
return false;
}, '$type from server');
return found;
}
}
Future<int> _freePort() async {
final s = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = s.port;
await s.close();
return port;
}
void main() {
test('server isolate: logs, raw join, crash reported, stop frees port',
timeout: const Timeout(Duration(minutes: 1)), () async {
final logs = <String>[];
final crashes = <String>[];
final server = await ServerIsolate.start(
const AppConfig(port: 0, secret: '', name: 'iso'),
onLog: logs.add,
onCrash: crashes.add);
final viewer = await _Raw.join(server.port, 'viewer');
await viewer.next('state');
await _poll(() => logs.any((l) => l.contains('join viewer')), 'join log');
await viewer.ws.close();
server.isolate.kill(priority: Isolate.immediate);
await _poll(() => crashes.isNotEmpty, 'crash callback');
expect(server.isRunning, isFalse);
await server.stop(); // no-op on a dead isolate
final clean = await ServerIsolate.start(
const AppConfig(port: 0, secret: '', name: 'iso'),
onLog: (_) {},
onCrash: crashes.add);
final port = clean.port;
await clean.stop();
expect(crashes, hasLength(1), reason: 'a requested stop is not a crash');
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close();
});
test('bind failure surfaces as a start error',
timeout: const Timeout(Duration(seconds: 30)), () async {
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
try {
await expectLater(
ServerIsolate.start(AppConfig(port: blocker.port, secret: '', name: 'x'),
onLog: (_) {}, onCrash: (_) {}),
throwsA(isA<StateError>()));
} finally {
await blocker.close();
}
});
test('host path: isolate server, host unkickable, crash leaves the session',
timeout: const Timeout(Duration(minutes: 2)), () async {
MediaKit.ensureInitialized();
Hive.init(
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_isolate_test');
final session = SessionState.instance;
final port = await _freePort();
_Raw? friend;
try {
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
await session.coordinator.playback.player.setVolume(0);
friend = await _Raw.join(port, 'friend', secret: 'pw');
final state = await friend.next('state',
where: (m) => (m['members'] as List).any((x) => (x as Map)['name'] == 'host'));
final hostId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id'];
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
final err = await friend.next('err');
expect(err['message'], "can't kick the host");
// The host can kick the friend (plain loopback peers are not hosts).
final friendId = ((state['members'] as List)
.cast<Map>()
.firstWhere((m) => m['name'] == 'friend'))['id'];
session.coordinator.kick('$friendId');
await friend.next('kicked');
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
expect(session.serverCrash.peek(), startsWith('server crashed'));
expect(session.hostedServer, isNull);
// The killed isolate's socket stays bound in-process; hosting again on
// the same port must still work.
session.serverCrash.value = null;
await session.host(
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
ConnectForm());
expect(session.route.peek(), AppRoute.session);
final again = await _Raw.join(port, 'again', secret: 'pw');
await again.ws.close();
await session.leave();
expect(session.hostedServer, isNull);
} finally {
await friend?.ws.close();
await session.coordinator.leave(silent: true);
await session.stopServer();
}
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/idle.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
MemberView(
@@ -1,15 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/sync/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
RoomBroadcast _room({
StateBroadcast _state({
int eventId = 0,
String transport = 'held',
double position = 10.0,
int mediaId = 1,
}) {
final now = DateTime.now().toUtc();
return RoomBroadcast(
return StateBroadcast(
eventId: eventId,
transport: transport,
position: position,
@@ -19,10 +19,10 @@ RoomBroadcast _room({
);
}
RoomBroadcast _roomWithMembers(List<MemberView> members,
StateBroadcast _stateWithMembers(List<MemberView> members,
{String transport = 'playing'}) {
final now = DateTime.now().toUtc();
return RoomBroadcast(
return StateBroadcast(
eventId: 3,
transport: transport,
position: 10.0,
@@ -33,50 +33,50 @@ RoomBroadcast _roomWithMembers(List<MemberView> members,
}
void main() {
test('initial diff seeks to room edge and sets transport', () {
final diff = RoomDiff();
final actions = diff.diff(_room(), 0, DateTime.now().toUtc());
test('initial diff seeks to server edge and sets transport', () {
final diff = StateDiff();
final actions = diff.diff(_state(), 0, DateTime.now().toUtc());
expect(actions.whereType<SeekAction>().single.position, closeTo(10.0, 1.0));
expect(actions.whereType<TransportAction>().single.playing, isFalse);
});
test('same event small drift does nothing', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
final actions = diff.diff(_room(), 10.5, now);
diff.diff(_state(), 0, now);
final actions = diff.diff(_state(), 10.5, now);
expect(actions, isEmpty);
});
test('ahead drift latches slowdown, resets inside 0.1', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
var actions = diff.diff(_room(), 13.0, now);
diff.diff(_state(), 0, now);
var actions = diff.diff(_state(), 13.0, now);
expect(actions.whereType<RateAction>().single.rate, 0.95);
actions = diff.diff(_room(), 12.0, now);
actions = diff.diff(_state(), 12.0, now);
expect(actions, isEmpty);
actions = diff.diff(_room(), 10.05, now);
actions = diff.diff(_state(), 10.05, now);
expect(actions.whereType<RateAction>().single.rate, 1.0);
});
test('eventId jump seeks and resets rate', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(), 0, now);
diff.diff(_room(), 13.0, now);
diff.diff(_state(), 0, now);
diff.diff(_state(), 13.0, now);
final actions =
diff.diff(_room(eventId: 1, position: 90.0), 13.0, now);
diff.diff(_state(eventId: 1, position: 90.0), 13.0, now);
expect(actions.whereType<SeekAction>().single.position, closeTo(90.0, 1.0));
expect(actions.whereType<RateAction>().single.rate, 1.0);
});
test('media swap seeks zero and pauses', () {
final diff = RoomDiff();
final diff = StateDiff();
final now = DateTime.now().toUtc();
diff.diff(_room(transport: 'playing'), 10.0, now);
diff.diff(_state(transport: 'playing'), 10.0, now);
final actions = diff.diff(
_room(eventId: 2, transport: 'held', position: 0, mediaId: 2),
_state(eventId: 2, transport: 'held', position: 0, mediaId: 2),
50.0,
now);
expect(actions.whereType<SeekAction>().single.position, 0);
@@ -84,25 +84,25 @@ void main() {
});
group('seek debounce while playing', () {
RoomBroadcast playing({int eventId = 5, double position = 100.0}) => _room(
StateBroadcast playing({int eventId = 5, double position = 100.0}) => _state(
eventId: eventId, transport: 'playing', position: position);
test('fresh big drift is swallowed first, seeks after window', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(playing(), 100.0, t0);
var actions = diff.diff(playing(), 120.0, t0);
expect(actions.whereType<SeekAction>(), isEmpty,
reason: 'user scrub must not be yanked back before detector runs');
actions = diff.diff(
playing(), 120.0, t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
playing(), 120.0, t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
expect(actions.whereType<SeekAction>().single.position,
closeTo(102.0, 3.0),
reason: 'genuine drift still converges after the window');
});
test('new eventId seeks immediately (echo of promoted seek)', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(playing(), 100.0, t0);
diff.diff(playing(), 120.0, t0);
@@ -112,13 +112,13 @@ void main() {
});
test('held transport debounces the same way (detector promotes)', () {
final diff = RoomDiff();
final diff = StateDiff();
final t0 = DateTime.now().toUtc();
diff.diff(_room(), 10.0, t0);
var actions = diff.diff(_room(), 60.0, t0);
diff.diff(_state(), 10.0, t0);
var actions = diff.diff(_state(), 60.0, t0);
expect(actions.whereType<SeekAction>(), isEmpty);
actions = diff.diff(_room(), 60.0,
t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
actions = diff.diff(_state(), 60.0,
t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
expect(actions.whereType<SeekAction>().single.position,
closeTo(10.0, 3.0));
});
@@ -148,12 +148,12 @@ void main() {
stale: false);
test('only in-set ready members may follow transport to playing', () {
final room = _roomWithMembers([ready, catchingUp, unreadyInSet]);
expect(localPlayAllowed(room, 'm1'), isTrue);
expect(localPlayAllowed(room, 'm2'), isFalse);
expect(localPlayAllowed(room, 'm3'), isFalse);
expect(localPlayAllowed(room, 'nope'), isFalse);
expect(localPlayAllowed(room, ''), isFalse);
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);
expect(localPlayAllowed(state, 'm1'), isTrue);
expect(localPlayAllowed(state, 'm2'), isFalse);
expect(localPlayAllowed(state, 'm3'), isFalse);
expect(localPlayAllowed(state, 'nope'), isFalse);
expect(localPlayAllowed(state, ''), isFalse);
});
});
}
@@ -37,10 +37,10 @@ void main() {
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(const AppConfig(
final server = SyncServer(const AppConfig(
port: _port,
secret: _secret,
name: 'room',
name: 'server',
mediaPath: _mediaPath,
));
await server.start();
@@ -67,10 +67,10 @@ void main() {
reason: 'media must open paused, not autoplay');
expect(viewer.playback.player.state.playing, isFalse);
await _poll(() => host.room.peek() != null, 'host room state');
await _poll(() => host.serverState.peek() != null, 'host server state');
host.toggleReady();
await Future.delayed(const Duration(seconds: 5));
expect(host.room.peek()?.transport, 'held',
expect(host.serverState.peek()?.transport, 'held',
reason: 'one Space with company present must not solo-play');
expect(host.playback.player.state.playing, isFalse);
expect(viewer.playback.player.state.playing, isFalse);
@@ -78,7 +78,7 @@ void main() {
viewer.toggleReady();
await _poll(
() =>
host.room.peek()?.transport == 'playing' &&
host.serverState.peek()?.transport == 'playing' &&
viewer.playback.player.state.playing &&
host.playback.player.state.playing,
'both players playing once quorum readies',
+6 -6
View File
@@ -5,7 +5,7 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/room_diff.dart';
import 'package:watch_together/src/sync/state_diff.dart';
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
@@ -29,7 +29,7 @@ void main() {
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = RoomServer(const AppConfig(
final server = SyncServer(const AppConfig(
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
await server.start();
final c = SessionCoordinator();
@@ -40,7 +40,7 @@ void main() {
secret: 'reload',
name: 'solo'));
await c.playback.player.setVolume(0);
await _poll(() => c.room.peek() != null, 'room state');
await _poll(() => c.serverState.peek() != null, 'server state');
c.toggleReady();
await _poll(
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
@@ -54,14 +54,14 @@ void main() {
expect(c.selfMember.peek()?.ready, isTrue);
await _poll(() {
final r = c.room.peek();
final r = c.serverState.peek();
if (r == null || !c.playback.player.state.playing) return false;
final want = roomPositionAt(r, DateTime.now().toUtc());
final want = serverPositionAt(r, DateTime.now().toUtc());
return (c.playback.positionSecs - want).abs() < 1.5 &&
c.playback.positionSecs > 2;
}, 'reloaded player playing at the synced position');
expect(c.selfMember.peek()?.ready, isTrue);
expect(c.room.peek()?.transport, 'playing');
expect(c.serverState.peek()?.transport, 'playing');
await c.leave();
expect(c.playback.alive.peek(), isFalse);