Commit Graph
3 Commits
Author SHA1 Message Date
172beb99ef fix(watch-together): stop a dropped socket reading as the host leaving (#487)
* feat(watch-together): make vote rooms actually vote

selection_mode has been stored, normalized and published since the
feature landed, and nothing has ever read it. A "vote" room behaved
exactly like a host_pick one: members could suggest and vote, the tally
was recorded and broadcast, and then the host promoted whatever they
liked regardless of it.

In a vote room the host now starts the winner rather than choosing it.
Promoting anything other than the leading suggestion is refused, because
being able to overrule the tally makes the mode host_pick with extra
steps and turns the vote counts on everyone else's screen into
decoration.

The winner is the head of the repository's existing ordering
(vote_count DESC, created_at ASC): most votes, ties to whoever suggested
first — deterministic, and re-suggesting a title cannot jump the queue.

A room where nobody has voted has no winner and says so, rather than
quietly promoting the oldest suggestion as though a vote had happened.

host_pick rooms are untouched: the host still promotes freely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): close the second door into a vote room's selection

Gating PromoteSuggestion left SelectItem wide open: it is host-only but
was not gated by selection mode, so the host of a vote room could set any
title directly and bypass the vote entirely. Enforcing the tally on one
path and not the other makes the vote counts on everyone else's screen
decoration.

A vote room now refuses a direct selection outright. The winner is the
only way in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): stop a dropped socket reading as the host leaving

hostDisconnectTTL was 15 seconds, which treated any transient drop as a
departure. An explicit leave and an explicit close already tear the room
down immediately, so this timer only ever covers a host who has NOT said
they are going — and at 15s a host who backgrounded the app, moved
between screens, or hit a brief network blip lost the room for everyone
with a "host_left" nobody could explain.

Two minutes survives a reconnect or an app switch, and is short enough
that a genuinely departed host does not leave a room open all evening.
The janitor still reaps idle rooms independently.

This matters for what the clients are growing into: a room you stay in
while you browse for something to suggest. A client that drops its socket
when the lobby leaves composition should cost you a reconnect, not the
room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(watch-together): let a vote room actually start its winner

The vote gate landed on both doors into a room's selection, but promoting
the winner walks through SelectItem to commit — so the gate meant to stop
the host bypassing the vote also stopped the vote itself. Vote rooms could
not start playback by any route.

Split the commit path: SelectItem keeps the gate for direct requests, and
PromoteSuggestion goes through the internal path once it has confirmed the
suggestion is the winner. Map ErrVoteRoomSelection in the promote handler
too, so a future regression there reads as a conflict rather than a 500.

Add service-level tests for both gates — the previous tests only covered
the pure winnerFrom helper, which is why the suite stayed green while vote
rooms were non-functional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-26 22:01:31 -04:00
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00