Restructure step 1: lasting identity, presence and protocol 16

Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 21:39:47 +03:00
1 parent 51c1c5ab78
commit 9f2586b7ff
85 files changed
+5198 -1902

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ void main() {
position: 0,
serverTime: DateTime.now().toUtc(),
mediaId: 0,
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0, stale: false)],
members: [MemberView(id: 'a', name: 'Alex', ready: false, inSet: false, buffered: 0)],
);
session.route.value = AppRoute.session;
await tester.pumpAndSettle();
+162
View File
@@ -0,0 +1,162 @@
// The Connection against a scripted fake server: what a hello says, a
// reconnect's hello, refusals in plain words, the backoff.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/session/connection.dart';
import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/sync/sync_client.dart';
/// A `/sync` endpoint that hands each socket and its hello to [onHello].
class _Fake {
final HttpServer _server;
final hellos = <Map<String, Object?>>[];
void Function(WebSocket ws, Map<String, Object?> hello) onHello;
_Fake(this._server, this.onHello) {
_server.listen((req) async {
final ws = await WebSocketTransformer.upgrade(req);
var first = true;
ws.listen((m) {
if (!first) return;
first = false;
final hello = Map<String, Object?>.from(jsonDecode('$m') as Map);
hellos.add(hello);
onHello(ws, hello);
}, onError: (_) {});
});
}
static Future<_Fake> start(void Function(WebSocket ws, Map<String, Object?> hello) onHello) async =>
_Fake(await HttpServer.bind(InternetAddress.loopbackIPv4, 0), onHello);
int get port => _server.port;
Future<void> close() => _server.close(force: true);
}
void _welcome(WebSocket ws, {String instance = 'boot-1', bool resumed = false}) => ws.add(jsonEncode(WelcomeMessage(
token: 'tok', memberId: 'm1', instance: instance, resumed: resumed,
state: StateBroadcast(
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.now().toUtc(), mediaId: 0, members: const [])
.toJson())
.toJson()));
Future<void> _poll(FutureOr<bool> Function() cond, String what, {Duration timeout = const Duration(seconds: 10)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(const Duration(milliseconds: 50));
}
fail('timed out waiting for $what');
}
ConnectParams _params(int port) => ConnectParams(host: '127.0.0.1', port: port, secret: 's', name: 'me');
void main() {
test('the hello: version range, client id, a rising seq, app, platform, caps; feedAfter only on a reconnect',
() async {
final fake = await _Fake.start((ws, hello) {
_welcome(ws);
// One feed line, then the link dies.
ws.add(jsonEncode(FeedMessage(actor: 'Ann', action: 'join', at: DateTime.now().toUtc(), seq: 5).toJson()));
});
final id = ClientIdentity.ephemeral();
final c = Connection(identity: id, caps: () => const ClientCaps(source: true, resolve: true),
reconnectDelay: const Duration(milliseconds: 50));
try {
c.begin(_params(fake.port));
await c.dial(c.invalidate());
await _poll(() => c.client.lastFeedSeq == 5, 'the feed line');
final h = fake.hellos.single;
expect(h['proto'], RelayConstants.protoMax);
expect(h['protoMin'], RelayConstants.protoMin);
expect(h['client'], id.clientId);
expect((h['client'] as String).length, 22);
expect(validClientId(h['client'] as String), isTrue);
expect(h['seq'], 1);
expect(h['app'], appVersion);
expect(h['platform'], Platform.operatingSystem);
expect(h['caps'], {'source': true, 'resolve': true});
expect(h.containsKey('feedAfter'), isFalse, reason: 'a first join replays nothing');
expect(h['media'], <String, Object?>{}, reason: 'a proto 15 server answers its own proto mismatch');
await c.client.drop();
// A dropped socket: no status from it; drive the drop the way the
// silence check does.
c.scheduleReconnect();
await _poll(() => fake.hellos.length == 2 && c.state.peek() is ConnOnline, 'the reconnect');
expect(fake.hellos[1]['seq'], 2);
expect(fake.hellos[1]['client'], id.clientId);
expect(fake.hellos[1]['feedAfter'], 5);
} finally {
await c.dispose();
await fake.close();
}
});
test('a proto 15 host refuses in its own words: "the host runs an older Starsync"', () async {
final fake = await _Fake.start((ws, hello) {
ws.add(jsonEncode({'t': 'err', 'message': 'proto mismatch: got 16, want 15'}));
ws.close(1008, 'refused');
});
final c = Connection();
try {
c.begin(_params(fake.port));
await expectLater(c.dial(c.invalidate()), throwsA(isA<HelloRefused>()));
final s = c.state.peek();
expect(s, isA<ConnRefused>());
expect((s as ConnRefused).code, 'version');
expect(s.message, 'the host runs an older Starsync — they need to update');
} finally {
await c.dispose();
await fake.close();
}
});
test('refusals in plain words', () {
HelloRefused v({int? min, int? max}) =>
HelloRefused(error: ErrorFrame(code: ErrCode.version, message: 'proto mismatch', min: min, max: max));
expect(connectErrorText(v(min: 15, max: 15)), contains('older Starsync'));
expect(connectErrorText(v(min: RelayConstants.protoMax + 1, max: RelayConstants.protoMax + 2)),
contains('newer Starsync'));
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.secret, message: 'wrong secret'))),
'wrong server password (401)');
expect(connectErrorText(const HelloRefused(closeCode: CloseCodes.replaced)), replacedText);
expect(connectErrorText(const HelloRefused(error: ErrorFrame(code: ErrCode.stale, message: 'older'))), replacedText);
});
test('backoff: about 1 s first, doubling, capped near 15 s, with jitter', () {
final c = Connection();
for (var i = 0; i < 20; i++) {
final first = c.delayFor(1).inMilliseconds;
expect(first, inInclusiveRange(800, 1200));
expect(c.delayFor(2).inMilliseconds, inInclusiveRange(1600, 2400));
expect(c.delayFor(30).inMilliseconds, inInclusiveRange(12000, 18000));
}
unawaited(c.dispose());
});
test('replaced is terminal: no more attempts', () async {
late WebSocket held;
final fake = await _Fake.start((ws, hello) {
held = ws;
_welcome(ws);
});
final c = Connection(reconnectDelay: const Duration(milliseconds: 50));
try {
c.begin(_params(fake.port));
await c.dial(c.invalidate());
held.add(jsonEncode(const ErrorFrame(code: ErrCode.replaced, message: 'another connection').toJson()));
await held.close(CloseCodes.replaced, 'replaced');
await _poll(() => c.state.peek() is ConnReplaced, 'replaced');
await Future.delayed(const Duration(milliseconds: 500));
expect(c.state.peek(), isA<ConnReplaced>());
expect(fake.hellos, hasLength(1), reason: 'it never dials again by itself');
} finally {
await c.dispose();
await fake.close();
}
});
}
+394 -219
View File
@@ -1,18 +1,31 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): reconnect,
// resume and ready restore; "feed and chat toasts" in step 2 (Notifier).
// restructure: rewrite in step 2 (Status + Notifier): "feed and chat toasts
// name their actor exactly".
//
// The session through drops, takeovers and goodbyes: real coordinators
// (real mpv), a real server, and FlakyProxy between a member and it.
import 'dart:async';
import 'dart:io';
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:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/connection.dart';
import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/session/notifier.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/sync/sync_client.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<void> _poll(
FutureOr<bool> Function() cond,
String what, {
@@ -26,224 +39,38 @@ Future<void> _poll(
fail('timed out waiting for $what');
}
void main() {
test(
'dead server clears server state and reconnects instead of freezing',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final server = SyncServer(
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
await server.start();
final coord = SessionCoordinator();
try {
await coord.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: 'presence',
name: 'watcher',
));
await coord.playback.player.setVolume(0);
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
expect(coord.reconnecting.peek(), isFalse);
/// Two minutes of small test video with sound (headless mpv needs audio).
Future<String> _media() async {
final path = '$_tmp/coord_presence_120s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=160x90:rate=12',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=22050',
'-t', '120', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '12', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '32k', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
await server.stop();
await _poll(
() => coord.reconnecting.peek() == true,
'reconnect loop to engage',
);
await _poll(
() => coord.serverState.peek() == null,
'stale state (ghost members) to clear',
);
coord.cancelReconnect();
expect(coord.reconnecting.peek(), isFalse);
} finally {
await coord.dispose();
/// Serves [file] with ranges: a link everyone opens directly, so only the
/// sync socket goes through the proxy.
Future<HttpServer> _origin(String file) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
s.listen((req) async {
final size = await File(file).length();
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: size, filename: 'v.mkv');
req.response.statusCode = r.status;
r.headers.forEach(req.response.headers.set);
try {
if (r.status == 200 || r.status == 206) {
await req.response.addStream(File(file).openRead(r.start, (r.end ?? size - 1) + 1));
}
},
);
test(
'cancel then retry within the reconnect delay dials only once',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = SyncServer(
const AppConfig(port: 0, secret: 'race', name: 'race'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
SyncServer? second;
final observer = SyncClient();
try {
await coord.connect(ConnectParams(
host: '127.0.0.1', port: port, secret: 'race', name: 'watcher'));
await coord.playback.player.setVolume(0);
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = SyncServer(
AppConfig(port: port, secret: 'race', name: 'race'));
await second.start();
var joins = 0;
final sub = observer.feeds.listen((f) {
if (f.action == 'join' && f.actor == 'watcher') joins++;
});
await observer.connect(
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
secret: 'race',
name: 'observer');
// Within the 1.5 s delay: the first attempt must die with Cancel.
coord.cancelReconnect();
coord.retry();
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
'reconnect to land');
await Future.delayed(const Duration(seconds: 4));
await sub.cancel();
expect(joins, 1, reason: 'a stale attempt dialled a second time');
expect(
second.hub.engine.members.values
.where((m) => m.name == 'watcher')
.length,
1);
} finally {
await observer.dispose();
await coord.dispose();
await second?.stop();
}
},
);
test(
'reconnect restores the server-side ready flag (force-all included)',
timeout: const Timeout(Duration(minutes: 2)),
() async {
MediaKit.ensureInitialized();
final first = SyncServer(
const AppConfig(port: 0, secret: 'force', name: 'force'));
await first.start();
final port = first.boundPort;
final coord = SessionCoordinator();
final friend = SyncClient();
SyncServer? second;
try {
await coord.connect(ConnectParams(
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
await coord.playback.player.setVolume(0);
await friend.connect(
wsUri: Uri.parse('ws://127.0.0.1:$port/sync'),
secret: 'force',
name: 'friend');
// Never pressed Space locally; a friend marks everyone ready.
friend.sendForce('all', true);
await _poll(() => coord.selfMember.peek()?.ready == true,
'forced ready to reach watcher');
await friend.disconnect();
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = SyncServer(
AppConfig(port: port, secret: 'force', name: 'force'));
await second.start();
await _poll(
() => second!.hub.engine.members.values
.any((m) => m.name == 'watcher' && m.ready),
'watcher to come back ready');
} finally {
await friend.dispose();
await coord.dispose();
await second?.stop();
}
},
);
test(
'cancel during connect setup aborts without joining',
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
final server = SyncServer(
const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
final coord = SessionCoordinator();
try {
final pending = coord.connect(ConnectParams(
host: '127.0.0.1',
port: server.boundPort,
secret: '',
name: 'watcher'));
await coord.cancelConnect();
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
await Future.delayed(const Duration(seconds: 2));
expect(server.hub.engine.members, isEmpty);
expect(coord.reconnecting.peek(), isFalse);
} finally {
await coord.dispose();
await server.stop();
}
},
);
test(
'cancelling a host setup releases the port',
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
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();
final session = SessionState.instance;
try {
final pending = session.host(
ConnectParams(host: '', port: port, secret: '', name: 'host'),
ConnectForm());
await session.cancelPending();
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
expect(session.route.peek(), isNot(AppRoute.session));
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close();
} finally {
await session.coordinator.leave(silent: true);
await session.stopServer();
}
},
);
test(
'feed and chat toasts name their actor exactly',
timeout: const Timeout(Duration(minutes: 1)),
() async {
MediaKit.ensureInitialized();
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start();
final sounds = _RecordingSfx();
final coord = SessionCoordinator(sfx: sounds);
final friend = SyncClient();
try {
await coord.connect(ConnectParams(
host: '127.0.0.1', port: server.boundPort, secret: 'toast', name: 'Bob'));
await coord.playback.player.setVolume(0);
await friend.connect(
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
friend.sendChat('hi there');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr');
// Others' chat makes a sound, your own does not.
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
coord.sendChat('mine');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
expect(sounds.kinds, contains(SfxKind.join));
} finally {
await friend.dispose();
await coord.dispose();
await server.stop();
}
},
);
await req.response.close();
} catch (_) {}
});
return s;
}
class _RecordingSfx implements SfxService {
@@ -251,3 +78,351 @@ class _RecordingSfx implements SfxService {
@override
Future<void> play(SfxCue cue) async => kinds.add(cue.kind);
}
void main() {
late List<SessionCoordinator> all;
setUp(() {
MediaKit.ensureInitialized();
all = [];
});
tearDown(() async {
for (final c in all) {
await c.dispose();
}
});
/// A member: its own install unless [identity] is given.
Future<SessionCoordinator> member(int port, String name,
{String secret = 'pw', _RecordingSfx? sfx, ClientIdentity? identity}) async {
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
await acc.ensureLoaded(validate: false);
final n = Notifier(sfx: sfx ?? const NoopSfxService());
final c = SessionCoordinator(
notifier: n,
connection: Connection(notify: n.notify, identity: identity),
jellyfin: acc,
trackPrefs: TrackPrefs(store: MemoryJellyfinStore()),
ytDlp: AppYtDlp(YtDlp('$_tmp/coord_presence_no_ytdlp', enabled: false)));
all.add(c);
await c.connect(ConnectParams(host: '127.0.0.1', port: port, secret: secret, name: name));
await c.playback.player.setVolume(0);
return c;
}
/// [up] puts the video up, everyone readies, it plays.
Future<void> playTogether(SessionCoordinator up, List<SessionCoordinator> xs, String link) async {
expect(await up.swapPicked(LinkMedia(link, anyway: true)), isNull);
await _poll(() => xs.every((x) => x.playback.durationSecs > 100), 'all opened it', timeout: const Duration(seconds: 30));
for (final x in xs) {
x.toggleReady();
}
await _poll(() => xs.every((x) => x.playback.player.state.playing), 'all playing');
}
test('a drop and return while playing: same member, nothing reopened, the state kept throughout',
timeout: const Timeout(Duration(minutes: 2)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'drop'));
await server.start();
final origin = await _origin(await _media());
final proxy = await FlakyProxy.start(server.boundPort);
try {
final alice = await member(server.boundPort, 'alice');
final sounds = _RecordingSfx();
final bob = await member(proxy.port, 'bob', sfx: sounds);
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
final id = bob.connection.memberId;
final token = bob.connection.token;
final opens = bob.opens;
final mediaId = bob.serverState.peek()!.mediaId;
// Bob's link goes half-open (no FIN): unstable from ~3 s, lost at
// ~9 s, then a new dial through a link that works.
final link = proxy.links.single;
link.blackhole();
var sawStale = false, sawUnstable = false, lostState = false;
await _poll(() {
final s = bob.serverState.peek();
if (s == null || s.media == null) lostState = true;
if (bob.stateStale.peek()) sawStale = true;
if (bob.unstable.peek()) sawUnstable = true;
return proxy.links.length > 1 && bob.connection.state.peek() is ConnOnline;
}, 'bob back online', timeout: const Duration(seconds: 30));
expect(lostState, isFalse, reason: 'the state (and the update band off the video) survives the drop');
expect(sawUnstable, isTrue, reason: '"connection unstable" before lost');
expect(sawStale, isTrue);
expect(bob.connection.memberId, id, reason: 'the same member');
expect(bob.connection.token, token);
expect(bob.opens, opens, reason: 'the player kept the media');
expect(bob.serverState.peek()!.mediaId, mediaId);
// Playing on (a drift correction on the return may seek once).
await _poll(() => bob.playback.player.state.playing, 'bob playing', timeout: const Duration(seconds: 3));
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
// A drop that long is heard once each way.
await _poll(() => sounds.kinds.contains(SfxKind.back), 'back sound');
expect(sounds.kinds.where((k) => k == SfxKind.lost), hasLength(1));
expect(sounds.kinds.where((k) => k == SfxKind.back), hasLength(1));
// In sync again.
await Future.delayed(const Duration(seconds: 3));
final gap = (alice.playback.positionSecs - bob.playback.positionSecs).abs();
expect(gap, lessThan(1.5), reason: 'in sync after the return');
} finally {
await proxy.close();
await origin.close(force: true);
await server.stop();
}
});
test('10 flaps in 60 s: at most one lost/back sound, nothing reopened, one member',
timeout: const Timeout(Duration(minutes: 3)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'flaps'));
await server.start();
final origin = await _origin(await _media());
final proxy = await FlakyProxy.start(server.boundPort);
try {
final alice = await member(server.boundPort, 'alice');
final sounds = _RecordingSfx();
final bob = await member(proxy.port, 'bob', sfx: sounds);
await playTogether(alice, [alice, bob], 'http://127.0.0.1:${origin.port}/ep.mkv');
final id = bob.connection.memberId;
final opens = bob.opens;
final watcher = SyncClient();
final feed = <FeedMessage>[];
final sub = watcher.feeds.listen(feed.add);
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
// The sync socket: the first link, then each one dialled while
// reconnecting (the session log upload and pictures go through the
// proxy too).
var sync = proxy.links.single;
final dials = proxy.connections.listen((l) {
if (bob.connection.state.peek().retrying) sync = l;
});
var drops = 0;
final stopCount = bob.connection.state.subscribe((s) {
if (s is ConnLost) drops++;
});
try {
for (var i = 0; i < 10; i++) {
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online before flap $i');
sync.reset();
await Future.delayed(const Duration(seconds: 5));
}
await _poll(() => bob.connection.state.peek() is ConnOnline, 'bob online after the flaps');
expect(drops, greaterThanOrEqualTo(10), reason: 'every flap dropped the link');
expect(sounds.kinds.where((k) => k == SfxKind.lost).length, lessThanOrEqualTo(1));
expect(sounds.kinds.where((k) => k == SfxKind.back).length, lessThanOrEqualTo(1));
expect(bob.opens, opens, reason: 'no reopen per flap');
expect(bob.connection.memberId, id);
expect(server.hub.engine.members.values.where((m) => m.name == 'bob'), hasLength(1));
expect(feed.where((f) => f.actorId == id && (f.action == 'join' || f.action == 'leave')), isEmpty,
reason: 'quick returns say nothing to the others');
expect(bob.playback.player.state.playing, isTrue);
} finally {
stopCount();
await dials.cancel();
await sub.cancel();
await watcher.dispose();
}
} finally {
await proxy.close();
await origin.close(force: true);
await server.stop();
}
});
test('replaced: opened in another window, the older one stops for good',
timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'replaced'));
await server.start();
try {
final install = ClientIdentity.ephemeral();
final first = await member(server.boundPort, 'me', identity: install);
final id = first.connection.memberId;
final second = await member(server.boundPort, 'me', identity: install);
expect(second.connection.memberId, id, reason: 'the newer connection took the member over');
await _poll(() => first.connection.state.peek() is ConnReplaced, 'the first one replaced');
expect(first.health.peek().message, replacedText);
// It does not fight back.
await Future.delayed(const Duration(seconds: 4));
expect(first.connection.state.peek(), isA<ConnReplaced>());
expect(first.reconnecting.peek(), isFalse);
expect(second.connection.state.peek(), isA<ConnOnline>());
expect(server.hub.engine.members, hasLength(1));
} finally {
await server.stop();
}
});
test('leave sends bye (others see "left" at once); update and restart does not',
timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'pw', name: 'bye'));
await server.start();
final watcher = SyncClient();
final feed = <FeedMessage>[];
final sub = watcher.feeds.listen(feed.add);
try {
await watcher.connect(wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'pw', name: 'watcher');
final leaver = await member(server.boundPort, 'leaver');
final id = leaver.connection.memberId;
final sw = Stopwatch()..start();
await leaver.leave();
await _poll(() => feed.any((f) => f.action == 'leave' && f.actorId == id), 'leave line',
timeout: const Duration(seconds: 3));
expect(sw.elapsed, lessThan(const Duration(seconds: 3)));
expect(feed.firstWhere((f) => f.action == 'leave' && f.actorId == id).reason, 'bye');
expect(server.hub.engine.byId(id), isNull);
final restarter = await member(server.boundPort, 'restarter');
final rid = restarter.connection.memberId;
await restarter.leave(bye: false);
await _poll(() => server.hub.engine.byId(rid)?.presence == Presence.gone, 'gone, kept for its grace');
await Future.delayed(const Duration(seconds: 1));
expect(feed.where((f) => f.action == 'leave' && f.actorId == rid), isEmpty);
} finally {
await sub.cancel();
await watcher.dispose();
await server.stop();
}
});
test('a dead server: the state stays (stale), the app reconnects, and "stop reconnecting" stops',
timeout: const Timeout(Duration(minutes: 2)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'presence', name: 'presence'));
await server.start();
final coord = await member(server.boundPort, 'watcher', secret: 'presence');
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
expect(coord.reconnecting.peek(), isFalse);
expect(coord.stateStale.peek(), isFalse);
await server.stop();
await _poll(() => coord.reconnecting.peek() == true, 'reconnect loop to engage');
expect(coord.serverState.peek(), isNotNull, reason: 'a drop keeps the last state');
expect(coord.stateStale.peek(), isTrue);
coord.cancelReconnect();
expect(coord.reconnecting.peek(), isFalse);
});
test('cancel then retry within the reconnect delay dials only once',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'race', name: 'race'));
await first.start();
final port = first.boundPort;
SyncServer? second;
final observer = SyncClient();
try {
final coord = await member(port, 'watcher', secret: 'race');
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = SyncServer(AppConfig(port: port, secret: 'race', name: 'race'));
await second.start();
var joins = 0;
final sub = observer.feeds.listen((f) {
if (f.action == 'join' && f.actor == 'watcher') joins++;
});
await observer.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'race', name: 'observer');
// Within the first delay: the first attempt must die with Cancel.
coord.cancelReconnect();
coord.retry();
await _poll(() => !coord.reconnecting.peek() && coord.connection.state.peek().online, 'reconnect to land');
await Future.delayed(const Duration(seconds: 4));
await sub.cancel();
expect(joins, 1, reason: 'a stale attempt dialled a second time');
expect(second.hub.engine.members.values.where((m) => m.name == 'watcher').length, 1);
} finally {
await observer.dispose();
await second?.stop();
}
});
test('a restarted server (not resumed): the app sends its ready again (force-all included)',
timeout: const Timeout(Duration(minutes: 2)), () async {
final first = SyncServer(const AppConfig(port: 0, secret: 'force', name: 'force'));
await first.start();
final port = first.boundPort;
final friend = SyncClient();
SyncServer? second;
try {
final coord = await member(port, 'watcher', secret: 'force');
await friend.connect(wsUri: Uri.parse('ws://127.0.0.1:$port/sync'), secret: 'force', name: 'friend');
// Never pressed Space locally; a friend marks everyone ready.
friend.sendForce('all', true);
await _poll(() => coord.selfMember.peek()?.ready == true, 'forced ready to reach watcher');
await friend.disconnect();
await first.stop();
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
second = SyncServer(AppConfig(port: port, secret: 'force', name: 'force'));
await second.start();
await _poll(() => second!.hub.engine.members.values.any((m) => m.name == 'watcher' && m.ready),
'watcher to come back ready');
} finally {
await friend.dispose();
await second?.stop();
}
});
test('cancel during connect setup aborts without joining', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'cancel'));
await server.start();
final coord = SessionCoordinator();
try {
final pending =
coord.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'watcher'));
await coord.cancelConnect();
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
await Future.delayed(const Duration(seconds: 2));
expect(server.hub.engine.members, isEmpty);
expect(coord.reconnecting.peek(), isFalse);
} finally {
await coord.dispose();
await server.stop();
}
});
test('cancelling a host setup releases the port', timeout: const Timeout(Duration(minutes: 1)), () async {
Hive.init('$_tmp/hive_coord_test');
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final port = probe.port;
await probe.close();
final session = SessionState.instance;
try {
final pending = session.host(ConnectParams(host: '', port: port, secret: '', name: 'host'), ConnectForm());
await session.cancelPending();
await expectLater(pending, throwsA(isA<ConnectCancelled>()));
expect(session.route.peek(), isNot(AppRoute.session));
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
await rebind.close();
} finally {
await session.coordinator.leave(silent: true);
await session.stopServer();
}
});
test('feed and chat toasts name their actor exactly', timeout: const Timeout(Duration(minutes: 1)), () async {
final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast'));
await server.start();
final sounds = _RecordingSfx();
final friend = SyncClient();
try {
final coord = await member(server.boundPort, 'Bob', secret: 'toast', sfx: sounds);
await friend.connect(
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'), secret: 'toast', name: 'Bob Jr');
friend.sendChat('hi there');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob Jr: hi there'), 'chat toast');
final toasts = coord.toasts.peek();
expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr');
final chat = toasts.firstWhere((t) => t.text == 'Bob Jr: hi there');
expect(chat.actor, 'Bob Jr');
expect(chat.actorId, friend.memberId, reason: 'matched by member id');
// Others' chat makes a sound, your own does not.
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
coord.sendChat('mine');
await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat');
expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1));
expect(sounds.kinds, contains(SfxKind.join));
} finally {
await friend.dispose();
await server.stop();
}
});
}
+2 -2
View File
@@ -7,7 +7,7 @@ import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/shell/delivery_line.dart';
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, host: host, rate: rate);
StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) =>
StateBroadcast(
@@ -87,7 +87,7 @@ void main() {
test('Jellyfin: own accounts direct, the rest via server', () {
const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'};
MemberView jf(String id, String name, bool own) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own});
MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, jf: {'srv': own});
final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)];
final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()};
final d = deliveryOf(_state(media, all), selfId: 'm2')!;
+9 -3
View File
@@ -60,9 +60,15 @@ JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
.primarySource!;
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool stale = false}) =>
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool gone = false}) =>
MemberView(
id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, jf: jf);
id: id,
name: name,
ready: true,
inSet: true,
buffered: 10,
presence: gone ? Presence.gone : Presence.watching,
jf: jf);
void main() {
setUp(reports.clear);
@@ -371,7 +377,7 @@ void main() {
member('m10', 'Ann', jf: {fixtureServerId: true}),
member('m2', 'Bob', jf: {fixtureServerId: true}),
member('m1', 'Guest', jf: {fixtureServerId: false}),
member('m0', 'Old', jf: {fixtureServerId: true}, stale: true),
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
member('m3', 'Fresh'),
];
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
+40 -18
View File
@@ -1,6 +1,5 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): the
// feed rows follow the new feed actions one-to-one (away/in/gone/return);
// step 2 (Status + Notifier): every row, when noticeFor takes a context.
// restructure: rewrite in step 2 (Status + Notifier): every row, when
// noticeFor takes a context.
//
// The Notifier policy as a table: one row per AppEvent subtype, what
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
@@ -14,8 +13,21 @@ import 'package:starsync/src/sfx/sfx.dart';
final _at = DateTime.utc(2026, 1, 1);
FeedMessage _feed(String action, {String actor = 'Ann', String? target, String reason = ''}) =>
FeedMessage(actor: actor, action: action, target: target, reason: reason, at: _at);
/// A feed line; people are named and identified (Ann is m1, Bob m2, this
/// member, Me, m9).
FeedMessage _feed(String action,
{String actor = 'Ann', String? target, String reason = '', bool replayed = false}) =>
FeedMessage(
actor: actor,
actorId: _ids[actor] ?? '',
action: action,
target: target,
targetId: _ids[target] ?? '',
reason: reason,
at: _at,
replayed: replayed);
const _ids = {'Ann': 'm1', 'Bob': 'm2', 'Me': 'm9', 'server': 'server'};
/// One row: the event, then what it must do.
class _Row {
@@ -33,22 +45,36 @@ class _Row {
{this.toast, this.actor, this.sound, this.readyIndex = 0, this.attention = false, this.logged = false});
}
/// The feed as the coordinator turns it into events: this member is 'Me',
/// two members ready in the latest state.
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfName: 'Me', readyCount: 2);
/// The feed as the coordinator turns it into events: this member is m9
/// ('Me'), two members ready in the latest state.
FeedEvent _of(FeedMessage f) => FeedEvent.of(f, selfId: 'm9', readyCount: 2);
final _rows = <_Row>[
// ---- the feed (FeedEvent.of, then noticeFor); every line is logged
_Row('join', _of(_feed('join')), MemberJoined,
toast: 'Ann joined', actor: 'Ann', sound: SfxKind.join, logged: true),
_Row('leave', _of(_feed('leave')), MemberLeft,
_Row('leave (bye)', _of(_feed('leave', reason: 'bye')), MemberLeft,
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('stale', _of(_feed('stale')), MemberStale, logged: true),
_Row('out, someone else sat out', _of(_feed('out', target: 'Bob')), MemberSatOut, logged: true),
_Row('out, sat yourself out', _of(_feed('out', actor: 'Me', target: 'Me')), MemberSatOut, logged: true),
_Row('out, someone sat you out', _of(_feed('out', target: 'Me')), MemberSatOut,
_Row('leave (grace over)', _of(_feed('leave', reason: 'grace')), MemberLeft,
toast: 'Ann left', actor: 'Ann', sound: SfxKind.leave, logged: true),
_Row('gone (lost signal)', _of(_feed('gone')), MemberGone, logged: true),
_Row('return', _of(_feed('return')), MemberReturned, logged: true),
_Row('away, someone sat themselves out', _of(_feed('away')), MemberSatOut, logged: true),
_Row('away, someone else auto sat out', _of(_feed('away', reason: 'idle')), MemberSatOut, logged: true),
_Row('away, someone sat someone else out', _of(_feed('away', target: 'Bob')), MemberSatOut, logged: true),
_Row('away, you sat yourself out', _of(_feed('away', actor: 'Me')), MemberSatOut, logged: true),
_Row('away, someone sat you out', _of(_feed('away', target: 'Me')), MemberSatOut,
toast: 'Ann sat you out', actor: 'Ann', sound: SfxKind.kick, logged: true),
_Row('back', _of(_feed('back')), MemberBack, logged: true),
_Row('away, auto sit-out (idle)', _of(_feed('away', actor: 'Me', reason: 'idle')), MemberSatOut,
toast: 'You sit out (idle); you are back as soon as you return', sound: SfxKind.warn, logged: true),
_Row('away, auto sit-out (minimised)', _of(_feed('away', actor: 'Me', reason: 'minimised')), MemberSatOut,
toast: 'You sit out (minimised); you are back as soon as you return', sound: SfxKind.warn, logged: true),
_Row('in (back from away)', _of(_feed('in')), MemberBack, logged: true),
_Row('a replayed join: logged only', _of(_feed('join', replayed: true)), MemberJoined, logged: true),
_Row('a replayed chat: logged only', _of(_feed('chat', reason: 'hi', replayed: true)), ChatPosted, logged: true),
_Row('someone with your name is not you', _of(FeedMessage(
actor: 'Me', actorId: 'm4', action: 'away', target: 'Me', targetId: 'm9', at: _at)), MemberSatOut,
toast: 'Me sat you out', actor: 'Me', sound: SfxKind.kick, logged: true),
_Row('ready', _of(_feed('ready')), ReadyChanged, sound: SfxKind.ready, readyIndex: 2, logged: true),
_Row('unready', _of(_feed('unready')), ReadyChanged, sound: SfxKind.unready, logged: true),
_Row('force-ready', _of(_feed('force-ready')), ForcedReady, sound: SfxKind.forceReady, logged: true),
@@ -87,10 +113,6 @@ final _rows = <_Row>[
_Row('relay started', const RelayStarted(), RelayStarted,
toast: 'Passing the video on through the server. Keep the app open while it plays.'),
_Row('relay failed', const RelayFailed('closed'), RelayFailed, toast: 'Cannot pass the video on: closed'),
_Row('idle, unready', const IdleWarning(sitsOut: false), IdleWarning,
toast: 'idle — auto-unready in 2 min unless you press Space', sound: SfxKind.warn),
_Row('idle, sits out', const IdleWarning(sitsOut: true), IdleWarning,
toast: 'idle — sitting out in 2 min unless you press Space', sound: SfxKind.warn),
_Row('connection lost', const ConnectionLost(), ConnectionLost, sound: SfxKind.lost),
_Row('connection back', const ConnectionBack(), ConnectionBack, sound: SfxKind.back),
_Row('jellyfin, sign-in expired', const JellyfinProgressFailed(signInExpired: true), JellyfinProgressFailed,
+3 -3
View File
@@ -272,8 +272,8 @@ void main() {
testWidgets('the state\'s host flag crowns that member for everyone', (tester) async {
online(const [
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, stale: false, host: true),
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false),
MemberView(id: 'm1', name: 'Hana', ready: true, inSet: true, buffered: 30, host: true),
MemberView(id: 'm2', name: 'Bob', ready: false, inSet: false, buffered: 0),
]);
final avatars = {for (final a in partyAvatars(coord)) a.name: a};
expect(avatars['Hana']!.host, isTrue);
@@ -292,7 +292,7 @@ void main() {
});
testWidgets('hosting: Copy invite address lists this machine\'s addresses', (tester) async {
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0, stale: false)]);
online(const [MemberView(id: 'm1', name: 'Me', ready: false, inSet: false, buffered: 0)]);
coord.hostFile = (_) async => null;
SessionState.instance.lastForm.hostPort = '8080';
bigWindow(tester);
+11 -5
View File
@@ -29,8 +29,14 @@ final png = Uint8List.fromList(const [
]);
MemberView mv(String id, String name,
{bool ready = false, bool? inSet, double buffered = 30, bool stale = false, bool out = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale, out: out);
{bool ready = false, bool? inSet, double buffered = 30, bool gone = false, bool away = false}) =>
MemberView(
id: id,
name: name,
ready: ready,
inSet: inSet ?? ready,
buffered: buffered,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching);
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast(
@@ -103,12 +109,12 @@ void main() {
expect(v.self, isTrue);
final w = playerStatusFor(
health: ok,
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true), mv('o', 'Ola', out: true)]),
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', gone: true), mv('o', 'Ola', away: true)]),
self: me)!;
expect(w.kind, StatusKind.waiting);
expect(w.text, 'Waiting for Bob and Mika', reason: 'nobody waits for who sits out');
expect(w.detail, '1/3 ready');
final outMe = mv('me', 'Me', out: true);
final outMe = mv('me', 'Me', away: true);
expect(playerStatusFor(health: ok, state: held([outMe, mv('b', 'Bob')]), self: outMe), isNull,
reason: 'sitting out: the join-back card speaks');
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
@@ -117,7 +123,7 @@ void main() {
test("can't play: yours with Retry (reload); others' instead of waiting", () {
var v = playerStatusFor(health: ok, state: held([me]), self: me, playerError: 'Not allowed (403)')!;
expect((v.kind, v.text, v.action), (StatusKind.problem, "Can't play this: Not allowed (403)", StatusAction.reload));
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0, stale: false,
final bob = MemberView(id: 'b', name: 'Bob', ready: false, inSet: false, buffered: 0,
error: 'Not allowed (403)');
v = playerStatusFor(health: ok, state: held([me, bob, mv('m', 'Mika', ready: true)]), self: me)!;
expect((v.kind, v.text, v.action), (StatusKind.cannotPlay, "Bob can't play this: Not allowed (403)", null));
+20 -12
View File
@@ -5,9 +5,15 @@ import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id,
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
{bool gone = false, bool away = false, Map<String, bool> jf = const {}, String name = ''}) =>
MemberView(
id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, jf: jf);
id: id,
name: name.isEmpty ? id : name,
ready: false,
inSet: true,
buffered: 0,
presence: gone ? Presence.gone : away ? Presence.away : Presence.watching,
jf: jf);
QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem(
id: 'q1',
@@ -22,12 +28,14 @@ QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => Queue
void main() {
group('queuePerformer', () {
test('a link passed on or a file: only its adder; gone means skipped', () {
test('a link passed on or a file: only its adder; gone waits for it, left means skipped', () {
for (final how in [QueueHow.through, QueueHow.file]) {
expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2');
final gone = queuePerformer(item(how), [member('m1'), member('m3')]);
expect(gone.id, isNull);
expect(gone.why, 'Bob left');
expect(queuePerformer(item(how), [member('m1'), member('m2', away: true)]).id, 'm2');
final waits = queuePerformer(item(how), [member('m1'), member('m2', gone: true)]);
expect((waits.id, waits.why), (null, null), reason: 'a gone adder may come back within its grace');
final left = queuePerformer(item(how), [member('m1'), member('m3')]);
expect(left.id, isNull);
expect(left.why, 'Bob left');
}
});
@@ -38,22 +46,22 @@ void main() {
queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')])
.id,
'm3');
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, out: true), member('m2')]).why,
expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, away: true), member('m2')]).why,
isNotNull);
});
test('a link everyone opens or the server fetches: the adder, else the lowest active member', () {
for (final how in [QueueHow.direct, QueueHow.server]) {
expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2');
expect(queuePerformer(item(how), [member('m4'), member('m2', stale: true), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', out: true)]);
expect(queuePerformer(item(how), [member('m4'), member('m2', gone: true), member('m3')]).id, 'm3');
final nobody = queuePerformer(item(how), [member('m2', away: true)]);
expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip');
}
});
});
test('the janitor is the lowest member that is not stale', () {
expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2');
test('the janitor is the lowest member that is not gone', () {
expect(queueJanitor([member('m9'), member('m1', gone: true), member('m2')]), 'm2');
});
test('labels', () {
+25 -57
View File
@@ -1,6 +1,3 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence): "the
// source drops uncleanly and resumes" (resume token, D6 source resume); the
// rest stays.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -8,11 +5,13 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_core/testing.dart';
import 'package:sync_relay_server/server.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/connection.dart' show ConnOnline;
import 'package:starsync/src/session/session_coordinator.dart';
// The relay with real coordinators, real mpv and a dedicated server that
@@ -197,12 +196,7 @@ void main() {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
ws.add(jsonEncode(const HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'pw',
user: HelloUser(name: 'bob'),
media: HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
ws.add(jsonEncode(const HelloMessage(secret: 'pw', user: HelloUser(name: 'bob')).toJson()));
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
final size = await File(file).length();
ws.add(jsonEncode({
@@ -225,10 +219,10 @@ void main() {
}
});
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
test('the source drops uncleanly and comes back: the others play on, the relay continues (D6)',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
final proxy = await _Blackhole.start(server.boundPort);
final proxy = await FlakyProxy.start(server.boundPort);
try {
final alice = await member('alice');
final acc = JellyfinAccounts(store: MemoryJellyfinStore());
@@ -241,17 +235,30 @@ void main() {
await _poll(() => server.hub.relay?.live == true, 'source live');
await _poll(() => alice.playback.durationSecs > 19 && bob.playback.durationSecs > 19, 'both opened');
final token = bob.connection.token;
final opens = bob.opens;
// Bob's network goes silent (no FIN): states stop, he reconnects
// with `resume`, the server still has him.
proxy.cut();
// Bob's network goes silent (no FIN) on every link he has, the sync
// socket and his relay channel, and new ones too, long enough for
// the server to call him gone: the relay stays (`sourceGone`).
proxy.onConnect = (l) => l.blackhole();
for (final l in proxy.links) {
l.blackhole();
}
await _poll(() => bob.reconnecting.peek(), 'reconnect engaged', timeout: const Duration(seconds: 15));
await _poll(() => !bob.reconnecting.peek() && bob.serverState.peek() != null, 'back',
timeout: const Duration(seconds: 20));
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? false, 'source gone for alice',
timeout: const Duration(seconds: 25));
expect(alice.serverState.peek()!.media, isNotNull);
expect(server.hub.relay, isNotNull, reason: 'the relay is kept through the grace');
// His link comes back: the same member, his source dials its channel
// again with the same `info` and the relay continues.
proxy.onConnect = null;
await _poll(() => bob.connection.state.peek() is ConnOnline, 'back', timeout: const Duration(seconds: 30));
expect(bob.connection.token, token, reason: 'resumed as the same member');
await _poll(() => server.hub.relay?.live == true, 'source redialled', timeout: const Duration(seconds: 15));
expect(bob.serverState.peek(), isNotNull, reason: 'the state stayed through the drop');
await _poll(() => server.hub.relay?.live == true && !(stateMediaRelay(alice.serverState.peek()!)?.sourceGone ?? true),
'source redialled', timeout: const Duration(seconds: 25));
expect(bob.relaying.peek(), isTrue);
expect(bob.playback.durationSecs, greaterThan(19), reason: 'still his own copy');
expect(bob.opens, opens, reason: 'still his own copy, not reopened');
// Alice can still get bytes the buffer never had.
alice.seekTo(17);
@@ -261,42 +268,3 @@ void main() {
}
});
}
/// TCP proxy whose [cut] silently stops forwarding on every connection made
/// so far (no FIN, like a dead Wi-Fi); later connections work.
class _Blackhole {
final ServerSocket _server;
final _conns = <(Socket, Socket, List<bool>)>[];
_Blackhole(this._server);
int get port => _server.port;
static Future<_Blackhole> start(int target) async {
final b = _Blackhole(await ServerSocket.bind(InternetAddress.loopbackIPv4, 0));
b._server.listen((c) async {
final up = await Socket.connect('127.0.0.1', target);
final dead = [false];
b._conns.add((c, up, dead));
c.listen((d) {
if (!dead[0]) up.add(d);
}, onDone: () => dead[0] ? null : up.destroy(), onError: (_) {});
up.listen((d) {
if (!dead[0]) c.add(d);
}, onDone: () => dead[0] ? null : c.destroy(), onError: (_) {});
});
return b;
}
void cut() {
for (final (_, _, dead) in _conns) {
dead[0] = true;
}
}
Future<void> close() async {
await _server.close();
for (final (a, b, _) in _conns) {
a.destroy();
b.destroy();
}
}
}
+3 -8
View File
@@ -38,12 +38,7 @@ class _Raw {
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()));
r.ws.add(jsonEncode(HelloMessage(secret: secret, user: HelloUser(name: name)).toJson()));
await r.next('welcome');
return r;
}
@@ -135,8 +130,8 @@ void main() {
.cast<Map>()
.firstWhere((m) => m['name'] == 'host'))['id'];
// Anyone may sit the host out; its player unloads, the server serves on.
friend.ws.add(jsonEncode(OutIntent(target: '$hostId', out: true).toJson()));
await _poll(() => session.coordinator.selfOut.peek(), 'host sat out');
friend.ws.add(jsonEncode(PresenceIntent(target: '$hostId', state: Presence.away).toJson()));
await _poll(() => session.coordinator.selfAway.peek(), 'host sat out');
// The host nudges the friend: only the friend hears it.
final friendId = ((state['members'] as List)
+88 -58
View File
@@ -1,78 +1,108 @@
// restructure: rewrite in step 1 (Connection + Identity + Presence):
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
// linkDisplayName stays.
// Pure session logic: auto sit-out on a fake clock, link names, the
// unready-on-swap rule.
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/session/idle.dart';
import 'package:sync_relay_core/core.dart' show Presence, PresenceReason;
import 'package:starsync/src/platform/ports/shell_port.dart' show WindowActivity;
import 'package:starsync/src/session/auto_sit_out.dart';
import 'package:starsync/src/session/session_coordinator.dart';
void main() {
group('IdleTracker', () {
group('AutoSitOut', () {
final t0 = DateTime.utc(2026, 1, 1, 12);
const idle = AutoSitOutTimes.idle;
const minimised = AutoSitOutTimes.minimised;
List<IdleEvent> run(IdleTracker idle, DateTime from, Duration span,
{required bool playing, bool selfReady = true}) {
final out = <IdleEvent>[];
/// Ticks once a second from [from] for [span]; the steps that were not
/// [AutoStay], at their second.
List<(int, String)> run(AutoSitOut a, DateTime from, Duration span,
{Presence self = Presence.watching, bool playing = false, bool alone = false, bool enabled = true}) {
final out = <(int, String)>[];
for (var s = 0; s <= span.inSeconds; s++) {
final e = idle.tick(from.add(Duration(seconds: s)),
playing: playing, selfReady: selfReady);
if (e != IdleEvent.none) out.add(e);
final step = a.tick(from.add(Duration(seconds: s)),
self: self, playing: playing, alone: alone, enabled: enabled);
switch (step) {
case AutoAway(:final reason):
out.add((s, 'away ${reason.name}'));
case AutoBack():
out.add((s, 'back'));
case AutoStay():
break;
}
}
return out;
}
test('long watch then pause: no instant warn+unready', () {
final idle = IdleTracker(t0);
// 15 min of playback without any input.
expect(run(idle, t0, const Duration(minutes: 15), playing: true), isEmpty);
final paused = t0.add(const Duration(minutes: 15));
// First minute after the pause: nothing fires.
expect(run(idle, paused, const Duration(minutes: 1), playing: false),
isEmpty);
test('not playing: idle for the idle time sets you away (idle)', () {
final a = AutoSitOut(t0);
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
});
test('pause measures from when playback stopped', () {
final idle = IdleTracker(t0);
run(idle, t0, const Duration(minutes: 15), playing: true);
// Last playing tick was at +15 min; count seconds from there.
final paused = t0.add(const Duration(minutes: 15));
final events = <(int, IdleEvent)>[];
for (var s = 1; s <= 11 * 60; s++) {
final e = idle.tick(paused.add(Duration(seconds: s)),
playing: false, selfReady: true);
if (e != IdleEvent.none) events.add((s, e));
}
expect(events, [(8 * 60, IdleEvent.warn), (10 * 60, IdleEvent.unready)]);
test('never when alone, never when switched off', () {
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), alone: true), isEmpty);
expect(run(AutoSitOut(t0), t0, const Duration(minutes: 30), enabled: false), isEmpty);
final m = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
expect(run(m, t0, const Duration(minutes: 30), alone: true, playing: true), isEmpty);
});
test('unready always trails the warning by 2 min', () {
final idle = IdleTracker(t0);
// Idle 20 min while unready (nothing to lose, nothing fires)...
expect(
run(idle, t0, const Duration(minutes: 20),
playing: false, selfReady: false),
isEmpty);
// ...then someone force-readies us: warn now, unready 2 min later.
final forced = t0.add(const Duration(minutes: 20, seconds: 1));
expect(idle.tick(forced, playing: false, selfReady: true), IdleEvent.warn);
expect(
idle.tick(forced.add(const Duration(seconds: 1)),
playing: false, selfReady: true),
IdleEvent.none);
expect(
idle.tick(forced.add(const Duration(minutes: 2)),
playing: false, selfReady: true),
IdleEvent.unready);
test('while playing idle input does not count, minimised for a while does', () {
final a = AutoSitOut(t0);
expect(run(a, t0, const Duration(minutes: 30), playing: true), isEmpty, reason: 'watching is activity');
final at = t0.add(const Duration(minutes: 30));
a.window(WindowActivity.minimised, at);
expect(run(a, at, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
});
test('input resets the clock and the warning', () {
final idle = IdleTracker(t0);
expect(idle.tick(t0.add(const Duration(minutes: 8)),
playing: false, selfReady: true), IdleEvent.warn);
idle.activity(t0.add(const Duration(minutes: 9)));
expect(idle.tick(t0.add(const Duration(minutes: 11)),
playing: false, selfReady: true), IdleEvent.none);
expect(idle.tick(t0.add(const Duration(minutes: 17)),
playing: false, selfReady: true), IdleEvent.warn);
test('a pause after a long watch starts a fresh idle clock', () {
final a = AutoSitOut(t0);
run(a, t0, const Duration(minutes: 30), playing: true);
final paused = t0.add(const Duration(minutes: 30, seconds: 1));
expect(run(a, paused, idle - const Duration(seconds: 2)), isEmpty);
});
test('back on input or focus, only from an automatic away', () {
final a = AutoSitOut(t0);
expect(run(a, t0, idle), [(idle.inSeconds, 'away idle')]);
final at = t0.add(idle);
// The away shows in the next states; nothing until the user is back.
expect(run(a, at, const Duration(minutes: 5), self: Presence.away), isEmpty);
final later = at.add(const Duration(minutes: 5, seconds: 1));
a.input(later);
expect(run(a, later, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
// Minimised away: restoring is not enough, the focus brings it back.
final b = AutoSitOut(t0)..window(WindowActivity.minimised, t0);
expect(run(b, t0, minimised, playing: true), [(minimised.inSeconds, 'away minimised')]);
final m = t0.add(minimised);
b.window(WindowActivity.unfocused, m);
expect(run(b, m, const Duration(seconds: 3), self: Presence.away), isEmpty);
b.window(WindowActivity.focused, m);
expect(run(b, m, const Duration(seconds: 1), self: Presence.away), [(0, 'back')]);
});
test('a manual away (own or someone else\'s) is never returned automatically', () {
// Someone else sat you out: activity does not bring you back.
final a = AutoSitOut(t0);
a.input(t0);
expect(run(a, t0, const Duration(minutes: 20), self: Presence.away), isEmpty);
// Auto away, then the user sits out by hand: it stays.
final b = AutoSitOut(t0);
expect(run(b, t0, idle), [(idle.inSeconds, 'away idle')]);
b.manual();
final at = t0.add(idle);
b.input(at);
expect(run(b, at, const Duration(minutes: 1), self: Presence.away), isEmpty);
});
test('an away not yet echoed is not sent again', () {
final a = AutoSitOut(t0);
// The state still says `in` for a few seconds after the away went out.
expect(run(a, t0, idle + const Duration(seconds: 8)), [(idle.inSeconds, 'away idle')]);
expect(a.autoAway, isTrue);
});
test('the reasons on the wire', () {
expect(PresenceReason.idle.name, 'idle');
expect(PresenceReason.minimised.name, 'minimised');
});
});
+18 -2
View File
@@ -16,10 +16,26 @@ void main() {
await dir.delete(recursive: true);
});
test('a box without a version is at 1 and gets stamped', () async {
test('a box without a version is at 1, runs the steps and gets stamped', () async {
final box = await Hive.openBox('settings');
expect(await migrateSettings(box), isEmpty);
expect(await migrateSettings(box), hasLength(settingsSchemaVersion - 1));
expect(box.get(settingsSchemaKey), settingsSchemaVersion);
expect(await migrateSettings(box), isEmpty);
});
test('2: the idle switch gives way to auto sit-out, on', () async {
for (final old in [true, false]) {
final box = await Hive.openBox('settings');
await box.put('idleSitsOut', old);
await migrateSettings(box);
expect(box.containsKey('idleSitsOut'), isFalse);
expect(box.get('autoSitOut'), isTrue);
await box.deleteFromDisk();
}
final kept = await Hive.openBox('settings');
await kept.putAll({'idleSitsOut': true, 'autoSitOut': false});
await migrateSettings(kept);
expect(kept.get('autoSitOut'), isFalse, reason: 'a choice already made stays');
});
test('steps run in order, only past the stored version', () async {
+11 -5
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:starsync/src/screens/home/profile.dart';
import 'package:starsync/src/session/identity.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/avatar.dart';
import 'package:starsync/src/shell/motion.dart';
@@ -38,8 +38,14 @@ class _FakeVideoState extends State<FakeVideo> {
Widget build(BuildContext context) => const ColoredBox(color: Colors.black);
}
MemberView member(String id, String name, {bool ready = false, bool stale = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: ready, buffered: 30, stale: stale);
MemberView member(String id, String name, {bool ready = false, bool gone = false}) =>
MemberView(
id: id,
name: name,
ready: ready,
inSet: ready,
buffered: 30,
presence: gone ? Presence.gone : Presence.watching);
void main() {
final session = SessionState.instance;
@@ -93,7 +99,7 @@ void main() {
member('b', 'Bob'),
member('c', 'Cleo', ready: true),
member('d', 'Dan'),
member('e', 'Eve', stale: true),
member('e', 'Eve', gone: true),
member('f', 'Finn'),
];
@@ -145,7 +151,7 @@ void main() {
expect(find.byKey(const ValueKey('strip-join')), findsNothing);
expect(find.byKey(const ValueKey('strip-leave')), findsOneWidget);
expect(find.byKey(const ValueKey('strip-chat')), findsOneWidget);
expect(find.text('ready 2/6'), findsOneWidget);
expect(find.text('ready 2/5'), findsOneWidget, reason: 'Eve is gone: not counted');
final stack = find.byKey(const ValueKey('party-avatars'));
// Five shown, the sixth as "+1"; ready ones first.
expect(find.descendant(of: stack, matching: find.byType(StatusAvatar)), findsNWidgets(5));
+3 -6
View File
@@ -130,22 +130,19 @@ void main() {
name: 'a',
ready: true,
inSet: true,
buffered: 9,
stale: false);
buffered: 9);
const catchingUp = MemberView(
id: 'm2',
name: 'b',
ready: false,
inSet: false,
buffered: 0,
stale: false);
buffered: 0);
const unreadyInSet = MemberView(
id: 'm3',
name: 'c',
ready: false,
inSet: true,
buffered: 9,
stale: false);
buffered: 9);
test('only in-set ready members may follow transport to playing', () {
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);