Restructure step 0 done: notice table test, flaky-link proxy, liveness on the engine clock, STARSYNC_PROFILE
- notice_table_test: one row per AppEvent variant through noticeFor. - FlakyProxy (core/testing.dart): pass, blackhole (half-open), delay, hold, cut after N bytes, reset; demo tests against today's server. - Liveness (stale/drop/ping) on the engine clock, so grace can be tested with a fake clock; Connection takes an injectable clock. - AppDirs with STARSYNC_PROFILE=<name>: separate data and logs, so two instances run on one machine. - DEVELOPMENT: scenario list per restructure step; test tags follow the new step order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eac45746ce
commit
f4ca02c3cf
23 files changed
+959
-48
No files matched your search
@@ -7,6 +7,7 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'src/platform/app_dirs.dart';
|
||||||
import 'src/platform/app_log.dart';
|
import 'src/platform/app_log.dart';
|
||||||
import 'src/platform/app_shutdown.dart';
|
import 'src/platform/app_shutdown.dart';
|
||||||
import 'src/platform/desktop_entry.dart';
|
import 'src/platform/desktop_entry.dart';
|
||||||
@@ -41,14 +42,17 @@ Future<void> main(List<String> args) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await startAppLogging();
|
final dirs = await AppDirs.resolve();
|
||||||
await Hive.initFlutter();
|
await startAppLogging(dirs);
|
||||||
|
// An absolute subDir replaces the documents folder (package:path join).
|
||||||
|
await Hive.initFlutter(dirs.hive);
|
||||||
await AppSettings.instance.ensureLoaded();
|
await AppSettings.instance.ensureLoaded();
|
||||||
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
|
||||||
// see "left", a hosted server shuts down and no player outlives the
|
// see "left", a hosted server shuts down and no player outlives the
|
||||||
// engine.
|
// engine.
|
||||||
final ports = SessionState.instance.ports;
|
final ports = SessionState.instance.ports;
|
||||||
await ports.shell.start(title: appName, onCloseRequest: shutDownApp);
|
await ports.shell.start(
|
||||||
|
title: dirs.profile == null ? appName : '$appName [${dirs.profile}]', onCloseRequest: shutDownApp);
|
||||||
try {
|
try {
|
||||||
await SessionState.instance.loadForm();
|
await SessionState.instance.loadForm();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// The environment variable that picks a separate profile (DEVELOPMENT.md
|
||||||
|
/// "Dev loop": two instances on one machine).
|
||||||
|
const profileEnv = 'STARSYNC_PROFILE';
|
||||||
|
|
||||||
|
/// Where the app keeps its own files, resolved once in `main` ([resolve])
|
||||||
|
/// and handed on from there.
|
||||||
|
///
|
||||||
|
/// Without a profile it is exactly today's layout: Hive's boxes in Hive's
|
||||||
|
/// default folder (the documents folder) and the logs in
|
||||||
|
/// `<app support>/logs`. With `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||||
|
/// `-`, `_`; at most 32) the per-install data moves to
|
||||||
|
/// `<app support>/profiles/<name>/`: the Hive boxes (settings and so the
|
||||||
|
/// profile, connect form, window bounds, folders, Jellyfin sign-ins; the
|
||||||
|
/// client id from restructure step 1 on) under `hive/`, the logs under
|
||||||
|
/// `logs/`. Downloads and caches stay shared: yt-dlp and its cookies, the
|
||||||
|
/// extracted shaders, thumbnails, the AniList cache.
|
||||||
|
class AppDirs {
|
||||||
|
/// The profile name; null for the normal app.
|
||||||
|
final String? profile;
|
||||||
|
/// The app support folder (yt-dlp, shaders; logs without a profile).
|
||||||
|
final String support;
|
||||||
|
/// Hive's folder; null: Hive's default (`Hive.initFlutter()`).
|
||||||
|
final String? hive;
|
||||||
|
/// The app's log folder.
|
||||||
|
final String logs;
|
||||||
|
|
||||||
|
const AppDirs({required this.profile, required this.support, required this.hive, required this.logs});
|
||||||
|
|
||||||
|
/// The layout for [support] (the app support folder) and [env]; an
|
||||||
|
/// invalid profile name is ignored ([invalidProfile] says which).
|
||||||
|
static AppDirs fromEnv({required String support, required Map<String, String> env}) {
|
||||||
|
final sep = Platform.pathSeparator;
|
||||||
|
final name = validProfile(env[profileEnv]);
|
||||||
|
if (name == null) return AppDirs(profile: null, support: support, hive: null, logs: '$support${sep}logs');
|
||||||
|
final root = '$support${sep}profiles$sep$name';
|
||||||
|
return AppDirs(profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [raw] when it is a usable profile name, else null.
|
||||||
|
static String? validProfile(String? raw) {
|
||||||
|
final v = raw?.trim() ?? '';
|
||||||
|
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The env value when it is set but not a usable name (for a warning).
|
||||||
|
static String? invalidProfile(Map<String, String> env) {
|
||||||
|
final raw = env[profileEnv];
|
||||||
|
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the layout from the platform and the process environment.
|
||||||
|
/// Needs Flutter's binding (path_provider).
|
||||||
|
static Future<AppDirs> resolve() async {
|
||||||
|
final support = (await getApplicationSupportDirectory()).path;
|
||||||
|
final dirs = fromEnv(support: support, env: Platform.environment);
|
||||||
|
final hive = dirs.hive;
|
||||||
|
if (hive != null) await Directory(hive).create(recursive: true);
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import '../screens/home/ui.dart' show appName, displayVersion;
|
import '../screens/home/ui.dart' show appName, displayVersion;
|
||||||
|
import 'app_dirs.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
|
|
||||||
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
|
||||||
@@ -17,13 +17,11 @@ final _appLog = createLogger('app');
|
|||||||
/// The logs folder, once [startAppLogging] ran.
|
/// The logs folder, once [startAppLogging] ran.
|
||||||
String? appLogDir;
|
String? appLogDir;
|
||||||
|
|
||||||
/// Opens the log files and routes `debugPrint` and uncaught errors into
|
/// Opens the log files in [dirs]' log folder and routes `debugPrint` and
|
||||||
/// the log. Needs Flutter's binding (path_provider).
|
/// uncaught errors into the log.
|
||||||
Future<void> startAppLogging() async {
|
Future<void> startAppLogging(AppDirs dirs) async {
|
||||||
try {
|
try {
|
||||||
final base = await getApplicationSupportDirectory();
|
if (startLogFiles(dirs.logs) != null) appLogDir = dirs.logs;
|
||||||
final dir = '${base.path}${Platform.pathSeparator}logs';
|
|
||||||
if (startLogFiles(dir) != null) appLogDir = dir;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_appLog.warning('no log files: $e');
|
_appLog.warning('no log files: $e');
|
||||||
}
|
}
|
||||||
@@ -39,7 +37,10 @@ Future<void> startAppLogging() async {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
|
||||||
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
|
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
|
||||||
|
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
|
||||||
|
final bad = AppDirs.invalidProfile(Platform.environment);
|
||||||
|
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The text "Copy diagnostics" gives: versions, then the last [since] of
|
/// The text "Copy diagnostics" gives: versions, then the last [since] of
|
||||||
|
|||||||
@@ -196,23 +196,30 @@ class Connection {
|
|||||||
final Duration reconnectDelay;
|
final Duration reconnectDelay;
|
||||||
/// No state broadcast for this long means the connection is lost.
|
/// No state broadcast for this long means the connection is lost.
|
||||||
final Duration silenceLimit;
|
final Duration silenceLimit;
|
||||||
|
/// The clock [silenceLimit] is measured on (UTC; a test passes a fake
|
||||||
|
/// one). The heartbeat that checks it stays a real 1 s timer.
|
||||||
|
final DateTime Function() now;
|
||||||
|
|
||||||
Connection(
|
Connection(
|
||||||
{void Function(AppEvent event)? notify,
|
{void Function(AppEvent event)? notify,
|
||||||
SyncClient? client,
|
SyncClient? client,
|
||||||
this.hello = defaultHello,
|
this.hello = defaultHello,
|
||||||
this.reconnectDelay = const Duration(milliseconds: 1500),
|
this.reconnectDelay = const Duration(milliseconds: 1500),
|
||||||
this.silenceLimit = const Duration(seconds: 5)})
|
this.silenceLimit = const Duration(seconds: 5),
|
||||||
|
DateTime Function()? now})
|
||||||
: _notify = notify ?? ((_) {}),
|
: _notify = notify ?? ((_) {}),
|
||||||
|
now = now ?? _utcNow,
|
||||||
client = client ?? SyncClient() {
|
client = client ?? SyncClient() {
|
||||||
_subs = [
|
_subs = [
|
||||||
this.client.status.listen(_onStatus),
|
this.client.status.listen(_onStatus),
|
||||||
this.client.states.listen((_) => _lastStateAt = DateTime.now().toUtc()),
|
this.client.states.listen((_) => _lastStateAt = this.now()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
late final List<StreamSubscription> _subs;
|
late final List<StreamSubscription> _subs;
|
||||||
|
|
||||||
|
static DateTime _utcNow() => DateTime.now().toUtc();
|
||||||
|
|
||||||
final _state = signal<ConnState>(const ConnClosed());
|
final _state = signal<ConnState>(const ConnClosed());
|
||||||
/// Every change is notified, a repeat too.
|
/// Every change is notified, a repeat too.
|
||||||
ReadonlySignal<ConnState> get state => _state;
|
ReadonlySignal<ConnState> get state => _state;
|
||||||
@@ -368,7 +375,7 @@ class Connection {
|
|||||||
_notify(const ConnectionBack());
|
_notify(const ConnectionBack());
|
||||||
}
|
}
|
||||||
_resumeToken = client.token;
|
_resumeToken = client.token;
|
||||||
_lastStateAt = DateTime.now().toUtc();
|
_lastStateAt = now();
|
||||||
_set(const ConnOnline());
|
_set(const ConnOnline());
|
||||||
_startHeartbeat();
|
_startHeartbeat();
|
||||||
}
|
}
|
||||||
@@ -400,13 +407,13 @@ class Connection {
|
|||||||
/// Every second while connected: a clock sample, the diagnostics, and
|
/// Every second while connected: a clock sample, the diagnostics, and
|
||||||
/// the silence check.
|
/// the silence check.
|
||||||
void _beat() {
|
void _beat() {
|
||||||
final now = DateTime.now().toUtc();
|
final t = now();
|
||||||
if (client.token.isNotEmpty) client.sendPing();
|
if (client.token.isNotEmpty) client.sendPing();
|
||||||
diag
|
diag
|
||||||
..share = AppSettings.instance.shareDiagnostics.peek()
|
..share = AppSettings.instance.shareDiagnostics.peek()
|
||||||
..tick();
|
..tick();
|
||||||
final last = _lastStateAt;
|
final last = _lastStateAt;
|
||||||
if (!_closed && last != null && now.difference(last) >= silenceLimit) {
|
if (!_closed && last != null && t.difference(last) >= silenceLimit) {
|
||||||
_silence.add(null);
|
_silence.add(null);
|
||||||
if (!_state.peek().retrying) _dropped();
|
if (!_state.peek().retrying) _dropped();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// restructure: rewrite in step R (Rename): Hive moves into the support
|
||||||
|
// folder for the normal app too.
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/platform/app_dirs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final sep = Platform.pathSeparator;
|
||||||
|
const support = '/s';
|
||||||
|
|
||||||
|
test('no profile: today\'s layout', () {
|
||||||
|
final d = AppDirs.fromEnv(support: support, env: const {});
|
||||||
|
expect(d.profile, isNull);
|
||||||
|
expect(d.hive, isNull, reason: "Hive's default folder");
|
||||||
|
expect(d.logs, '$support${sep}logs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a profile gets its own Hive and logs folders', () {
|
||||||
|
final d = AppDirs.fromEnv(support: support, env: const {profileEnv: 'b'});
|
||||||
|
expect(d.profile, 'b');
|
||||||
|
expect(d.hive, '$support${sep}profiles${sep}b${sep}hive');
|
||||||
|
expect(d.logs, '$support${sep}profiles${sep}b${sep}logs');
|
||||||
|
expect(d.support, support, reason: 'yt-dlp and shaders stay shared');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unusable name is ignored and reported', () {
|
||||||
|
for (final bad in ['../x', 'a b', 'x' * 33, 'é']) {
|
||||||
|
final env = {profileEnv: bad};
|
||||||
|
expect(AppDirs.fromEnv(support: support, env: env).profile, isNull, reason: bad);
|
||||||
|
expect(AppDirs.invalidProfile(env), bad);
|
||||||
|
}
|
||||||
|
expect(AppDirs.invalidProfile(const {profileEnv: ''}), isNull, reason: 'empty is just unset');
|
||||||
|
expect(AppDirs.invalidProfile(const {profileEnv: 'ok_1-2'}), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// restructure: rewrite in step 5 (Watch tracking): the progress-reporting
|
// restructure: rewrite in step 6 (Watch tracking): the progress-reporting
|
||||||
// checks; the guest-through-the-relay flow stays.
|
// checks; the guest-through-the-relay flow stays.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// restructure: rewrite in step 5 (What's next + Watch tracking): the
|
// restructure: rewrite in step 6 (What's next + Watch tracking): the
|
||||||
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// The Notifier policy as a table: one row per AppEvent subtype, what
|
||||||
|
// noticeFor makes of it (toast text and actor, sound, attention, activity-log
|
||||||
|
// line). A policy change edits a row here on purpose.
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show FeedMessage;
|
||||||
|
import 'package:watch_together/src/session/notifier.dart';
|
||||||
|
import 'package:watch_together/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);
|
||||||
|
|
||||||
|
/// One row: the event, then what it must do.
|
||||||
|
class _Row {
|
||||||
|
final String name;
|
||||||
|
final AppEvent event;
|
||||||
|
final Type type;
|
||||||
|
final String? toast;
|
||||||
|
final String? actor;
|
||||||
|
final SfxKind? sound;
|
||||||
|
final int readyIndex;
|
||||||
|
final bool attention;
|
||||||
|
/// Whether the event's own feed line goes into the activity log.
|
||||||
|
final bool logged;
|
||||||
|
const _Row(this.name, this.event, this.type,
|
||||||
|
{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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
toast: 'Ann sat you out', actor: 'Ann', sound: SfxKind.kick, logged: true),
|
||||||
|
_Row('back', _of(_feed('back')), MemberBack, 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),
|
||||||
|
_Row('force-unready', _of(_feed('force-unready')), ForcedReady, logged: true),
|
||||||
|
_Row('force-play', _of(_feed('force-play')), ForcedTransport, logged: true),
|
||||||
|
_Row('force-hold', _of(_feed('force-hold')), ForcedTransport, logged: true),
|
||||||
|
_Row('swap', _of(_feed('swap')), MediaSwapped,
|
||||||
|
toast: 'Ann changed media', actor: 'Ann', sound: SfxKind.swap, logged: true),
|
||||||
|
_Row('seek', _of(_feed('seek')), Seeked, logged: true),
|
||||||
|
_Row('pause', _of(_feed('pause', actor: 'server')), Paused, logged: true),
|
||||||
|
_Row('resume', _of(_feed('resume', actor: 'server')), Resumed, logged: true),
|
||||||
|
_Row('finished', _of(_feed('finished', actor: 'server')), Finished, logged: true),
|
||||||
|
_Row('chat from someone', _of(_feed('chat', reason: 'hi')), ChatPosted,
|
||||||
|
toast: 'Ann: hi', actor: 'Ann', sound: SfxKind.chat, logged: true),
|
||||||
|
_Row('own chat', _of(_feed('chat', actor: 'Me', reason: 'hi')), ChatPosted,
|
||||||
|
toast: 'Me: hi', actor: 'Me', logged: true),
|
||||||
|
_Row('rename', _of(_feed('rename', target: 'Annie')), MemberRenamed, logged: true),
|
||||||
|
_Row('avatar', _of(_feed('avatar')), AvatarChanged, logged: true),
|
||||||
|
_Row('queue-add', _of(_feed('queue-add', target: 'Ep 6')), QueueAdded, logged: true),
|
||||||
|
_Row('queue-remove', _of(_feed('queue-remove', target: 'Ep 6')), QueueRemoved, logged: true),
|
||||||
|
_Row('queue-skip with a reason', _of(_feed('queue-skip', actor: 'server', target: 'Ep 6', reason: 'gone')),
|
||||||
|
QueueSkipped, toast: 'Skipped Ep 6: gone', logged: true),
|
||||||
|
_Row('queue-skip, no title or reason', _of(_feed('queue-skip', actor: 'server')), QueueSkipped,
|
||||||
|
toast: 'Skipped an item', logged: true),
|
||||||
|
_Row('fetch-fail', _of(_feed('fetch-fail', actor: 'server', reason: '404')), FetchFailed, logged: true),
|
||||||
|
_Row('an unknown action', _of(_feed('from-the-future')), OtherFeed, logged: true),
|
||||||
|
// ---- the session
|
||||||
|
_Row('nudge', const Nudged('Ann'), Nudged,
|
||||||
|
toast: 'Ann nudged you', actor: 'Ann', sound: SfxKind.nudge, attention: true),
|
||||||
|
_Row('nudge from someone unknown', const Nudged(''), Nudged,
|
||||||
|
toast: 'Someone nudged you', sound: SfxKind.nudge, attention: true),
|
||||||
|
_Row('member cannot play', const MemberCantPlay('Ann', 'no codec'), MemberCantPlay,
|
||||||
|
toast: "Ann can't play this: no codec", actor: 'Ann'),
|
||||||
|
_Row('slow source', const SlowSource('Passing on slower than it plays'), SlowSource,
|
||||||
|
toast: 'Passing on slower than it plays'),
|
||||||
|
_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,
|
||||||
|
toast: 'Jellyfin progress not saved: sign-in expired'),
|
||||||
|
_Row('jellyfin, unreachable', const JellyfinProgressFailed(signInExpired: false), JellyfinProgressFailed,
|
||||||
|
toast: 'Jellyfin progress not saved: server unreachable'),
|
||||||
|
// ---- the app
|
||||||
|
_Row('shader message', const ShaderMessage('Shaders off'), ShaderMessage, toast: 'Shaders off'),
|
||||||
|
_Row('added to up next', const AddedToUpNext('Ep 6'), AddedToUpNext, toast: 'Added Ep 6 to Up next'),
|
||||||
|
_Row('action failed', const ActionFailed('No such file'), ActionFailed, toast: 'No such file'),
|
||||||
|
_Row('schedule result', const ScheduleResult('Loaded'), ScheduleResult, toast: 'Loaded'),
|
||||||
|
_Row('problem marked in session', const ProblemMarked(inSession: true), ProblemMarked,
|
||||||
|
toast: 'Marked in the session log'),
|
||||||
|
_Row('problem marked locally', const ProblemMarked(inSession: false), ProblemMarked,
|
||||||
|
toast: "Marked in this app's log"),
|
||||||
|
_Row('copied', const Copied('Link copied'), Copied, toast: 'Link copied'),
|
||||||
|
_Row('copy failed', const CopyFailed(), CopyFailed, toast: "Couldn't copy"),
|
||||||
|
_Row('update available', const UpdateAvailable('1.3.0'), UpdateAvailable),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('noticeFor', () {
|
||||||
|
for (final r in _rows) {
|
||||||
|
test(r.name, () {
|
||||||
|
expect(r.event.runtimeType, r.type, reason: 'event type');
|
||||||
|
final n = noticeFor(r.event);
|
||||||
|
expect(n.toast, r.toast, reason: 'toast');
|
||||||
|
expect(n.actor, r.actor, reason: 'actor');
|
||||||
|
expect(n.sound?.kind, r.sound, reason: 'sound');
|
||||||
|
if (r.sound != null) expect(n.sound!.readyIndex, r.readyIndex, reason: 'ready pitch');
|
||||||
|
expect(n.attention, r.attention, reason: 'attention');
|
||||||
|
final e = r.event;
|
||||||
|
expect(n.log, r.logged ? same((e as FeedEvent).feed) : isNull, reason: 'activity log');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every concrete AppEvent subtype has a row', () {
|
||||||
|
// Read from the source, so a new event fails here until it gets a row.
|
||||||
|
final src = File('lib/src/session/app_events.dart').readAsStringSync();
|
||||||
|
final declared = RegExp(r'^class (\w+) extends (?:FeedEvent|AppEvent)', multiLine: true)
|
||||||
|
.allMatches(src)
|
||||||
|
.map((m) => m.group(1)!)
|
||||||
|
.toSet();
|
||||||
|
expect(declared, isNotEmpty);
|
||||||
|
expect(_rows.map((r) => r.type.toString()).toSet(), declared);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// restructure: rewrite in step 5 (What's next): who performs an item and
|
// restructure: rewrite in step 6 (What's next): who performs an item and
|
||||||
// the janitor become server state.
|
// the janitor become server state.
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// restructure: rewrite in step 6 (Media pipeline): preload and the warm
|
// restructure: rewrite in step 5 (Media pipeline): preload and the warm
|
||||||
// relay become one pipeline; keep the timing per source.
|
// relay become one pipeline; keep the timing per source.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|||||||
+20
-6
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
|||||||
|
|
||||||
| Path | What | Rule |
|
| Path | What | Rule |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`) | pure Dart, no Flutter |
|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
|
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
|
||||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||||
@@ -180,6 +180,12 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
user is not seeking.
|
user is not seeking.
|
||||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||||
|
The durations are a `Liveness` (core, `sync_engine.dart`; defaults
|
||||||
|
`RelayConstants.staleAfterSecs` / `dropAfterSecs` / `pingIntervalSecs`)
|
||||||
|
given to `SyncEngine(liveness:)` and `SyncServer(liveness:)`; stale and
|
||||||
|
drop are measured on the engine's clock (`SyncEngine.clock`,
|
||||||
|
`SyncServer(clock:)`) by the 1 s heartbeat sweep, so a test with a fake
|
||||||
|
clock steps through them; the ping is the socket's own real timer.
|
||||||
A stale member goes back into the set with its next frame if it is ready
|
A stale member goes back into the set with its next frame if it is ready
|
||||||
(also one stale at a swap) or the server is not PLAYING.
|
(also one stale at a swap) or the server is not PLAYING.
|
||||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||||
@@ -228,7 +234,9 @@ the diagnostics tick, the silence check: `silenceLimit`, 5 s), the
|
|||||||
diagnostics uploader (`diag`, started with the session, stopped by
|
diagnostics uploader (`diag`, started with the session, stopped by
|
||||||
`close` before the socket), "Stop reconnecting" (`cancelReconnect`),
|
`close` before the socket), "Stop reconnecting" (`cancelReconnect`),
|
||||||
"Retry" (`retry`) and setup cancel (`cancelConnect`). The `hello` is
|
"Retry" (`retry`) and setup cancel (`cancelConnect`). The `hello` is
|
||||||
built by an injectable `HelloBuilder` (`defaultHello`). The incoming
|
built by an injectable `HelloBuilder` (`defaultHello`). The silence is
|
||||||
|
measured on `Connection.now` (injectable, UTC wall clock by default); the
|
||||||
|
heartbeat and the reconnect delay are real timers. The incoming
|
||||||
streams (`states`, `feeds`, `nudges`, `errors`), the send methods, the
|
streams (`states`, `feeds`, `nudges`, `errors`), the send methods, the
|
||||||
`token`, `memberId` and `serverNow()` are read through it.
|
`token`, `memberId` and `serverNow()` are read through it.
|
||||||
|
|
||||||
@@ -1494,7 +1502,12 @@ panel) and chat messages (Copy message, and a link in it).
|
|||||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||||
servers and links, the profile picture and colour; saved even when a join
|
servers and links, the profile picture and colour; saved even when a join
|
||||||
fails) and
|
fails) and
|
||||||
`settings`. Link media names are a web page's title (see "yt-dlp"), else
|
`settings`. Where Hive and the logs live is `AppDirs`
|
||||||
|
(`platform/app_dirs.dart`, resolved once in `main`): Hive's default folder
|
||||||
|
(the documents folder) and `<app support>/logs`; with `STARSYNC_PROFILE`
|
||||||
|
(DEVELOPMENT "Dev loop") `<app support>/profiles/<name>/hive` and `logs`,
|
||||||
|
and the window title gets ` [<name>]`. yt-dlp, shaders and caches stay
|
||||||
|
shared. Link media names are a web page's title (see "yt-dlp"), else
|
||||||
derived from the URL path, never containing a query string.
|
derived from the URL path, never containing a query string.
|
||||||
|
|
||||||
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
|
||||||
@@ -1684,9 +1697,10 @@ dedicated server (`package:logging` loggers named by area: `relay`,
|
|||||||
`app`).
|
`app`).
|
||||||
|
|
||||||
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
|
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
|
||||||
(Linux `~/.local/share/moe.senkochan.watch_together/logs`); its hosted
|
(Linux `~/.local/share/moe.senkochan.watch_together/logs`; with a
|
||||||
server's records arrive over the isolate's `events` port and go in the
|
profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
|
||||||
same file as `server.<logger>` (`relogLine`). The dedicated server
|
"Desktop UI"); its hosted server's records arrive over the isolate's
|
||||||
|
`events` port and go in the same file as `server.<logger>` (`relogLine`). The dedicated server
|
||||||
writes `server-YYYY-MM-DD.log` in `--log-dir`, else `logs` next to
|
writes `server-YYYY-MM-DD.log` in `--log-dir`, else `logs` next to
|
||||||
`--config`, else `logs` next to the binary (systemd:
|
`--config`, else `logs` next to the binary (systemd:
|
||||||
`$STATE_DIRECTORY/logs`), else `~/.cache/watch-together/logs`; it
|
`$STATE_DIRECTORY/logs`), else `~/.cache/watch-together/logs`; it
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ effect with embedded video: media_kit forces `vo=libmpv`. `SYNC_RELAY_PRELOAD=0`
|
|||||||
turns Up next's preload player off; `SYNC_RELAY_TRACE_SWITCH=1` prints
|
turns Up next's preload player off; `SYNC_RELAY_TRACE_SWITCH=1` prints
|
||||||
the timings of each media change (`SwitchTrace`).
|
the timings of each media change (`SwitchTrace`).
|
||||||
|
|
||||||
|
Two instances on one machine: `STARSYNC_PROFILE=<name>` (letters, digits,
|
||||||
|
`-`, `_`; at most 32) gives an instance its own Hive boxes (settings,
|
||||||
|
profile, connect form, window bounds, folders, Jellyfin sign-ins; the
|
||||||
|
client id from restructure step 1 on) and logs under
|
||||||
|
`<app support>/profiles/<name>/`, and ` [<name>]` in the window title;
|
||||||
|
yt-dlp, shaders and caches stay shared (`AppDirs`, ARCHITECTURE "Desktop
|
||||||
|
UI"). Without it nothing changes. Run one plain instance and one with
|
||||||
|
`STARSYNC_PROFILE=b` (`flutter run -d linux` passes the environment on, or
|
||||||
|
start the built binary twice); one hosts, the other joins
|
||||||
|
`127.0.0.1:<port>`, or both join a `dart run` server. An unusable name is
|
||||||
|
ignored with a warning in the log.
|
||||||
|
|
||||||
Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
|
Harnesses in `tool/` (own `pubspec.yaml`, `dart run tool/<name>.dart`):
|
||||||
`range_logger` (serve a file and log every Range request), `shaper`
|
`range_logger` (serve a file and log every Range request), `shaper`
|
||||||
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
|
(bandwidth cap, latency, `--kill-after` for outage tests), `fake_viewer`
|
||||||
@@ -95,6 +107,42 @@ display; that says nothing about the app. `sound_device_e2e` opens the
|
|||||||
real audio device (flutter_soloud) and plays every sound of every preset
|
real audio device (flutter_soloud) and plays every sound of every preset
|
||||||
quietly (`WT_SFX_VOLUME`, default 0.1).
|
quietly (`WT_SFX_VOLUME`, default 0.1).
|
||||||
|
|
||||||
|
Bad links: `FlakyProxy` (`package:sync_relay_core/testing.dart`, so
|
||||||
|
core, server and desktop tests share it; not part of `core.dart`) sits
|
||||||
|
between a client and a server on loopback: `FlakyProxy.start(serverPort)`,
|
||||||
|
the client dials `proxy.port`, and each accepted connection is a
|
||||||
|
`FlakyLink` the test steers at any time: `pass()`, `blackhole()` (both
|
||||||
|
sockets stay open, every byte and a closing end are dropped: a half-open
|
||||||
|
link), `delay` (added latency each way, order kept), `holdNext(d, flow:)`
|
||||||
|
(the next chunk, and what queues behind it, arrives `d` late),
|
||||||
|
`cutAfter(n, flow:)` (n more bytes, then a reset) and `reset()` (RST via
|
||||||
|
SO_LINGER 0). `onConnect` steers a link before its first byte (blackhole
|
||||||
|
or refuse the next dial). It counts bytes each way and the dropped ones.
|
||||||
|
It is TCP only: a "frame" is one chunk as read (on loopback usually one
|
||||||
|
small WebSocket frame). Its own tests: core `flaky_proxy_test`; against
|
||||||
|
the server: server `flaky_link_test`. For manual runs with real apps use
|
||||||
|
`tool/shaper` instead.
|
||||||
|
|
||||||
|
Long durations on a fake clock: `SyncServer(clock:, liveness:)` and
|
||||||
|
`SyncEngine(clock:, liveness:)` take the engine clock and the server's
|
||||||
|
silence limits (`Liveness`, ARCHITECTURE "Sync model"), so a test can
|
||||||
|
pass short limits or step a fake clock through minutes (the 1 s heartbeat
|
||||||
|
still ticks in real time and reads the clock); `Connection(now:,
|
||||||
|
silenceLimit:, reconnectDelay:)` on the app side.
|
||||||
|
|
||||||
|
Safety-net scenarios: the core flows every restructure step must keep
|
||||||
|
working. What covers them today and which step adds the rest (steps as
|
||||||
|
in [ROADMAP.md](ROADMAP.md)):
|
||||||
|
|
||||||
|
| Scenario | Covered today | Missing, added in |
|
||||||
|
|---|---|---|
|
||||||
|
| Drop and reconnect while sharing a relay | server `relay_test` "source drops and comes back", `sync_wave_test` (resume after an unclean drop), `flaky_link_test` (blackhole, reset); desktop `relay_e2e` "the source drops uncleanly and resumes", `coord_presence` (dead server, reconnect restores ready) | step 1: the same member back after a closed socket (grace, P1), the source re-dialling its relay (D6), the player and state kept through the drop, through `FlakyProxy` and a fake clock |
|
||||||
|
| Join mid-playback | core `sync_engine_test` "late joiner does not halt playback", "a joiner while playing never holds anyone back"; server `relay_test` "a late joiner gets its own /r/ URL" | step 1: `joining` until in sync, loading in the background while playing (D2, P3), the ~15 s hold while not playing |
|
||||||
|
| Sit out / Away and back | core `sync_engine_test` "sitting out"; server `hardening_test` "anyone sits anyone out"; desktop `server_isolate_test` (host sat out), `notice_table_test` (sat you out) | step 1: presence `away`/`in`, the ready counter (P6), auto sit-out; step 2: notifications while away (P8); step 6: not tracked as watched (P7) |
|
||||||
|
| Swap, also while someone is Away or gone | core `sync_engine_test` "swap" (a stale member stays out), `queue_test` (a swap takes its item); server `relay_test` (a swap drops the buffer); desktop `sync_playback_e2e` | step 1: a swap with a member `away` (stays away) or `gone` (in grace, its items wait); step 5: no commands reach a player mid-switch (P15) |
|
||||||
|
| Up next advance | core `queue_test` (skip, play request timeout); server `queue_test`, `relay_test` "warm relay"; desktop `jellyfin_session_test` (auto-advance swapper, end card countdown), `queue_switch_e2e` | step 5: preparing it through the media pipeline; step 6: the continuation and the countdown as server state with a shared cancel |
|
||||||
|
| Close the app cleanly, incl. `bye` | server `lifecycle_test` (clean close removes the member, server stop closes sockets); desktop `server_isolate_test` (stop frees the port), `coord_presence` (cancelled host setup releases the port) | step 1: `bye` leaves at once, any other close keeps the member in grace, update-and-restart sends no `bye`; step 5: the Windows close smoke job (P32) |
|
||||||
|
|
||||||
A test file whose first line is `// restructure: rewrite in step N` pins
|
A test file whose first line is `// restructure: rewrite in step N` pins
|
||||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||||
the comment names the tests. Rewrite or delete them in that step, not
|
the comment names the tests. Rewrite or delete them in that step, not
|
||||||
|
|||||||
+6
-11
@@ -85,17 +85,12 @@ the automatic continuation and countdown, relay state), the device
|
|||||||
(client id, library and folders, match corrections, watch history,
|
(client id, library and folders, match corrections, watch history,
|
||||||
settings, notification prefs, what this app is sourcing) and the
|
settings, notification prefs, what this app is sourcing) and the
|
||||||
integrations (Jellyfin played/resume, AniList list).
|
integrations (Jellyfin played/resume, AniList list).
|
||||||
0. Safety net and seams, no visible change. Left: a `noticeFor` table
|
|
||||||
test; a flaky-link test harness (pure-Dart TCP proxy: pass, blackhole,
|
|
||||||
delay, reset, late frame); grace and silence through
|
|
||||||
the engine clock; an env override for client id and data folder (two
|
|
||||||
instances on one machine); the scenario list in DEVELOPMENT "Tests".
|
|
||||||
(Done: tests audited and tagged, P27, AppEvent + Notifier, ports,
|
|
||||||
protocol 16 planned, Connection extracted from the coordinator.)
|
|
||||||
R. Rename (before step 1, so dev builds install side by side with 1.2.x):
|
R. Rename (before step 1, so dev builds install side by side with 1.2.x):
|
||||||
app id `dev.foxyz.starsync`, binary, Windows company `foxyz`; Hive out
|
app id `dev.foxyz.starsync`, binary, Windows company `foxyz`; Hive out
|
||||||
of `~/Documents` into the app's support folder, copied not moved;
|
of `~/Documents` into the app's support folder, copied not moved;
|
||||||
a settings schema version with ordered migrations; an `AppDirs` port;
|
a settings schema version with ordered migrations; `AppDirs` (today
|
||||||
|
Hive and logs, and `STARSYNC_PROFILE`) becomes a port and the default
|
||||||
|
moves next to the profiles;
|
||||||
the 1.3.0 archive still contains `watch_together(.exe)` so 1.2.x's
|
the 1.3.0 archive still contains `watch_together(.exe)` so 1.2.x's
|
||||||
self-update restart works; the old `.desktop` entry removed; the
|
self-update restart works; the old `.desktop` entry removed; the
|
||||||
manifest kept at the old URL; VPS `ExecStart`/`update.sh` paths.
|
manifest kept at the old URL; VPS `ExecStart`/`update.sh` paths.
|
||||||
@@ -156,9 +151,9 @@ Not in the restructure: P25 (ops).
|
|||||||
**Agreed since:** the queue stays in memory (no server storage).
|
**Agreed since:** the queue stays in memory (no server storage).
|
||||||
Transport: `wss://` preferred when a server offers it, `ws://` still
|
Transport: `wss://` preferred when a server offers it, `ws://` still
|
||||||
allowed (a cleartext exception on Android); it only carries sync.
|
allowed (a cleartext exception on Android); it only carries sync.
|
||||||
Tests: many pin behaviour the restructure drops; step 0 audits them
|
Tests: many pin behaviour the restructure drops; each is tagged with the
|
||||||
(delete what no longer fits, keep a few scenario tests for the core
|
step that rewrites it, and the safety-net scenarios (DEVELOPMENT "Tests")
|
||||||
flows). Open questions below are decided during the restructure.
|
must keep passing through every step. Open questions below are decided during the restructure.
|
||||||
How: incremental on `main` (one system extracted per step, every dev
|
How: incremental on `main` (one system extracted per step, every dev
|
||||||
build usable), `signals` plus constructor wiring through one composition
|
build usable), `signals` plus constructor wiring through one composition
|
||||||
root (no new framework), dev builds `1.3.0-dev.N`, everyone moves to
|
root (no new framework), dev builds `1.3.0-dev.N`, everyone moves to
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ abstract class RelayConstants {
|
|||||||
|
|
||||||
static const int stateHeartbeatSecs = 1;
|
static const int stateHeartbeatSecs = 1;
|
||||||
static const int pingIntervalSecs = 5;
|
static const int pingIntervalSecs = 5;
|
||||||
|
/// Server liveness defaults ([Liveness]): a member silent this long is
|
||||||
|
/// stale, then removed.
|
||||||
|
static const int staleAfterSecs = 15;
|
||||||
|
static const int dropAfterSecs = 30;
|
||||||
|
|
||||||
// Base64-char count of the avatar string, enforced at hello and
|
// Base64-char count of the avatar string, enforced at hello and
|
||||||
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
|
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ import 'queue.dart';
|
|||||||
|
|
||||||
typedef SyncClock = DateTime Function();
|
typedef SyncClock = DateTime Function();
|
||||||
|
|
||||||
|
/// How long the server bears a silent member. [staleAfter] and [dropAfter]
|
||||||
|
/// are measured on the engine's clock ([SyncEngine.clock]) by its 1 s
|
||||||
|
/// sweep, so a test with a fake clock steps through them without waiting;
|
||||||
|
/// [pingInterval] is the WebSocket's own timer (real time).
|
||||||
|
class Liveness {
|
||||||
|
/// No frame for longer than this: stale (out of the set, feed `stale`).
|
||||||
|
final Duration staleAfter;
|
||||||
|
/// No frame for longer than this: the member is removed (feed `leave`)
|
||||||
|
/// and its socket closed.
|
||||||
|
final Duration dropAfter;
|
||||||
|
/// WebSocket ping on every `/sync` socket.
|
||||||
|
final Duration pingInterval;
|
||||||
|
const Liveness({
|
||||||
|
this.staleAfter = const Duration(seconds: RelayConstants.staleAfterSecs),
|
||||||
|
this.dropAfter = const Duration(seconds: RelayConstants.dropAfterSecs),
|
||||||
|
this.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
enum Transport { playing, held, finished }
|
enum Transport { playing, held, finished }
|
||||||
|
|
||||||
enum Correction { none, slowDown, seek }
|
enum Correction { none, slowDown, seek }
|
||||||
@@ -141,8 +160,9 @@ class EngineFeed {
|
|||||||
|
|
||||||
class SyncEngine {
|
class SyncEngine {
|
||||||
final SyncClock clock;
|
final SyncClock clock;
|
||||||
final Duration staleAfter;
|
final Liveness liveness;
|
||||||
final Duration dropAfter;
|
Duration get staleAfter => liveness.staleAfter;
|
||||||
|
Duration get dropAfter => liveness.dropAfter;
|
||||||
final EngineFeed feed = EngineFeed();
|
final EngineFeed feed = EngineFeed();
|
||||||
final Map<String, EngineMember> members = {};
|
final Map<String, EngineMember> members = {};
|
||||||
|
|
||||||
@@ -167,10 +187,8 @@ class SyncEngine {
|
|||||||
int _seekSeq = 0;
|
int _seekSeq = 0;
|
||||||
int _seekEventId = -1;
|
int _seekEventId = -1;
|
||||||
|
|
||||||
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
|
SyncEngine({SyncClock? clock, this.liveness = const Liveness()})
|
||||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||||
staleAfter = staleAfter ?? const Duration(seconds: 15),
|
|
||||||
dropAfter = dropAfter ?? const Duration(seconds: 30),
|
|
||||||
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
|
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
|
||||||
|
|
||||||
DateTime get now => clock();
|
DateTime get now => clock();
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// Which way bytes go through a [FlakyLink]: [up] client to server,
|
||||||
|
/// [down] server to client.
|
||||||
|
enum Flow { up, down, both }
|
||||||
|
|
||||||
|
/// A TCP proxy for tests that need a bad link between a client and a
|
||||||
|
/// server (DEVELOPMENT.md "Tests"): every accepted connection is a
|
||||||
|
/// [FlakyLink] the test steers while it runs: pass through, blackhole (a
|
||||||
|
/// half-open link), added latency, a late chunk, a cut after N bytes, a
|
||||||
|
/// reset. It knows nothing about WebSocket or HTTP; a "frame" is one chunk
|
||||||
|
/// as it was read from the socket (on loopback that is usually one write,
|
||||||
|
/// e.g. one small WebSocket frame).
|
||||||
|
class FlakyProxy {
|
||||||
|
final String upstreamHost;
|
||||||
|
final int upstreamPort;
|
||||||
|
final ServerSocket _server;
|
||||||
|
|
||||||
|
/// Every connection so far, in accept order.
|
||||||
|
final List<FlakyLink> links = [];
|
||||||
|
|
||||||
|
/// Called with each new link before any byte goes through it, e.g. to
|
||||||
|
/// blackhole or reset the next connection.
|
||||||
|
void Function(FlakyLink link)? onConnect;
|
||||||
|
|
||||||
|
final _accepted = StreamController<FlakyLink>.broadcast(sync: true);
|
||||||
|
|
||||||
|
FlakyProxy._(this._server, this.upstreamHost, this.upstreamPort, this.onConnect) {
|
||||||
|
_server.listen(_accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listens on a free loopback port and forwards to
|
||||||
|
/// [upstreamHost]:[upstreamPort].
|
||||||
|
static Future<FlakyProxy> start(int upstreamPort,
|
||||||
|
{String upstreamHost = '127.0.0.1', void Function(FlakyLink link)? onConnect}) async {
|
||||||
|
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
return FlakyProxy._(server, upstreamHost, upstreamPort, onConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port clients dial instead of the server's.
|
||||||
|
int get port => _server.port;
|
||||||
|
|
||||||
|
/// Each new link as it is accepted.
|
||||||
|
Stream<FlakyLink> get connections => _accepted.stream;
|
||||||
|
|
||||||
|
/// The latest link, null before the first.
|
||||||
|
FlakyLink? get last => links.isEmpty ? null : links.last;
|
||||||
|
|
||||||
|
Future<void> _accept(Socket client) async {
|
||||||
|
final link = FlakyLink._(client);
|
||||||
|
links.add(link);
|
||||||
|
onConnect?.call(link);
|
||||||
|
_accepted.add(link);
|
||||||
|
if (link.isClosed) return;
|
||||||
|
try {
|
||||||
|
final upstream = await Socket.connect(upstreamHost, upstreamPort);
|
||||||
|
link._attach(upstream);
|
||||||
|
} catch (_) {
|
||||||
|
link._close(rst: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops accepting and closes every link.
|
||||||
|
Future<void> close() async {
|
||||||
|
await _server.close();
|
||||||
|
for (final l in links) {
|
||||||
|
l._close(rst: false);
|
||||||
|
}
|
||||||
|
await _accepted.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One proxied connection. Modes change at once and can be combined
|
||||||
|
/// ([delay] with [holdNext], say); [pass] clears them all.
|
||||||
|
class FlakyLink {
|
||||||
|
final Socket _client;
|
||||||
|
Socket? _upstream;
|
||||||
|
late final _Pipe _up = _Pipe(this, Flow.up);
|
||||||
|
late final _Pipe _down = _Pipe(this, Flow.down);
|
||||||
|
final _ready = Completer<void>();
|
||||||
|
final _closed = Completer<void>();
|
||||||
|
bool _blackholed = false;
|
||||||
|
|
||||||
|
/// Latency added to every chunk, each way (on top of the link's own; a
|
||||||
|
/// chunk never overtakes an earlier one).
|
||||||
|
Duration delay = Duration.zero;
|
||||||
|
|
||||||
|
FlakyLink._(this._client) {
|
||||||
|
_quiet(_client);
|
||||||
|
_client.listen(_up._onData, onDone: _up._onDone, onError: (_) => _close(rst: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _attach(Socket upstream) {
|
||||||
|
_upstream = upstream;
|
||||||
|
_quiet(upstream);
|
||||||
|
if (isClosed) {
|
||||||
|
upstream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upstream.listen(_down._onData, onDone: _down._onDone, onError: (_) => _close(rst: false));
|
||||||
|
_ready.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _quiet(Socket s) {
|
||||||
|
try {
|
||||||
|
s.setOption(SocketOption.tcpNoDelay, true);
|
||||||
|
} catch (_) {}
|
||||||
|
s.done.then((_) {}, onError: (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes delivered client to server / server to client.
|
||||||
|
int get bytesUp => _up.forwarded;
|
||||||
|
int get bytesDown => _down.forwarded;
|
||||||
|
|
||||||
|
/// Bytes swallowed by [blackhole] (and by a cut), both ways.
|
||||||
|
int get dropped => _up.dropped + _down.dropped;
|
||||||
|
|
||||||
|
bool get isClosed => _closed.isCompleted;
|
||||||
|
|
||||||
|
/// Completes when the link is gone (both ends closed, a reset, a cut).
|
||||||
|
Future<void> get closed => _closed.future;
|
||||||
|
|
||||||
|
/// Plain pass-through again: no blackhole, delay, held chunk or cut.
|
||||||
|
/// Bytes swallowed meanwhile stay lost (a WebSocket on it is then
|
||||||
|
/// usually broken).
|
||||||
|
void pass() {
|
||||||
|
_blackholed = false;
|
||||||
|
delay = Duration.zero;
|
||||||
|
for (final p in [_up, _down]) {
|
||||||
|
p._holdNext = null;
|
||||||
|
p._cutLeft = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps both sockets open and drops every byte both ways, a closing
|
||||||
|
/// end included: neither side learns anything (a half-open link, like a
|
||||||
|
/// laptop lid closed or a NAT forgetting the flow). Chunks already
|
||||||
|
/// delayed are dropped too.
|
||||||
|
void blackhole() => _blackholed = true;
|
||||||
|
|
||||||
|
bool get blackholed => _blackholed;
|
||||||
|
|
||||||
|
/// The next chunk going [flow] (and, as TCP keeps order, everything
|
||||||
|
/// behind it) arrives [late] later than it would.
|
||||||
|
void holdNext(Duration late, {Flow flow = Flow.down}) {
|
||||||
|
for (final p in _pipes(flow)) {
|
||||||
|
p._holdNext = late;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delivers [bytes] more going [flow] (counted per direction; with
|
||||||
|
/// [Flow.both] whichever gets there first), then resets the link.
|
||||||
|
void cutAfter(int bytes, {Flow flow = Flow.both}) {
|
||||||
|
for (final p in _pipes(flow)) {
|
||||||
|
p._cutLeft = bytes;
|
||||||
|
}
|
||||||
|
if (bytes <= 0) reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aborts both ends at once: an RST where the platform lets us ask for
|
||||||
|
/// one (SO_LINGER 0), otherwise an abrupt close. Nothing queued is sent.
|
||||||
|
void reset() => _close(rst: true);
|
||||||
|
|
||||||
|
List<_Pipe> _pipes(Flow flow) => switch (flow) {
|
||||||
|
Flow.up => [_up],
|
||||||
|
Flow.down => [_down],
|
||||||
|
Flow.both => [_up, _down],
|
||||||
|
};
|
||||||
|
|
||||||
|
void _close({required bool rst}) {
|
||||||
|
if (isClosed) return;
|
||||||
|
_closed.complete();
|
||||||
|
for (final s in [_client, _upstream]) {
|
||||||
|
if (s == null) continue;
|
||||||
|
if (rst) _lingerZero(s);
|
||||||
|
s.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _lingerZero(Socket s) {
|
||||||
|
try {
|
||||||
|
// struct linger { int l_onoff = 1; int l_linger = 0; }
|
||||||
|
final v = ByteData(8)
|
||||||
|
..setInt32(0, 1, Endian.host)
|
||||||
|
..setInt32(4, 0, Endian.host);
|
||||||
|
final soLinger = Platform.isLinux || Platform.isAndroid ? 13 : 0x80;
|
||||||
|
s.setRawOption(RawSocketOption(RawSocketOption.levelSocket, soLinger, v.buffer.asUint8List()));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bothDone() {
|
||||||
|
if (_up._srcDone && _down._srcDone) _close(rst: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pipe {
|
||||||
|
final FlakyLink link;
|
||||||
|
final Flow flow;
|
||||||
|
_Pipe(this.link, this.flow);
|
||||||
|
|
||||||
|
int forwarded = 0;
|
||||||
|
int dropped = 0;
|
||||||
|
int? _cutLeft;
|
||||||
|
Duration? _holdNext;
|
||||||
|
bool _srcDone = false;
|
||||||
|
Future<void>? _chainTail;
|
||||||
|
|
||||||
|
Socket? get _dst => flow == Flow.up ? link._upstream : link._client;
|
||||||
|
|
||||||
|
Future<void> get _chain => _chainTail ??= link._ready.future;
|
||||||
|
|
||||||
|
void _then(Future<void> Function() step) {
|
||||||
|
_chainTail = _chain.then((_) => step()).catchError((Object _) => link._close(rst: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onData(Uint8List data) {
|
||||||
|
if (link.isClosed) return;
|
||||||
|
if (link._blackholed) {
|
||||||
|
dropped += data.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var chunk = data;
|
||||||
|
var cut = false;
|
||||||
|
final left = _cutLeft;
|
||||||
|
if (left != null) {
|
||||||
|
if (chunk.length >= left) {
|
||||||
|
dropped += chunk.length - left;
|
||||||
|
chunk = Uint8List.sublistView(chunk, 0, left);
|
||||||
|
_cutLeft = 0;
|
||||||
|
cut = true;
|
||||||
|
} else {
|
||||||
|
_cutLeft = left - chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var due = DateTime.now().add(link.delay);
|
||||||
|
final hold = _holdNext;
|
||||||
|
if (hold != null) {
|
||||||
|
_holdNext = null;
|
||||||
|
due = due.add(hold);
|
||||||
|
}
|
||||||
|
_then(() async {
|
||||||
|
final wait = due.difference(DateTime.now());
|
||||||
|
if (wait > Duration.zero) await Future<void>.delayed(wait);
|
||||||
|
if (link.isClosed) return;
|
||||||
|
if (link._blackholed) {
|
||||||
|
dropped += chunk.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final dst = _dst!;
|
||||||
|
if (chunk.isNotEmpty) {
|
||||||
|
dst.add(chunk);
|
||||||
|
forwarded += chunk.length;
|
||||||
|
}
|
||||||
|
if (cut) {
|
||||||
|
await dst.flush();
|
||||||
|
link.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDone() {
|
||||||
|
_srcDone = true;
|
||||||
|
if (link._blackholed) {
|
||||||
|
link._bothDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_then(() async {
|
||||||
|
// Half-close: the far end reads its end of stream, may still answer.
|
||||||
|
if (!link.isClosed && !link._blackholed) unawaited(_dst?.close().then((_) {}, onError: (_) {}));
|
||||||
|
link._bothDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/// Test support shared by core, server and desktop tests; not part of
|
||||||
|
/// `core.dart` and never imported by app or server code.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/testing/flaky_proxy.dart';
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// The flaky-link proxy itself, against a loopback echo server.
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/testing.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A client socket through the proxy, collecting what comes back.
|
||||||
|
class _Client {
|
||||||
|
final Socket socket;
|
||||||
|
final received = <int>[];
|
||||||
|
final done = Completer<Object?>();
|
||||||
|
_Client(this.socket) {
|
||||||
|
socket.done.then((_) {}, onError: (_) {});
|
||||||
|
socket.listen(received.addAll,
|
||||||
|
onDone: () => done.isCompleted ? null : done.complete(null),
|
||||||
|
onError: (Object e) => done.isCompleted ? null : done.complete(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_Client> dial(FlakyProxy p) async => _Client(await Socket.connect('127.0.0.1', p.port));
|
||||||
|
|
||||||
|
void send(String s) => socket.add(s.codeUnits);
|
||||||
|
String get text => String.fromCharCodes(received);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> until(bool Function() ok, {Duration limit = const Duration(seconds: 3)}) async {
|
||||||
|
final end = DateTime.now().add(limit);
|
||||||
|
while (!ok()) {
|
||||||
|
if (DateTime.now().isAfter(end)) fail('condition not met in $limit');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ServerSocket echo;
|
||||||
|
late FlakyProxy proxy;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
echo = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
echo.listen((s) {
|
||||||
|
s.done.then((_) {}, onError: (_) {});
|
||||||
|
s.listen(s.add, onDone: () => s.close().then((_) {}, onError: (_) {}), onError: (_) {});
|
||||||
|
});
|
||||||
|
proxy = await FlakyProxy.start(echo.port);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await proxy.close();
|
||||||
|
await echo.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes bytes both ways and counts them', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
c.send('hello');
|
||||||
|
await until(() => c.text == 'hello');
|
||||||
|
expect(proxy.links, hasLength(1));
|
||||||
|
expect(proxy.last!.bytesUp, 5);
|
||||||
|
expect(proxy.last!.bytesDown, 5);
|
||||||
|
await c.socket.close();
|
||||||
|
await proxy.last!.closed.timeout(const Duration(seconds: 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blackhole drops everything both ways and keeps the socket open', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
c.send('a');
|
||||||
|
await until(() => c.text == 'a');
|
||||||
|
proxy.last!.blackhole();
|
||||||
|
c.send('bcd');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
expect(c.text, 'a');
|
||||||
|
expect(proxy.last!.dropped, 3);
|
||||||
|
expect(c.done.isCompleted, isFalse);
|
||||||
|
expect(proxy.last!.isClosed, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delay holds every chunk', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
await until(() => proxy.last != null);
|
||||||
|
proxy.last!.delay = const Duration(milliseconds: 150);
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
c.send('x');
|
||||||
|
await until(() => c.text == 'x');
|
||||||
|
// Up and down each wait 150 ms.
|
||||||
|
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(290));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holdNext delivers one chunk late and keeps order', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
await until(() => proxy.last != null);
|
||||||
|
proxy.last!.holdNext(const Duration(milliseconds: 300), flow: Flow.up);
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
c.send('1');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
c.send('2');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
expect(c.text, '', reason: 'both still held');
|
||||||
|
await until(() => c.text == '12');
|
||||||
|
expect(sw.elapsedMilliseconds, greaterThanOrEqualTo(290));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cutAfter delivers N bytes then resets', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
await until(() => proxy.last != null);
|
||||||
|
proxy.last!.cutAfter(3, flow: Flow.up);
|
||||||
|
c.send('abcdef');
|
||||||
|
await c.done.future.timeout(const Duration(seconds: 3));
|
||||||
|
expect(proxy.last!.bytesUp, 3);
|
||||||
|
expect(proxy.last!.isClosed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset ends the client at once', () async {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
c.send('a');
|
||||||
|
await until(() => c.text == 'a');
|
||||||
|
proxy.last!.reset();
|
||||||
|
await c.done.future.timeout(const Duration(seconds: 3));
|
||||||
|
expect(proxy.last!.isClosed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onConnect can steer a link before any byte goes through', () async {
|
||||||
|
proxy.onConnect = (l) => l.reset();
|
||||||
|
// The RST can beat the connect's own completion.
|
||||||
|
try {
|
||||||
|
final c = await _Client.dial(proxy);
|
||||||
|
await c.done.future.timeout(const Duration(seconds: 3));
|
||||||
|
expect(c.text, '');
|
||||||
|
} on Object catch (e) {
|
||||||
|
// dart:io throws the bare OSError here (ECONNRESET), on some
|
||||||
|
// platforms a SocketException.
|
||||||
|
expect(e, anyOf(isA<OSError>(), isA<SocketException>()));
|
||||||
|
}
|
||||||
|
expect(proxy.last!.isClosed, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// groups "sitting out", "membership + gate", "presence gate", "stale ready
|
// groups "sitting out", "membership + gate", "presence gate", "stale ready
|
||||||
// rejoins" and the member rules of "swap" pin today's presence (out flag,
|
// rejoins" and the member rules of "swap" pin today's presence (out flag,
|
||||||
// stale sweep, leave on drop); presence In/Away/Gone with grace replaces
|
// stale sweep, leave on drop); presence In/Away/Gone with grace replaces
|
||||||
// them. The clock, correction, end-of-file and wire groups stay.
|
// them ("liveness on the engine clock" then steps through the grace). The clock, correction, end-of-file and wire groups stay.
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -618,6 +618,35 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('liveness on the engine clock', () {
|
||||||
|
test('stale and drop follow Liveness and the fake clock, not wall time', () {
|
||||||
|
engine = SyncEngine(
|
||||||
|
clock: clock,
|
||||||
|
liveness: const Liveness(staleAfter: Duration(minutes: 5), dropAfter: Duration(minutes: 10)));
|
||||||
|
join('a', 'alice');
|
||||||
|
engine.applyReady('a', true);
|
||||||
|
engine.feed.drain();
|
||||||
|
t = t.add(const Duration(minutes: 5));
|
||||||
|
engine.tick();
|
||||||
|
expect(engine.members['a']!.inSet, isTrue, reason: 'not past staleAfter yet');
|
||||||
|
t = t.add(const Duration(seconds: 1));
|
||||||
|
engine.tick();
|
||||||
|
expect(engine.members['a']!.inSet, isFalse);
|
||||||
|
expect(engine.feed.drain().map((f) => f.action), ['stale']);
|
||||||
|
t = t.add(const Duration(minutes: 4, seconds: 59));
|
||||||
|
expect(engine.sweepDropped(), isEmpty, reason: 'exactly dropAfter is not past it');
|
||||||
|
t = t.add(const Duration(seconds: 1));
|
||||||
|
expect(engine.sweepDropped(), ['a']);
|
||||||
|
expect(engine.feed.drain().map((f) => f.action), ['leave']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults are today\'s 15 s / 30 s', () {
|
||||||
|
expect(engine.staleAfter, const Duration(seconds: 15));
|
||||||
|
expect(engine.dropAfter, const Duration(seconds: 30));
|
||||||
|
expect(engine.liveness.pingInterval, const Duration(seconds: 5));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('end of file', () {
|
group('end of file', () {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
engine.media = const HelloMedia(name: 'f', size: 1, duration: 20);
|
engine.media = const HelloMedia(name: 'f', size: 1, duration: 20);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// restructure: rewrite in step 5 (D3: the watched rule moves from position coverage to time present)
|
// restructure: rewrite in step 6 (D3: the watched rule moves from position coverage to time present)
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ void mountSyncRoute(
|
|||||||
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
||||||
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
||||||
return WebSocketUpgrade((final ws) async {
|
return WebSocketUpgrade((final ws) async {
|
||||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
ws.pingInterval = hub.engine.liveness.pingInterval;
|
||||||
final firstText = Completer<String>();
|
final firstText = Completer<String>();
|
||||||
String? token;
|
String? token;
|
||||||
var welcomed = false;
|
var welcomed = false;
|
||||||
@@ -534,7 +534,7 @@ void mountSyncRoute(
|
|||||||
void handleIntent(String text) {
|
void handleIntent(String text) {
|
||||||
final session = hub.session;
|
final session = hub.session;
|
||||||
final viewer = token == null ? null : session.tokens[token];
|
final viewer = token == null ? null : session.tokens[token];
|
||||||
viewer?.touch();
|
viewer?.touch(hub.engine.now);
|
||||||
Map<String, Object?> decoded;
|
Map<String, Object?> decoded;
|
||||||
try {
|
try {
|
||||||
final parsed = jsonDecode(text);
|
final parsed = jsonDecode(text);
|
||||||
|
|||||||
@@ -65,9 +65,13 @@ class SyncServer {
|
|||||||
/// The session log while running with a [logDir].
|
/// The session log while running with a [logDir].
|
||||||
SessionLog? get sessionLog => _sessionLog;
|
SessionLog? get sessionLog => _sessionLog;
|
||||||
|
|
||||||
/// [ytDlp] replaces the server's own (tests).
|
/// [ytDlp] replaces the server's own (tests). [clock] and [liveness]
|
||||||
SyncServer(this.config, {YtDlp? ytDlp, this.loopbackOnly = false, this.logDir}) {
|
/// go to the engine: a test passes a fake clock to step through the
|
||||||
|
/// silence limits ([Liveness]) without waiting.
|
||||||
|
SyncServer(this.config,
|
||||||
|
{YtDlp? ytDlp, this.loopbackOnly = false, this.logDir, SyncClock? clock, Liveness liveness = const Liveness()}) {
|
||||||
hub = ServerHub(sessions, config.secret,
|
hub = ServerHub(sessions, config.secret,
|
||||||
|
engine: SyncEngine(clock: clock, liveness: liveness),
|
||||||
hostKey: config.hostKey,
|
hostKey: config.hostKey,
|
||||||
jellyfin: config.jellyfin,
|
jellyfin: config.jellyfin,
|
||||||
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
ytDlp: ytDlp ?? YtDlp(serverYtDlpDir(config.ytDlpDir), enabled: config.ytDlp));
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// restructure: rewrite in step 1 (Connection + Identity + Presence): both
|
||||||
|
// tests pin today's rules (a silent member is stale, then removed; a reset
|
||||||
|
// socket loses its member, so the redial is a new one, P1); with grace they
|
||||||
|
// expect `gone` and the same member back.
|
||||||
|
//
|
||||||
|
// A client reaching the server through FlakyProxy (DEVELOPMENT.md "Tests").
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_core/testing.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
/// A bare `/sync` client: hello, then a report every 100 ms (any frame
|
||||||
|
/// keeps a member fresh).
|
||||||
|
class _Client {
|
||||||
|
final WebSocketChannel ch;
|
||||||
|
final frames = <Map<String, Object?>>[];
|
||||||
|
bool ended = false;
|
||||||
|
Timer? _reports;
|
||||||
|
late final StreamSubscription<dynamic> _sub;
|
||||||
|
|
||||||
|
_Client(int port, {String resume = ''})
|
||||||
|
: ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:$port/sync')) {
|
||||||
|
ch.ready.then((_) {}, onError: (_) {});
|
||||||
|
_sub = ch.stream.listen((m) => frames.add(Map<String, Object?>.from(jsonDecode('$m') as Map)),
|
||||||
|
onDone: () => ended = true, onError: (_) => ended = true);
|
||||||
|
ch.sink.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: 'shh',
|
||||||
|
user: const HelloUser(name: 'alice'),
|
||||||
|
media: const HelloMedia(name: 'f.bin', size: 1, duration: 10.0),
|
||||||
|
resume: resume)
|
||||||
|
.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? get welcome => frames.where((f) => f['t'] == 'welcome').firstOrNull;
|
||||||
|
String get token => welcome!['token'] as String;
|
||||||
|
String get memberId => welcome!['memberId'] as String;
|
||||||
|
|
||||||
|
void startReports() => _reports = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
if (!ended) {
|
||||||
|
ch.sink.add(jsonEncode(const ReportMessage(position: 0, buffered: 0, eventId: 0).toJson()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
_reports?.cancel();
|
||||||
|
try {
|
||||||
|
await _sub.cancel();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await ch.sink.close().timeout(const Duration(seconds: 1));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> until(bool Function() ok, String what, {Duration limit = const Duration(seconds: 6)}) async {
|
||||||
|
final end = DateTime.now().add(limit);
|
||||||
|
while (!ok()) {
|
||||||
|
if (DateTime.now().isAfter(end)) fail('$what: not within $limit');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SyncServer server;
|
||||||
|
late FlakyProxy proxy;
|
||||||
|
final clients = <_Client>[];
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
// Short limits so the demo runs in a couple of seconds.
|
||||||
|
server = SyncServer(AppConfig(port: 0, secret: 'shh', name: 'test'),
|
||||||
|
liveness: const Liveness(staleAfter: Duration(milliseconds: 600), dropAfter: Duration(milliseconds: 1500)));
|
||||||
|
await server.start();
|
||||||
|
proxy = await FlakyProxy.start(server.boundPort);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
for (final c in clients) {
|
||||||
|
await c.close();
|
||||||
|
}
|
||||||
|
clients.clear();
|
||||||
|
await proxy.close();
|
||||||
|
await server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
_Client dial({String resume = ''}) {
|
||||||
|
final c = _Client(proxy.port, resume: resume);
|
||||||
|
clients.add(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a blackholed client goes stale, then is removed; it never hears of it', () async {
|
||||||
|
final c = dial();
|
||||||
|
await until(() => c.welcome != null, 'welcome');
|
||||||
|
c.startReports();
|
||||||
|
final engine = server.hub.engine;
|
||||||
|
EngineMember? member() => engine.members[c.token];
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||||
|
expect(engine.isStale(member()!), isFalse, reason: 'reports through the proxy keep it fresh');
|
||||||
|
|
||||||
|
proxy.last!.blackhole();
|
||||||
|
final seen = c.frames.length;
|
||||||
|
await until(() => member() != null && engine.isStale(member()!), 'stale');
|
||||||
|
await until(() => member() == null, 'removed');
|
||||||
|
expect(server.hub.sockets.containsKey(c.token), isFalse);
|
||||||
|
// Half-open: the server's close never reached the client.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
expect(c.ended, isFalse);
|
||||||
|
expect(c.frames.length, seen);
|
||||||
|
expect(proxy.last!.isClosed, isFalse);
|
||||||
|
expect(proxy.last!.dropped, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reset client sees the drop at once and redials; today it comes back as a new member', () async {
|
||||||
|
final c = dial();
|
||||||
|
await until(() => c.welcome != null, 'welcome');
|
||||||
|
final first = c.memberId;
|
||||||
|
proxy.last!.reset();
|
||||||
|
await until(() => c.ended, 'client sees the reset');
|
||||||
|
await until(() => !server.hub.engine.members.containsKey(c.token), 'server forgets the socket');
|
||||||
|
|
||||||
|
final again = dial(resume: c.token);
|
||||||
|
await until(() => again.welcome != null, 'welcome after redial');
|
||||||
|
expect(proxy.links, hasLength(2));
|
||||||
|
expect(again.memberId, isNot(first), reason: 'P1: no grace yet');
|
||||||
|
expect(server.hub.engine.members, hasLength(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user