Server on an isolate, dedicated server binary, room -> server (proto 5)

- serve(Map config, events) is the one server entrypoint: the app
  hosts it via Isolate.spawn (crash surfaced on the Host card, logs
  forwarded as [server]); bin/server.dart is the dedicated server,
  compiled into the bundle as watch-together-server. --headless gone.
- Host identity via a per-hosting hostKey in hello, replacing the
  loopback heuristic.
- Rename room -> server across code, files and wire (t:"state",
  /health name); protocol bumped to 5.
- CI compiles the server binary; isolate smoke tests in server and
  desktop suites. Docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:55:14 +03:00
1 parent d8a0729029
commit 81cac1214d
48 files changed
+1073 -423

No files matched your search

+56 -37
View File
@@ -7,40 +7,56 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`RoomEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`RoomServer`, `RoomHub`, `Session`, media endpoint), `bin/server.dart` | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), updater | the only shipped binary |
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the standalone server (stale until the dedicated server exists) | |
| `deploy` | systemd unit for the dedicated server | |
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
analyze + tests for core and server on every push. Server has no such test but
the same rule; it is checked by CI building without Flutter. The client
the same rule; CI checks it with `dart compile exe` of `bin/server.dart`. The client
WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
## Server lifecycle
Today (in-process): `SessionState.host()` creates a `RoomServer`, starts it
on the chosen port (bound to all IPv4 interfaces), then the app joins it over
`127.0.0.1` like any other client. Leaving or closing the window stops it.
`RoomServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
socket drop, room + feed broadcast. There is one session, id `default`.
One entrypoint: `serve(config, events:)` in `packages/server`. `config` is
an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
`ready` (with the port and a `stop` SendPort), `error` (start failed), and
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
all sockets and returns, and the isolate exits.
`--headless` does not work: the Flutter binary needs a display and exits with
"cannot open display" on a headless box. `packages/server/bin/server.dart` is
the only working standalone server (flags `--port`, `--secret`, `--name`,
`--media`, `--advertise-host`, `--config`; default port 8080).
- **Hosting from the app**: `SessionState.host()` spawns an isolate running
`serve` (`ServerIsolate`), waits for `ready`, then joins it over
`127.0.0.1` like any other client. Server logs go to the app's log with a
`[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
uncaught async error is logged and the server keeps going. If the isolate
dies anyway, the session shows "server crashed: ..." and leaves; Home
shows it on the Host card (during setup the host attempt fails with it).
Stop (leave, close, cancel during setup, a failed own join, being kicked)
sends `stop`, waits up to 3 s for exit, then kills the isolate.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--config`; default port 8080) into the same map and calls `serve`;
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
Planned: hosting from the app runs the server on a Dart isolate (ports and
sockets stay in the same process, UI stays responsive); the dedicated server
is a separate `dart compile exe` of `packages/server`. See [ROADMAP.md](ROADMAP.md).
`SyncServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
socket drop, state + feed broadcast. There is one session, id `default`.
Passwords: the default secret is empty, which means an open server (a
warning is logged). Otherwise one shared secret, compared at hello.
The host is the member that connected from a loopback address; it cannot be
kicked. Kick = revoke token, send `kicked`, close; the
person may rejoin at once with a new token.
Host: when hosting, the app generates a random `hostKey` per hosting, puts
it in the server config and sends it in its own `hello`. The member whose
key matches is the host and cannot be kicked. The address a connection
comes from plays no part (so a reverse proxy or tunnel on the same machine
does not make everyone the host). The dedicated server has no key, so it
has no host. Kick = revoke token, send `kicked`, close; the person may
rejoin at once with a new token.
## Sync model
@@ -63,20 +79,20 @@ Loosely modeled on Syncplay, with a different readiness design.
4 s buffered or stale flips the server to HELD (feed action `pause` by `server`).
Recovery resumes by itself when the gate passes.
- **Seek**: allowed from any member in any transport; the server writes the
position and bumps `eventId`; flags are untouched. Clients diff each `room`
broadcast by `eventId` (`RoomDiff`): new event means seek to the computed
room position, transport change means play/pause. Between events: drift
position and bumps `eventId`; flags are untouched. Clients diff each `state`
broadcast by `eventId` (`StateDiff`): new event means seek to the computed
server position, transport change means play/pause. Between events: drift
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
until level, otherwise nothing. Local seeks are detected by the coordinator
polling mpv position once per second (`_detectUserSeek`, jump over 2 s and
more than 2 s from the room) and sent as a `seek` intent; a retry
(`_checkSeekLanded`) re-applies a room seek that has not landed after 1.5 s.
more than 2 s from the server position) and sent as a `seek` intent; a retry
(`_checkSeekLanded`) re-applies a server seek that has not landed after 1.5 s.
Known roughness: see ROADMAP.
- **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.
Closed sockets are polled with `isClosed` every second: Relic's server-side
`onDone` does not fire for dead peers, so presence never relies on it.
- **Client policies**: no room broadcast for 5 s means connection lost, clear
- **Client policies**: no state broadcast for 5 s means connection lost, clear
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
restored from the server's state. Cancel during setup aborts the join.
@@ -96,14 +112,14 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
Two sources, same sync.
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `room`, and as `mediaUrl` in `welcome`. Each app plays it
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
directly; the server is only the clock.
- **Host local file**: `RoomServer.setLocalFile` / `mediaPath` makes the file
- **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
table, gated by the viewer's token. The server builds the URL per viewer
(`http://<host>:<port>/m/default/<token>/<name>`), so tokens never reach
other viewers. `<host>` is the authority the client dialled; `advertiseHost`
(default empty) only overrides it when set explicitly. When a `room`
(default empty) only overrides it when set explicitly. When a `state`
broadcast has no `media.url`, the client builds the URL itself from the host
it connected to. Bytes stream file to socket and are counted as streamed
(`bytesServed`); nothing is stored. The host plays through loopback like
@@ -146,10 +162,10 @@ Reload video.
Player lifecycle: the `Player` and its `VideoController` live for one
session: created on join, disposed on leave, replaced by `recreate()` for
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
`RoomDiff` actions or the play gate (`localPlayAllowed`: self in-set and
ready). A room seek within 0.5 s of the current position is skipped. `leave()`
`StateDiff` actions or the play gate (`localPlayAllowed`: self in-set and
ready). A server seek within 0.5 s of the current position is skipped. `leave()`
stops the player (otherwise audio keeps playing). On media change
(`mediaId` bump) the coordinator opens the new URL and `RoomDiff` seeks to 0,
(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0,
holds and resets rate.
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
@@ -186,7 +202,7 @@ stalls silently. The app has a `HealthStatus` signal (severity + message)
for connecting, reconnecting, wrong password, version mismatch, kicked,
host offline, and starvation ("waiting for X - N.N s buffered").
Errors are logged with a stack trace at the boundary, then turned into a
displayable state. The server's `GET /health` returns JSON (`ok`, `room`,
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
the app does not read it.
@@ -217,9 +233,12 @@ this repo, written by `tool/release.sh`.
- No roles, friends trust model: simplest thing that fits the use.
- Blank password = open server: the owner chooses between open and private.
- The host cannot be kicked: the server dies with the host anyway.
- Server ships inside the app (and, planned, as a separate compiled server):
a Flutter binary cannot run headless, so a dedicated server needs its own
build of `packages/server`.
- Server runs inside the app on an isolate, and ships as a separate compiled
server: a Flutter binary cannot run headless, so a dedicated server needs
its own build of `packages/server`. An isolate keeps the UI responsive and
shares the process (sockets, file paths) without a second executable.
- Host by key, not by address: loopback detection broke behind a
same-machine reverse proxy or tunnel.
- Server owns the clock; reports never move it: avoids ping-pong between clients.
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
- Relic for the server, `web_socket_channel` for the client; packages are
@@ -228,5 +247,5 @@ this repo, written by `tool/release.sh`.
(e.g. a reverse proxy) is a documentation item.
- One session per server, id `default`: the map is keyed for later but unused.
- No server storage: config and logs only; media never touches the server disk.
- Avatars ride in the 1 Hz `room` broadcast (cap 256 KiB base64 per avatar):
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
simple, costs host uplink; hash-based serving is on the roadmap.
+31 -6
View File
@@ -12,11 +12,14 @@ mpv libraries come from `media_kit_libs_video`.
cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
cd packages/server && dart compile exe bin/server.dart -o watch-together-server
```
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it).
tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as
`watch-together-server`, also with `--skip-build`.
## Dev loop
@@ -26,8 +29,10 @@ Run a server without the app, then point apps at it:
dart run packages/server/bin/server.dart --port 8080 --secret x [--media FILE]
```
Prints `READY port=... room=...` and stops on SIGINT. Then Join from one or
more apps at `127.0.0.1:8080`. Or just Host from the app. mpv env override:
This is the same `serve()` the app runs when hosting and the same code as
the dedicated binary. Prints `READY port=... name=...` and stops on SIGINT
or SIGTERM. Then Join from one or more apps at `127.0.0.1:8080`. Or just
Host from the app. mpv env override:
`SYNC_RELAY_HWDEC` (wins over the Settings choice). `SYNC_RELAY_VO` has no
effect with embedded video: media_kit forces `vo=libmpv`.
@@ -43,13 +48,18 @@ and `e2e_check` (simulated viewers against a live server).
- `sync_playback_e2e_test.dart` and `coord_presence_test.dart` use real mpv;
the e2e one also expects `~/Videos/chiyo.mp4`. Run only tests related to
what you changed.
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server only.
Desktop tests are not in CI.
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
`dart compile exe` of the dedicated server (fails if Flutter creeps into
`packages/server`). Desktop tests are not in CI.
- `packages/server/test/serve_isolate_test.dart` runs `serve` in a real
isolate; `apps/desktop/test/server_isolate_test.dart` covers the app's
host path (isolate server, raw WebSocket join, crash, rehost).
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
Traps:
- Never pump the session screen or media_kit `Video` in `flutter test`: the
runner hangs (kill the `flutter_tester` by PID). Test pure logic
(`RoomDiff`, coordinator without widgets) instead.
(`StateDiff`, coordinator without widgets) instead.
- Never depend on a server-side WebSocket `onDone`; assert presence with
deadline polling.
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
@@ -73,9 +83,24 @@ Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
updater skips the check when absent. Workflows live in `.gitea/workflows`
(the forge reads `.gitea` before `.github`).
## Debugging the hosted server
When hosting, the server runs on its own isolate (`ServerIsolate`,
`apps/desktop/lib/src/state/server_isolate.dart`), in the same process.
Breakpoints in `packages/server` work under `flutter run`: the debugger
attaches to spawned isolates too (it shows as `server`). Its log lines are
printed with a `[server]` prefix. Hot reload reloads the server isolate's
code too, but its state (members, clock, sockets) survives until hosting
restarts; changes to startup code (`SyncServer.start`, routes) need a new
Host. Without the app: `dart run packages/server/bin/server.dart`.
## Gotchas
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
- A killed isolate (`Isolate.kill`) does not close its listening socket:
the port stays bound in the process until exit. `SyncServer` binds with
`shared: true` so a new hosted server in the same process can take it
over; a normal stop closes it.
- Relic server-side `onDone` does not fire for dead peers. Presence is the
`isClosed` sweep + `onError` forget + `lastSeen` timeouts.
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
+2 -2
View File
@@ -33,8 +33,8 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
- **Host from the app**: the app you click Host in also runs the server.
Simplest. The server lives as long as that app.
- **Dedicated server**: a separate server program on an always-on box.
Planned, see [ROADMAP.md](ROADMAP.md).
- **Dedicated server**: `watch-together-server`, a separate program in the
release tarball, for an always-on box. See the README.
Friends join either way with the Join card on Home.
+19 -14
View File
@@ -1,31 +1,32 @@
# Protocol (proto = 4)
# Protocol (proto = 5)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/room_hub.dart`.
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/server_hub.dart`.
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
the same commit. Changes are additive.
the same commit. Changes are additive where possible; a client and server
with different `proto` refuse each other at `hello` (`proto mismatch`).
## Transport
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
- Match incoming frames by `t`, never by order: `welcome` can race `room`.
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
- Failures: server sends `{"t":"err","message":...}` and closes.
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
with a non-finite number, e.g. NaN position), `bad swap media`,
`swap needs a url or the hosted file` (empty swap).
- `GET /health` returns JSON (`ok`, `room`, `proto`, `viewers`, `bytesServed`,
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
serves the host's file (Range table in ARCHITECTURE.md).
## Client to server
```
{"t":"hello","proto":4,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null}}
{"t":"hello","proto":5,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"..."}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
{"t":"force","target":"all"|"<memberId>","ready":true}
{"t":"seek","position":123.4}
@@ -37,8 +38,12 @@ the same commit. Changes are additive.
Field notes:
- `hello.secret`: the server password; a server with an empty secret is open.
The member connecting from a loopback address is the host.
`hello.media` is carried but not used by the server today.
- `hello.hostKey` (optional, omitted when empty): the hosting app
generates a random key per hosting, gives it to its server and sends it
from its own client. The member whose key equals the server's is the host
and cannot be kicked. A server without a key (the dedicated server) has no
host. Where the connection comes from does not matter.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
trimmed, empty becomes `?`, cut to 32 characters.
- `chat.text` is cut to 500 characters.
@@ -49,8 +54,8 @@ Field notes:
## Server to client
```
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<room>}}
{"t":"room","eventId":7,"transport":"playing"|"held","position":12.3,
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
{"t":"state","eventId":7,"transport":"playing"|"held","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}]}
@@ -64,8 +69,8 @@ Field notes:
media URL. `mediaUrl` is omitted when there is no media; it is a built
`/m/...` URL for a host file (host part = what the client dialled), otherwise
the shared link.
`welcome.state` has the same shape as `room`.
- `room` is sent every second and on every change. `position` is the value at
`welcome.state` has the same shape as `state`.
- `state` is sent every second and on every change. `position` is the value at
`serverTime`; clients extrapolate while `transport` is `playing`.
`eventId` increases on seek, swap and transport changes.
`mediaId` increases on swap.
@@ -80,7 +85,7 @@ Field notes:
## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the room
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
position and buffers. A joining member is `ready=false, inSet=false` until
the first `ready`. A socket that dies loses its token; reconnect is a fresh
`hello`, after which the client re-sends its prior ready flag.
@@ -90,4 +95,4 @@ the first `ready`. A socket that dies loses its token; reconnect is a fresh
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
`RelayConstants.chunkBytes`, `creditWindowChunks`, `maxConcurrentOpen` and the
`open`, `ack`, `cancel`, `eof`, `buffered`, `state`, `event` kinds in
`MessageKind` are unused leftovers.
`MessageKind` are unused leftovers (the `state` frame does not use the enum).
+10 -19
View File
@@ -6,17 +6,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
## Backlog
1. **Isolate server + dedicated server.** Replace host detection by loopback
peer (breaks behind a same-machine reverse proxy or tunnel) with an explicit
host token. Run the server on a Dart isolate
when hosting from the app; build the dedicated server as a separate
`dart compile exe` of `packages/server` (a Flutter binary cannot be
headless); drop `--headless`; rename "room" to "server" in code
(`RoomServer`, `RoomHub`, `RoomBroadcast`, wire `t:'room'`, `room` in
`/health`, config defaults) with the proto bump that implies; refresh
`deploy/` and the packaging.
2. **Sync fixes.**
1. **Sync fixes.**
- Jumpy seek. Root cause: the media_kit seek bar calls `player.seek`
continuously while dragging; the coordinator only notices through its
1 s position poll (`_detectUserSeek`), so it fights the server echo and
@@ -34,22 +24,23 @@ off by deleting it in the same commit as the code. Wire changes also edit
a workaround (remove it then).
- Ghost member after an unclean drop until `dropAfter`.
3. **Rendering, then shaders.** media_kit falls back to software rendering
2. **Rendering, then shaders.** media_kit falls back to software rendering
on the dev machine. Suspect Impeller on Linux against media_kit's GL
texture path; investigate first, since shaders need hardware rendering and
it may also cause the video freeze. Then shaders: per-viewer local setting
using mpv `glsl-shaders`, shipping Anime4K presets (bloc97, MIT).
4. **Windows build + CI.** There is no Windows runner. Try
3. **Windows build + CI.** There is no Windows runner. Try
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux
runner, fallback a GitHub Actions mirror. The manifest needs per-platform
URLs and `release_publish.sh` must handle a second platform. The updater's
Windows apply path exists but is untested. Desktop tests are not in CI
either.
Windows apply path exists but is untested. The tarball's
`watch-together-server` needs a Windows `dart compile exe` too. Desktop
tests are not in CI either.
5. **Jellyfin** (epic, below).
4. **Jellyfin** (epic, below).
6. **Smaller items**
5. **Smaller items**
- Link conveniences: Ctrl+V pastes into the media field; a link swap in
session updates the remembered media; show the open link with an
open-in-browser button.
@@ -63,8 +54,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
OOM, no stuck session, drift recovers.
- Chat history pane (today: activity log only).
- Avatar caching: serve avatar bytes once by hash instead of base64 in
every `room` broadcast.
- The `room` broadcast carries the full media URL, which can include an
every `state` broadcast.
- The `state` broadcast carries the full media URL, which can include an
`api_key`.
- TLS: document a reverse proxy in front of the server.
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.