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
+20
-6
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
@@ -180,6 +180,12 @@ Loosely modeled on Syncplay, with a different readiness design.
|
||||
user is not seeking.
|
||||
- **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.
|
||||
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
|
||||
(also one stale at a swap) or the server is not PLAYING.
|
||||
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
|
||||
`close` before the socket), "Stop reconnecting" (`cancelReconnect`),
|
||||
"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
|
||||
`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
|
||||
servers and links, the profile picture and colour; saved even when a join
|
||||
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.
|
||||
|
||||
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`).
|
||||
|
||||
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
|
||||
(Linux `~/.local/share/moe.senkochan.watch_together/logs`); 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
|
||||
(Linux `~/.local/share/moe.senkochan.watch_together/logs`; with a
|
||||
profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
|
||||
"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
|
||||
`--config`, else `logs` next to the binary (systemd:
|
||||
`$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
|
||||
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`):
|
||||
`range_logger` (serve a file and log every Range request), `shaper`
|
||||
(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
|
||||
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
|
||||
behaviour that step of the restructure ([ROADMAP.md](ROADMAP.md)) changes;
|
||||
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,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
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):
|
||||
app id `dev.foxyz.starsync`, binary, Windows company `foxyz`; Hive out
|
||||
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
|
||||
self-update restart works; the old `.desktop` entry removed; the
|
||||
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).
|
||||
Transport: `wss://` preferred when a server offers it, `ws://` still
|
||||
allowed (a cleartext exception on Android); it only carries sync.
|
||||
Tests: many pin behaviour the restructure drops; step 0 audits them
|
||||
(delete what no longer fits, keep a few scenario tests for the core
|
||||
flows). Open questions below are decided during the restructure.
|
||||
Tests: many pin behaviour the restructure drops; each is tagged with the
|
||||
step that rewrites it, and the safety-net scenarios (DEVELOPMENT "Tests")
|
||||
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
|
||||
build usable), `signals` plus constructor wiring through one composition
|
||||
root (no new framework), dev builds `1.3.0-dev.N`, everyone moves to
|
||||
|
||||
Reference in new issue
Block a user