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
@@ -19,10 +19,6 @@ Future<void> main(List<String> args) async {
|
||||
exit(exitCode);
|
||||
}
|
||||
initLogging();
|
||||
if (args.contains('--headless')) {
|
||||
await runHeadless(args);
|
||||
return;
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
debugPrint('FLUTTER ERROR: ${details.exceptionAsString()}\n${details.stack}');
|
||||
FlutterError.presentError(details);
|
||||
@@ -67,13 +63,6 @@ Future<void> main(List<String> args) async {
|
||||
runApp(const WatchTogetherApp());
|
||||
}
|
||||
|
||||
Future<void> runHeadless(List<String> args) async {
|
||||
final session = SessionState.instance;
|
||||
await session.startHeadless(args);
|
||||
await ProcessSignal.sigint.watch().first;
|
||||
await session.stopServer();
|
||||
}
|
||||
|
||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||
/// window open.
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../state/settings.dart';
|
||||
import '../sync/room_diff.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import 'frame_watchdog.dart';
|
||||
|
||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||
|
||||
@@ -39,6 +39,12 @@ class ConnectFlow extends ChangeNotifier {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void showError(ConnectMode mode, CardError error) {
|
||||
errors[mode] = error;
|
||||
notices[mode] = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void clearError(ConnectMode mode) {
|
||||
if (errors[mode] == null && notices[mode] == null) return;
|
||||
errors[mode] = null;
|
||||
|
||||
@@ -33,6 +33,7 @@ class _HomePageState extends State<HomePage> {
|
||||
late final _media =
|
||||
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
||||
late final void Function() _stopKickWatch;
|
||||
late final void Function() _stopCrashWatch;
|
||||
bool _dialogOpen = false;
|
||||
String? _lastKickShown;
|
||||
|
||||
@@ -49,11 +50,21 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
||||
});
|
||||
// Back from a session whose hosted server crashed: say so on the Host card.
|
||||
_stopCrashWatch = effect(() {
|
||||
final crash = SessionState.instance.serverCrash.value;
|
||||
if (crash == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SessionState.instance.serverCrash.value = null;
|
||||
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopKickWatch();
|
||||
_stopCrashWatch();
|
||||
_flow.dispose();
|
||||
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
||||
c.dispose();
|
||||
|
||||
@@ -354,7 +354,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
||||
final coord = SessionState.instance.coordinator;
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final r = coord.room.value;
|
||||
final r = coord.serverState.value;
|
||||
final waiting = [
|
||||
for (final m in r?.members ?? [])
|
||||
if (!m.ready) m,
|
||||
@@ -648,7 +648,7 @@ class _SheetPanel extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: SignalBuilder(
|
||||
builder: (context) {
|
||||
final r = coord.room.value;
|
||||
final r = coord.serverState.value;
|
||||
final log = coord.feedLog.value;
|
||||
final theme = Theme.of(context);
|
||||
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
|
||||
@@ -669,7 +669,7 @@ class _SheetPanel extends StatelessWidget {
|
||||
children: [
|
||||
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(playing: playing, hasRoom: r != null),
|
||||
_StatusChip(playing: playing, hasState: r != null),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -780,14 +780,14 @@ class _SheetPanel extends StatelessWidget {
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final bool playing;
|
||||
final bool hasRoom;
|
||||
const _StatusChip({required this.playing, required this.hasRoom});
|
||||
final bool hasState;
|
||||
const _StatusChip({required this.playing, required this.hasState});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color dot;
|
||||
final String label;
|
||||
if (!hasRoom) {
|
||||
if (!hasState) {
|
||||
dot = Colors.grey;
|
||||
label = '…';
|
||||
} else if (playing) {
|
||||
|
||||
@@ -19,7 +19,7 @@ class IdleTracker {
|
||||
}
|
||||
|
||||
/// Called once per tick. Warn and unready only matter while self is
|
||||
/// ready; while the room plays the ready flag gates nothing.
|
||||
/// ready; while playing the ready flag gates nothing.
|
||||
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
||||
if (playing) {
|
||||
activity(now);
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../sfx/sfx.dart';
|
||||
import '../sync/room_diff.dart';
|
||||
import '../sync/state_diff.dart';
|
||||
import '../sync/sync_client.dart';
|
||||
import 'health.dart';
|
||||
import 'idle.dart';
|
||||
@@ -42,6 +42,8 @@ class ConnectParams {
|
||||
final String avatar;
|
||||
final String mediaPath;
|
||||
final String linkUrl;
|
||||
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
|
||||
final String hostKey;
|
||||
const ConnectParams({
|
||||
required this.host,
|
||||
required this.port,
|
||||
@@ -50,6 +52,7 @@ class ConnectParams {
|
||||
this.avatar = '',
|
||||
this.mediaPath = '',
|
||||
this.linkUrl = '',
|
||||
this.hostKey = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,8 +65,8 @@ class ToastItem {
|
||||
|
||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||
|
||||
String? roomMediaUrl(RoomBroadcast room) {
|
||||
final media = room.media;
|
||||
String? stateMediaUrl(StateBroadcast state) {
|
||||
final media = state.media;
|
||||
if (media == null) return null;
|
||||
final url = media['url'];
|
||||
if (url is String && url.isNotEmpty) return url;
|
||||
@@ -74,11 +77,11 @@ class SessionCoordinator {
|
||||
final SfxService sfx;
|
||||
final SyncClient sync = SyncClient();
|
||||
final PlaybackController playback = PlaybackController();
|
||||
final RoomDiff diff = RoomDiff();
|
||||
final StateDiff diff = StateDiff();
|
||||
|
||||
final health =
|
||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final room = signal<RoomBroadcast?>(null);
|
||||
final serverState = signal<StateBroadcast?>(null);
|
||||
final feedLog = signal<List<FeedMessage>>([]);
|
||||
final toasts = signal<List<ToastItem>>([]);
|
||||
final reconnecting = signal(false);
|
||||
@@ -86,7 +89,7 @@ class SessionCoordinator {
|
||||
final starving = signal<String?>(null);
|
||||
|
||||
late final Computed<MemberView?> selfMember = computed(() {
|
||||
final r = room.value;
|
||||
final r = serverState.value;
|
||||
if (r == null || sync.memberId.isEmpty) return null;
|
||||
for (final m in r.members) {
|
||||
if (m.id == sync.memberId) return m;
|
||||
@@ -95,7 +98,7 @@ class SessionCoordinator {
|
||||
});
|
||||
|
||||
Timer? _ticker;
|
||||
StreamSubscription? _roomSub;
|
||||
StreamSubscription? _stateSub;
|
||||
StreamSubscription? _feedSub;
|
||||
StreamSubscription? _kickSub;
|
||||
StreamSubscription? _errSub;
|
||||
@@ -122,7 +125,7 @@ class SessionCoordinator {
|
||||
double? _pendingSeekTarget;
|
||||
DateTime? _pendingSeekAt;
|
||||
int _seekRetries = 0;
|
||||
DateTime? _lastRoomAt;
|
||||
DateTime? _lastStateAt;
|
||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||
int _toastId = 0;
|
||||
ConnectParams? _params;
|
||||
@@ -224,13 +227,14 @@ class SessionCoordinator {
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
hostKey: params.hostKey,
|
||||
);
|
||||
// Whoever bumped the epoch (cancel/leave) also closed the socket.
|
||||
if (epoch != _epoch) throw const ConnectCancelled();
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
_lastRoomAt = DateTime.now().toUtc();
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty) {
|
||||
await _openMedia(url);
|
||||
@@ -257,7 +261,7 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
_roomSub ??= sync.rooms.listen(_onRoom);
|
||||
_stateSub ??= sync.states.listen(_onState);
|
||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||
_kickSub ??= sync.kicked.listen(_onKicked);
|
||||
_errSub ??= sync.errors.listen((msg) {
|
||||
@@ -291,14 +295,14 @@ class SessionCoordinator {
|
||||
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
||||
}
|
||||
|
||||
Future<void> _onRoom(RoomBroadcast r) async {
|
||||
Future<void> _onState(StateBroadcast r) async {
|
||||
final now = DateTime.now().toUtc();
|
||||
_lastRoomAt = now;
|
||||
room.value = r;
|
||||
_lastStateAt = now;
|
||||
serverState.value = r;
|
||||
_trackSelfReady(r, now);
|
||||
if (r.mediaId != _mediaId) {
|
||||
_mediaId = r.mediaId;
|
||||
var url = roomMediaUrl(r);
|
||||
var url = stateMediaUrl(r);
|
||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||
final name = (r.media?['name'] as String?) ?? '';
|
||||
if (name.isNotEmpty) url = _fileUrlFor(name);
|
||||
@@ -325,7 +329,7 @@ class SessionCoordinator {
|
||||
];
|
||||
}
|
||||
if (allowed && !_wasPlayAllowed && r.transport == 'playing') {
|
||||
final edge = roomPositionAt(r, now);
|
||||
final edge = serverPositionAt(r, now);
|
||||
actions = [
|
||||
for (final a in actions) if (a is! TransportAction) a,
|
||||
SeekAction(edge),
|
||||
@@ -347,7 +351,7 @@ class SessionCoordinator {
|
||||
|
||||
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||
/// by others included) and re-enters the set after a stale spell.
|
||||
void _trackSelfReady(RoomBroadcast r, DateTime now) {
|
||||
void _trackSelfReady(StateBroadcast r, DateTime now) {
|
||||
final self = selfMember.peek();
|
||||
if (self == null) return;
|
||||
if (r.mediaId != _mediaId) _restoringReady = false;
|
||||
@@ -364,7 +368,7 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
|
||||
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||
if (r.transport == 'playing') {
|
||||
starving.value = null;
|
||||
return;
|
||||
@@ -418,7 +422,7 @@ class SessionCoordinator {
|
||||
if (kind != null) {
|
||||
var readyIndex = 0;
|
||||
if (f.action == 'ready') {
|
||||
readyIndex = room.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||
}
|
||||
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
||||
}
|
||||
@@ -469,7 +473,7 @@ class SessionCoordinator {
|
||||
final now = DateTime.now().toUtc();
|
||||
toasts.value =
|
||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||
final r = room.peek();
|
||||
final r = serverState.peek();
|
||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||
sync.sendReport(
|
||||
position: playback.positionSecs,
|
||||
@@ -481,10 +485,10 @@ class SessionCoordinator {
|
||||
}
|
||||
if (!_manualClose &&
|
||||
kickReason.peek() == null &&
|
||||
_lastRoomAt != null &&
|
||||
now.difference(_lastRoomAt!).inSeconds >= 5) {
|
||||
if (room.peek() != null) {
|
||||
room.value = null;
|
||||
_lastStateAt != null &&
|
||||
now.difference(_lastStateAt!).inSeconds >= 5) {
|
||||
if (serverState.peek() != null) {
|
||||
serverState.value = null;
|
||||
starving.value = null;
|
||||
_wasPlayAllowed = false;
|
||||
_mediaId = -1;
|
||||
@@ -502,7 +506,7 @@ class SessionCoordinator {
|
||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||
// activity so a pause after a long watch starts a fresh idle clock.
|
||||
final event = _idle.tick(now,
|
||||
playing: room.peek()?.transport == 'playing',
|
||||
playing: serverState.peek()?.transport == 'playing',
|
||||
selfReady: selfMember.peek()?.ready ?? false);
|
||||
switch (event) {
|
||||
case IdleEvent.warn:
|
||||
@@ -523,7 +527,7 @@ class SessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
void _detectUserSeek(RoomBroadcast r) {
|
||||
void _detectUserSeek(StateBroadcast r) {
|
||||
if (_openUrl == null) {
|
||||
_lastPos = playback.positionSecs;
|
||||
return;
|
||||
@@ -533,7 +537,7 @@ class SessionCoordinator {
|
||||
final jumped = (pos - _lastPos).abs();
|
||||
_lastPos = pos;
|
||||
if (jumped <= 2.0) return;
|
||||
if ((pos - roomPositionAt(r, now)).abs() < 2.0) return;
|
||||
if ((pos - serverPositionAt(r, now)).abs() < 2.0) return;
|
||||
final recentSync = _pendingSeekAt != null &&
|
||||
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
|
||||
if (recentSync) return;
|
||||
@@ -541,7 +545,7 @@ class SessionCoordinator {
|
||||
sync.sendSeek(pos);
|
||||
}
|
||||
|
||||
Future<void> _checkSeekLanded(RoomBroadcast r) async {
|
||||
Future<void> _checkSeekLanded(StateBroadcast r) async {
|
||||
final target = _pendingSeekTarget;
|
||||
final at = _pendingSeekAt;
|
||||
if (target == null || at == null) return;
|
||||
@@ -589,10 +593,10 @@ class SessionCoordinator {
|
||||
}
|
||||
|
||||
void nudge(double deltaSecs) {
|
||||
final r = room.peek();
|
||||
final r = serverState.peek();
|
||||
if (r == null || _openUrl == null) return;
|
||||
final target =
|
||||
roomPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
|
||||
serverPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
|
||||
if (target.isNaN || target < 0) return;
|
||||
markInput();
|
||||
sync.sendSeek(target);
|
||||
@@ -651,12 +655,13 @@ class SessionCoordinator {
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
hostKey: params.hostKey,
|
||||
);
|
||||
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||
if (stale()) return;
|
||||
reconnecting.value = false;
|
||||
_everConnected = true;
|
||||
_lastRoomAt = DateTime.now().toUtc();
|
||||
_lastStateAt = DateTime.now().toUtc();
|
||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||
final url = welcome.mediaUrl;
|
||||
if (url != null && url.isNotEmpty && url != _openUrl) {
|
||||
@@ -743,7 +748,7 @@ class SessionCoordinator {
|
||||
await sync.disconnect();
|
||||
} catch (_) {}
|
||||
if (!silent) {
|
||||
room.value = null;
|
||||
serverState.value = null;
|
||||
starving.value = null;
|
||||
health.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||
}
|
||||
@@ -751,7 +756,7 @@ class SessionCoordinator {
|
||||
|
||||
Future<void> dispose() async {
|
||||
await leave(silent: true);
|
||||
await _roomSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
await _feedSub?.cancel();
|
||||
await _kickSub?.cancel();
|
||||
await _errSub?.cancel();
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
|
||||
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
|
||||
serve(args.$1, events: args.$2);
|
||||
|
||||
/// The hosted server, running [serve] on its own isolate. Its log lines
|
||||
/// go to [onLog]; if the isolate dies without being asked to stop,
|
||||
/// [onCrash] gets the cause once.
|
||||
class ServerIsolate {
|
||||
final Isolate _isolate;
|
||||
final SendPort _stop;
|
||||
final int port;
|
||||
final ReceivePort _events;
|
||||
final ReceivePort _errors;
|
||||
final ReceivePort _exit;
|
||||
final Completer<void> _exited = Completer<void>();
|
||||
bool _stopping = false;
|
||||
String? _lastError;
|
||||
void Function(String error)? _onCrash;
|
||||
|
||||
ServerIsolate._(this._isolate, this._stop, this.port, this._events,
|
||||
this._errors, this._exit);
|
||||
|
||||
@visibleForTesting
|
||||
Isolate get isolate => _isolate;
|
||||
|
||||
bool get isRunning => !_exited.isCompleted;
|
||||
|
||||
/// Spawns the server and waits until it listens. Throws a
|
||||
/// [StateError] with the server's message if it cannot start.
|
||||
static Future<ServerIsolate> start(
|
||||
AppConfig config, {
|
||||
required void Function(String line) onLog,
|
||||
required void Function(String error) onCrash,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
final events = ReceivePort('server events');
|
||||
final errors = ReceivePort('server errors');
|
||||
final exit = ReceivePort('server exit');
|
||||
final startup = Completer<(int, SendPort)>();
|
||||
String? lastError;
|
||||
var exited = false;
|
||||
ServerIsolate? self;
|
||||
|
||||
void fail(String message) {
|
||||
if (!startup.isCompleted) startup.completeError(StateError(message));
|
||||
}
|
||||
|
||||
events.listen((msg) {
|
||||
if (msg is! Map) return;
|
||||
switch (msg['t']) {
|
||||
case 'log':
|
||||
final err = msg['error'];
|
||||
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
|
||||
'${err == null ? '' : ' ($err)'}');
|
||||
case 'ready':
|
||||
if (!startup.isCompleted) {
|
||||
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
|
||||
}
|
||||
case 'error':
|
||||
fail('${msg['message']}');
|
||||
}
|
||||
});
|
||||
// errorsAreFatal is off: a stray async error is logged and the server
|
||||
// keeps going, as it did in-process. Only a dead isolate is a crash.
|
||||
errors.listen((msg) {
|
||||
final text = msg is List && msg.isNotEmpty ? '${msg.first}' : '$msg';
|
||||
lastError = text;
|
||||
self?._lastError = text;
|
||||
onLog('SEVERE uncaught: $text');
|
||||
});
|
||||
exit.listen((_) {
|
||||
exited = true;
|
||||
fail(lastError ?? 'server exited before it was ready');
|
||||
self?._onExit();
|
||||
});
|
||||
|
||||
final Isolate isolate;
|
||||
try {
|
||||
isolate = await Isolate.spawn(
|
||||
_serverMain, (config.toMap(), events.sendPort),
|
||||
errorsAreFatal: false,
|
||||
onError: errors.sendPort,
|
||||
onExit: exit.sendPort,
|
||||
debugName: 'server');
|
||||
} catch (_) {
|
||||
events.close();
|
||||
errors.close();
|
||||
exit.close();
|
||||
rethrow;
|
||||
}
|
||||
final (int, SendPort) ready;
|
||||
try {
|
||||
ready = await startup.future.timeout(timeout);
|
||||
} catch (e) {
|
||||
if (!exited) isolate.kill(priority: Isolate.immediate);
|
||||
events.close();
|
||||
errors.close();
|
||||
exit.close();
|
||||
if (e is TimeoutException) {
|
||||
throw StateError('server did not start within ${timeout.inSeconds} s');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
final s = ServerIsolate._(isolate, ready.$2, ready.$1, events, errors, exit)
|
||||
.._onCrash = onCrash
|
||||
.._lastError = lastError;
|
||||
self = s;
|
||||
if (exited) {
|
||||
// Died between ready and here: a failed start, not a crash.
|
||||
s._stopping = true;
|
||||
s._onExit();
|
||||
throw StateError(lastError ?? 'server exited right after starting');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void _onExit() {
|
||||
if (_exited.isCompleted) return;
|
||||
_exited.complete();
|
||||
_events.close();
|
||||
_errors.close();
|
||||
_exit.close();
|
||||
final crash = _onCrash;
|
||||
_onCrash = null;
|
||||
if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly');
|
||||
}
|
||||
|
||||
/// Asks the server to close its sockets and exit; kills the isolate if it
|
||||
/// has not exited within [timeout].
|
||||
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
|
||||
if (_exited.isCompleted) return;
|
||||
_stopping = true;
|
||||
_stop.send({'t': 'stop'});
|
||||
try {
|
||||
await _exited.future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
_isolate.kill(priority: Isolate.immediate);
|
||||
await _exited.future.timeout(const Duration(seconds: 2),
|
||||
onTimeout: _onExit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
|
||||
import '../sfx/sfx.dart';
|
||||
import '../session/health.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../widgets/avatar_pipeline.dart';
|
||||
import 'connect_prefs.dart';
|
||||
import 'server_isolate.dart';
|
||||
|
||||
enum AppRoute {
|
||||
home,
|
||||
@@ -128,6 +129,21 @@ List<RecentServer> migrateRecents(
|
||||
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
||||
}
|
||||
|
||||
/// The hosted server died during setup; shown on the Host card.
|
||||
class ServerCrashed implements Exception {
|
||||
final String cause;
|
||||
const ServerCrashed(this.cause);
|
||||
@override
|
||||
String toString() => 'server crashed: $cause';
|
||||
}
|
||||
|
||||
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||
/// recognised as the (unkickable) host.
|
||||
String newHostKey() {
|
||||
final r = Random.secure();
|
||||
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||
}
|
||||
|
||||
class SessionState {
|
||||
static final SessionState instance = SessionState._();
|
||||
SessionState._();
|
||||
@@ -135,11 +151,17 @@ class SessionState {
|
||||
final route = signal(AppRoute.home);
|
||||
final recents = signal<List<RecentServer>>(const []);
|
||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||
final room = signal<RoomBroadcast?>(null);
|
||||
final SfxService sfx = const NoopSfxService();
|
||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||
final lastForm = ConnectForm();
|
||||
RoomServer? _server;
|
||||
/// Set when the hosted server died mid-session; Home shows it on the
|
||||
/// Host card and clears it.
|
||||
final serverCrash = signal<String?>(null);
|
||||
ServerIsolate? _server;
|
||||
String? _setupCrash;
|
||||
|
||||
@visibleForTesting
|
||||
ServerIsolate? get hostedServer => _server;
|
||||
void Function()? _stopKickWatch;
|
||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||
// next step and unwinds instead of connecting.
|
||||
@@ -208,18 +230,25 @@ class SessionState {
|
||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||
await stopServer();
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
final server = RoomServer(AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'room',
|
||||
mediaPath: params.mediaPath,
|
||||
));
|
||||
final hostKey = newHostKey();
|
||||
_setupCrash = null;
|
||||
late final ServerIsolate server;
|
||||
try {
|
||||
await server.start();
|
||||
server = await ServerIsolate.start(
|
||||
AppConfig(
|
||||
port: params.port,
|
||||
secret: params.secret,
|
||||
name: 'server',
|
||||
mediaPath: params.mediaPath,
|
||||
hostKey: hostKey,
|
||||
),
|
||||
onLog: (line) => debugPrint('[server] $line'),
|
||||
onCrash: (cause) => _onServerCrash(server, cause),
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('server start failed: $e\n$st');
|
||||
status.value = HealthStatus(
|
||||
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
||||
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
|
||||
rethrow;
|
||||
}
|
||||
if (op != _op) {
|
||||
@@ -228,20 +257,23 @@ class SessionState {
|
||||
}
|
||||
_server = server;
|
||||
status.value =
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
|
||||
try {
|
||||
await coordinator.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
port: server.port,
|
||||
secret: params.secret,
|
||||
name: params.name,
|
||||
avatar: params.avatar,
|
||||
linkUrl: params.linkUrl,
|
||||
hostKey: hostKey,
|
||||
));
|
||||
if (op != _op) throw const ConnectCancelled();
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
// Own join failed or was cancelled: don't leave the port open.
|
||||
if (identical(_server, server)) await stopServer();
|
||||
final crash = _setupCrash;
|
||||
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||
rethrow;
|
||||
}
|
||||
_watchKick();
|
||||
@@ -251,6 +283,21 @@ class SessionState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server isolate died without being stopped. In a session: show it
|
||||
/// and leave. During setup the own join fails and [host] reports it.
|
||||
void _onServerCrash(ServerIsolate server, String cause) {
|
||||
if (!identical(_server, server)) return;
|
||||
final message = 'server crashed: $cause';
|
||||
debugPrint('[server] $message');
|
||||
if (route.peek() != AppRoute.session) {
|
||||
_setupCrash = cause;
|
||||
return;
|
||||
}
|
||||
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
|
||||
serverCrash.value = message;
|
||||
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
|
||||
}
|
||||
|
||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
||||
final op = ++_op;
|
||||
_rememberForm(form);
|
||||
@@ -305,15 +352,6 @@ class SessionState {
|
||||
..linkUrl = form.linkUrl;
|
||||
}
|
||||
|
||||
Future<void> startHeadless(List<String> args) async {
|
||||
int port = 8080;
|
||||
for (var i = 0; i < args.length - 1; i++) {
|
||||
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
||||
}
|
||||
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
|
||||
await _server!.start();
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
_stopKickWatch?.call();
|
||||
_stopKickWatch = null;
|
||||
|
||||
+32
-32
@@ -19,9 +19,9 @@ class RateAction extends PlayerAction {
|
||||
const RateAction(this.rate);
|
||||
}
|
||||
|
||||
double roomPositionAt(RoomBroadcast room, DateTime now) {
|
||||
if (room.transport != 'playing') return room.position;
|
||||
return room.position + now.difference(room.serverTime).inMilliseconds / 1000.0;
|
||||
double serverPositionAt(StateBroadcast state, DateTime now) {
|
||||
if (state.transport != 'playing') return state.position;
|
||||
return state.position + now.difference(state.serverTime).inMilliseconds / 1000.0;
|
||||
}
|
||||
|
||||
bool seekLanded({required double ownPosition, required double target}) {
|
||||
@@ -31,9 +31,9 @@ bool seekLanded({required double ownPosition, required double target}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool localPlayAllowed(RoomBroadcast room, String memberId) {
|
||||
bool localPlayAllowed(StateBroadcast state, String memberId) {
|
||||
if (memberId.isEmpty) return false;
|
||||
for (final m in room.members) {
|
||||
for (final m in state.members) {
|
||||
if (m.id == memberId) return m.inSet && m.ready;
|
||||
}
|
||||
return false;
|
||||
@@ -45,7 +45,7 @@ bool localPlayAllowed(RoomBroadcast room, String memberId) {
|
||||
bool shouldReassertReady(MemberView? self) =>
|
||||
self != null && self.ready && !self.inSet && !self.stale;
|
||||
|
||||
class RoomDiff {
|
||||
class StateDiff {
|
||||
static const debounceWindow = Duration(seconds: 2);
|
||||
|
||||
int _lastEventId = -1;
|
||||
@@ -57,14 +57,14 @@ class RoomDiff {
|
||||
|
||||
bool get slowing => _slowing;
|
||||
|
||||
List<PlayerAction> diff(RoomBroadcast room, double ownPosition, DateTime now) {
|
||||
List<PlayerAction> diff(StateBroadcast state, double ownPosition, DateTime now) {
|
||||
final out = <PlayerAction>[];
|
||||
final roomPos = roomPositionAt(room, now);
|
||||
final serverPos = serverPositionAt(state, now);
|
||||
|
||||
if (room.mediaId != _lastMediaId && _lastMediaId != -1) {
|
||||
_lastEventId = room.eventId;
|
||||
_lastTransport = room.transport;
|
||||
_lastMediaId = room.mediaId;
|
||||
if (state.mediaId != _lastMediaId && _lastMediaId != -1) {
|
||||
_lastEventId = state.eventId;
|
||||
_lastTransport = state.transport;
|
||||
_lastMediaId = state.mediaId;
|
||||
_slowing = false;
|
||||
_deviatingSince = null;
|
||||
out.add(const SeekAction(0));
|
||||
@@ -74,39 +74,39 @@ class RoomDiff {
|
||||
}
|
||||
|
||||
if (_lastEventId == -1) {
|
||||
_lastEventId = room.eventId;
|
||||
_lastTransport = room.transport;
|
||||
_lastMediaId = room.mediaId;
|
||||
_lastEventId = state.eventId;
|
||||
_lastTransport = state.transport;
|
||||
_lastMediaId = state.mediaId;
|
||||
_deviatingSince = null;
|
||||
out.add(SeekAction(roomPos));
|
||||
out.add(TransportAction(room.transport == 'playing'));
|
||||
out.add(SeekAction(serverPos));
|
||||
out.add(TransportAction(state.transport == 'playing'));
|
||||
return out;
|
||||
}
|
||||
|
||||
if (room.eventId != _lastEventId) {
|
||||
_lastEventId = room.eventId;
|
||||
if (state.eventId != _lastEventId) {
|
||||
_lastEventId = state.eventId;
|
||||
_slowing = false;
|
||||
_deviatingSince = null;
|
||||
out.add(const RateAction(1.0));
|
||||
out.add(SeekAction(roomPos));
|
||||
if (room.transport != _lastTransport) {
|
||||
_lastTransport = room.transport;
|
||||
out.add(TransportAction(room.transport == 'playing'));
|
||||
out.add(SeekAction(serverPos));
|
||||
if (state.transport != _lastTransport) {
|
||||
_lastTransport = state.transport;
|
||||
out.add(TransportAction(state.transport == 'playing'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (room.transport != _lastTransport) {
|
||||
_lastTransport = room.transport;
|
||||
if (state.transport != _lastTransport) {
|
||||
_lastTransport = state.transport;
|
||||
_slowing = false;
|
||||
_deviatingSince = null;
|
||||
out.add(const RateAction(1.0));
|
||||
out.add(TransportAction(room.transport == 'playing'));
|
||||
out.add(SeekAction(roomPos));
|
||||
out.add(TransportAction(state.transport == 'playing'));
|
||||
out.add(SeekAction(serverPos));
|
||||
return out;
|
||||
}
|
||||
|
||||
final drift = ownPosition - roomPos;
|
||||
final drift = ownPosition - serverPos;
|
||||
if (_slowing) {
|
||||
if (drift.abs() < 0.1) {
|
||||
_slowing = false;
|
||||
@@ -114,10 +114,10 @@ class RoomDiff {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
switch (correctionFor(ownPosition: ownPosition, roomPosition: roomPos)) {
|
||||
switch (correctionFor(ownPosition: ownPosition, serverPosition: serverPos)) {
|
||||
case Correction.seek:
|
||||
if (_deviatingEvent != room.eventId || _deviatingSince == null) {
|
||||
_deviatingEvent = room.eventId;
|
||||
if (_deviatingEvent != state.eventId || _deviatingSince == null) {
|
||||
_deviatingEvent = state.eventId;
|
||||
_deviatingSince = now;
|
||||
break;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ class RoomDiff {
|
||||
break;
|
||||
}
|
||||
_deviatingSince = null;
|
||||
out.add(SeekAction(roomPos));
|
||||
out.add(SeekAction(serverPos));
|
||||
case Correction.slowDown:
|
||||
_deviatingSince = null;
|
||||
_slowing = true;
|
||||
@@ -9,13 +9,13 @@ enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
||||
class SyncClient {
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _sub;
|
||||
final StreamController<RoomBroadcast> _rooms = StreamController.broadcast();
|
||||
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||
final StreamController<String> _kicked = StreamController.broadcast();
|
||||
final StreamController<String> _errors = StreamController.broadcast();
|
||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||
|
||||
Stream<RoomBroadcast> get rooms => _rooms.stream;
|
||||
Stream<StateBroadcast> get states => _states.stream;
|
||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||
Stream<String> get kicked => _kicked.stream;
|
||||
Stream<String> get errors => _errors.stream;
|
||||
@@ -24,7 +24,7 @@ class SyncClient {
|
||||
String token = '';
|
||||
String memberId = '';
|
||||
String? mediaUrl;
|
||||
RoomBroadcast? lastRoom;
|
||||
StateBroadcast? lastState;
|
||||
Completer<WelcomeMessage>? _pendingWelcome;
|
||||
|
||||
Future<WelcomeMessage> connect({
|
||||
@@ -33,6 +33,7 @@ class SyncClient {
|
||||
required String name,
|
||||
String avatar = '',
|
||||
HelloMedia? media,
|
||||
String hostKey = '',
|
||||
}) async {
|
||||
await disconnect();
|
||||
_status.add(SyncStatus.connecting);
|
||||
@@ -61,6 +62,7 @@ class SyncClient {
|
||||
secret: secret,
|
||||
user: HelloUser(name: name, avatar: avatar),
|
||||
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
||||
hostKey: hostKey,
|
||||
);
|
||||
ch.sink.add(jsonEncode(hello.toJson()));
|
||||
_pendingWelcome = welcomeCompleter;
|
||||
@@ -74,8 +76,8 @@ class SyncClient {
|
||||
mediaUrl = welcome.mediaUrl;
|
||||
if (welcome.state != null) {
|
||||
try {
|
||||
lastRoom = RoomBroadcast.fromJson(welcome.state!);
|
||||
_rooms.add(lastRoom!);
|
||||
lastState = StateBroadcast.fromJson(welcome.state!);
|
||||
_states.add(lastState!);
|
||||
} catch (_) {}
|
||||
}
|
||||
_status.add(SyncStatus.open);
|
||||
@@ -113,11 +115,11 @@ class SyncClient {
|
||||
return;
|
||||
}
|
||||
switch (t) {
|
||||
case 'room':
|
||||
case 'state':
|
||||
try {
|
||||
final room = RoomBroadcast.fromJson(decoded);
|
||||
lastRoom = room;
|
||||
_rooms.add(room);
|
||||
final state = StateBroadcast.fromJson(decoded);
|
||||
lastState = state;
|
||||
_states.add(state);
|
||||
} catch (_) {}
|
||||
case 'feed':
|
||||
try {
|
||||
@@ -219,12 +221,12 @@ class SyncClient {
|
||||
token = '';
|
||||
memberId = '';
|
||||
mediaUrl = null;
|
||||
lastRoom = null;
|
||||
lastState = null;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _rooms.close();
|
||||
await _states.close();
|
||||
await _feeds.close();
|
||||
await _kicked.close();
|
||||
await _errors.close();
|
||||
|
||||
Reference in new issue
Block a user