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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user