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

+21 -5
View File
@@ -15,7 +15,7 @@ void main() {
test('fromMap tolerates missing keys', () {
final c = AppConfig.fromMap({});
expect(c.port, 8080);
expect(c.name, 'room');
expect(c.name, 'server');
});
test('round-trips through map', () {
@@ -24,8 +24,10 @@ void main() {
secret: 's',
name: 'n',
mediaPath: 'm',
advertiseHost: '1.2.3.4');
advertiseHost: '1.2.3.4',
hostKey: 'hk');
final back = AppConfig.fromMap(c.toMap());
expect(back.hostKey, 'hk');
expect(back.port, 1234);
expect(back.secret, 's');
expect(back.mediaPath, 'm');
@@ -64,15 +66,29 @@ void main() {
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
expect(back.user.name, 'alice');
expect(back.media.size, 1500000000);
expect(back.hostKey, isEmpty);
expect(msg.toJson().containsKey('hostKey'), isFalse);
});
test('hello carries hostKey when set', () {
const msg = HelloMessage(
proto: 1,
secret: '',
user: HelloUser(name: 'host'),
media: HelloMedia(name: '', size: 0, duration: 0),
hostKey: 'hk',
);
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
expect(back.hostKey, 'hk');
});
test('hello rejects wrong type', () {
expect(() => HelloMessage.fromJson({'t': 'bye'}), throwsA(isA<ProtocolException>()));
});
test('room state round-trips', () {
final s = RoomState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
final back = RoomState.fromJson(Map<String, Object?>.from(s.toJson()));
test('sync state round-trips', () {
final s = SyncState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
final back = SyncState.fromJson(Map<String, Object?>.from(s.toJson()));
expect(back.position, 12.5);
expect(back.paused, isFalse);
expect(back.mediaId, 3);
+19 -19
View File
@@ -5,12 +5,12 @@ import 'package:test/test.dart';
void main() {
late DateTime t;
late RoomEngine engine;
late SyncEngine engine;
DateTime clock() => t;
setUp(() {
t = DateTime.utc(2026, 1, 1);
engine = RoomEngine(clock: clock);
engine = SyncEngine(clock: clock);
});
String join(String token, String name) => engine.applyJoin(token, name, '');
@@ -37,12 +37,12 @@ void main() {
});
group('membership + gate', () {
test('join lands out-of-set, room stays held', () {
test('join lands out-of-set, playback stays held', () {
join('a', 'alice');
expect(engine.transport, Transport.held);
expect(engine.members['a']!.inSet, isFalse);
final room = engine.broadcast();
expect(room.members.single.inSet, isFalse);
final state = engine.broadcast();
expect(state.members.single.inSet, isFalse);
});
test('single ready + buffered auto-plays', () {
@@ -54,7 +54,7 @@ void main() {
expect(engine.transport, Transport.playing);
});
test('late joiner does not halt a playing room', () {
test('late joiner does not halt playback', () {
join('a', 'alice');
engine.applyReady('a', true);
buffer('a', 4.0);
@@ -77,7 +77,7 @@ void main() {
expect(engine.members['b']!.inSet, isTrue);
});
test('disconnect removes from quorum, room resumes', () {
test('disconnect removes from quorum, playback resumes', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
@@ -218,7 +218,7 @@ void main() {
expect(engine.members['a']!.ready, isTrue);
});
test('unpause uses room clock, laggards seek forward', () {
test('unpause uses server clock, laggards seek forward', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
@@ -355,29 +355,29 @@ void main() {
group('tiered correction', () {
test('big drift seeks either direction', () {
expect(correctionFor(ownPosition: 100, roomPosition: 0), Correction.seek);
expect(correctionFor(ownPosition: 0, roomPosition: 100), Correction.seek);
expect(correctionFor(ownPosition: 100, serverPosition: 0), Correction.seek);
expect(correctionFor(ownPosition: 0, serverPosition: 100), Correction.seek);
});
test('ahead beyond 1.5s slows to 0.95x', () {
expect(correctionFor(ownPosition: 3, roomPosition: 0), Correction.slowDown);
expect(correctionFor(ownPosition: 1.6, roomPosition: 0), Correction.slowDown);
expect(correctionFor(ownPosition: 3, serverPosition: 0), Correction.slowDown);
expect(correctionFor(ownPosition: 1.6, serverPosition: 0), Correction.slowDown);
});
test('deadzone does nothing', () {
expect(correctionFor(ownPosition: 1.0, roomPosition: 0), Correction.none);
expect(correctionFor(ownPosition: 0, roomPosition: 3.0), Correction.none);
expect(correctionFor(ownPosition: 5.1, roomPosition: 0), Correction.seek);
expect(correctionFor(ownPosition: 5.0, roomPosition: 0), Correction.slowDown);
expect(correctionFor(ownPosition: 1.0, serverPosition: 0), Correction.none);
expect(correctionFor(ownPosition: 0, serverPosition: 3.0), Correction.none);
expect(correctionFor(ownPosition: 5.1, serverPosition: 0), Correction.seek);
expect(correctionFor(ownPosition: 5.0, serverPosition: 0), Correction.slowDown);
});
});
group('broadcast shape', () {
test('room JSON round-trips with member views', () {
test('state JSON round-trips with member views', () {
join('a', 'alice');
final room = engine.broadcast();
final state = engine.broadcast();
final back =
RoomBroadcast.fromJson(Map<String, Object?>.from(room.toJson()));
StateBroadcast.fromJson(Map<String, Object?>.from(state.toJson()));
expect(back.transport, 'held');
expect(back.members.single.name, 'alice');
expect(back.members.single.id, 'm1');