Ctrl K
ComunidadGuías…

proposals/2026-07-14-graceful-reconnect.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Graceful reconnect: survive a network switch, relax revoke to "no new access"

**Goal** — A network change (wifi↔cellular, a dead spot) must not stop playback and
wipe the queue. Playback continues across the blip; only a real revoke ends it — and a
revoke ends all NEW access instantly while letting the current track's buffered audio
play out.

**Tier** — T3. It changes revoke semantics, which is one of the two rules the whole app
exists to enforce (CLAUDE.md). Proposal + rollback + a hardware acceptance check
required.

## Why

Observed on the Pixel during the cellular test: switching networks kills the P2P
connection, and the shell reacts by calling `stop()` — which tears down the player AND
clears the queue. So a five-second network blip costs you the whole queue and your
place in it. That is the opposite of "playable anywhere."

The reason it was built this way: from the phone, a revoke and a network drop look
IDENTICAL at the instant of disconnect, and the safe reaction to "maybe revoked" was
"stop everything." That made "revoke stops the music within a second" true and provable
(DONE 2026-07-14), at the cost of making every transient drop just as violent.

## The decision that unlocks it (Tim, 2026-07-14)

**Revoke does not need to cut off the CURRENT track's already-buffered audio. It needs
to cut off everything NEW.** A revoked device may finish the ~20-50s ExoPlayer already
buffered (or the rest of a short track); it may not start the next track, browse,
search, or fetch art or any new bytes. "After this track, they are out."

This is sound against the real threats (lost phone, removed guest, an ex): they hear
the tail of one song, nothing more. Revoke never prevented CAPTURING the current stream
anyway — any player can record what it is playing — so the seconds were about the demo
feeling instant, not about a threat where they matter.

## What distinguishes a revoke from a network switch

They look identical at disconnect, but they DIVERGE ON RECONNECT:
- **Network switch** → the grant is still valid → reconnect SUCCEEDS.
- **Revoke** → the grant is tombstoned → reconnect is DENIED by the firewall.

So the phone does not need to guess at disconnect time. It keeps playing and lets the
reconnect result decide.

## Scope

**What changes**

1. **The shell stops tearing down on `host:disconnected`.** It keeps the player and the
   queue, and lets ExoPlayer keep playing its buffer. (`app/index.tsx`: the
   `if (msg.event === 'host:disconnected') stop()` line is the whole culprit.)
2. **The shim already reconnects on demand** — every request calls `ensure()`, which
   awaits a single-flight reconnect (the "press play on the lock screen after the phone
   slept" path). So ExoPlayer's next chunk request, or its retry of the one that broke
   mid-stream, blocks in the shim until reconnected, then serves from the right Range.
   A network switch becomes a stall bounded by reconnect time, with the buffer covering
   it — often inaudible.
3. **ExoPlayer's load-error policy** is tuned so a transient chunk failure retries
   (through the reconnect) instead of fataling the player.
4. **Revoke ends playback by STARVATION, not teardown.** On revoke, reconnect is
   denied, so the shim can serve no more bytes; the buffer drains and the track stops.
   No new track loads (the load is denied), so the queue cannot advance. The shell
   reacts to the PLAYER erroring / running dry — not to `host:disconnected` — and only
   THEN stops, clears, and says "access ended."
5. **Copy updates.** CLAUDE.md's acceptance test and the DONE/DECISIONS wording change
   from "playback must stop within a second" to "all NEW access must stop within a
   second (browse, next track, art, reconnect all denied); the current track may play
   out its buffer."

**What does NOT change**

- The transport, pairing, grant store, the firewall, and `revokeDevice` killing live
  connections. The HOST behavior is untouched; this is entirely client reaction.
- No offline / downloading. Nothing is persisted for later playback; when the buffer is
  gone and the connection is denied, the music stops. Pure streaming holds.
- Direct-play seeking, the transcode path, everything from the transcoding milestone.

**Optional knob** — "always finish the CURRENT track" (not just the buffered ~50s) by
buffering the whole current track on a revoke. Cheap for a cellular mp3 (~4MB), ~40MB
for a FLAC. Default to the simpler "play out what's buffered"; add this only if a
mid-track cut feels abrupt in practice.

## Compat

- Pure client change; old and new phones interoperate with any host. No wire change.
- A phone on the OLD build keeps stopping-and-clearing on a switch; a NEW build rides
  through it. Both enforce revoke (the new one by starvation, the old one by teardown).

## Verify

- **The headline acceptance test, revised, on hardware:** revoke the phone mid-song from
  the dashboard. Within a second: browsing fails, the next track will not start, art
  stops loading, and reconnect is denied. The current track may finish its buffer, then
  playback stops and the app says access ended. (This is the T3 check; it must pass
  before merge.)
- **Network switch on the Pixel:** play a track, toggle wifi↔cellular mid-song. Playback
  continues (a brief stall at most), the queue and position survive, and it advances to
  the next track normally.
- **Background suspend** (the existing case): still reconnects on resume, unchanged.
- `npm run verify` green; a worklet/shim test for "a request that arrives mid-reconnect
  awaits the reconnect and then serves" if it can be exercised under Bare.

## Rollback

- One behavioral switch (react to `host:disconnected` or not). Reverting the shell to
  call `stop()` on disconnect restores the old stop-and-clear exactly, with no data or
  protocol migration. Keep the revert a one-liner.

## Open questions

- **How long does ExoPlayer actually keep the buffer playing when its DataSource stalls
  mid-load?** If it fatals too eagerly, we may need a custom load-error policy or to
  hold the shim response open (heartbeat) during the reconnect rather than letting the
  request break. Measure on device.
- **Do we PAUSE at the buffer edge and auto-resume on reconnect, or hard-stop?** For a
  reconnect that takes longer than the buffer, a pause-then-resume is nicer than a stop.
  Depends on whether ExoPlayer will resume a stalled source cleanly.
- **The "you were revoked" message vs "you went through a tunnel."** Only say access
  ended after a reconnect is actually DENIED, never merely delayed — the same rule the
  background-disconnect work already set (DECISIONS 2026-07-14).