diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 03ee1f3..d937061 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -20,6 +20,7 @@ import { import { createAudioBackend, detectPlayers, + PlayerRestartedError, type AudioBackend, type BackendName, type DetectedPlayer, @@ -502,8 +503,25 @@ async function pause(): Promise { } } +/** mpv was killed/crashed: respawn it and restart playback from the saved + * position via the full play path (fresh loadfile, cover art, media + * registry). A bare unpause would target a dead — or freshly-idle — + * daemon and silently do nothing. */ +async function recoverPlayback(): Promise { + const ep = currentEpisode(); + if (ep && ep.audioUrl) { + await play(ep); + } else { + setError("Player is not running"); + } +} + async function resume(): Promise { if (!backend) return; + if (!backend.isAlive()) { + await recoverPlayback(); + return; + } try { await backend.resume(); setIsPlaying(true); @@ -515,6 +533,13 @@ async function resume(): Promise { media.setPlaybackState(true); } } catch (err) { + // Race: the daemon died between the liveness check above and the + // unpause — backend.resume() respawned it and threw + // PlayerRestartedError (the fresh daemon has no file loaded). + if (err instanceof PlayerRestartedError) { + await recoverPlayback(); + return; + } setError(err instanceof Error ? err.message : "Resume failed"); } } diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 882768a..c41bc71 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -24,6 +24,10 @@ * fills its demuxer cache ahead of time; the first real play just flips * `pause` to false — the ~2s network open is paid at boot, not on the * user's first Play. + * - Crash/kill recovery: a dead daemon (process exit or broken IPC socket) + * is detected on the next command; play() respawns a fresh daemon and + * reloads. resume() cannot unpause a freshly-idle daemon — it throws + * PlayerRestartedError so the caller reloads the episode via play(). */ import { platform } from "os"; @@ -274,12 +278,28 @@ class MpvConnection { } this.handleTeardown(); } + + /** True while the Unix socket is open — a live, reachable daemon. */ + isConnected(): boolean { + return this.sock !== null; + } } // ── mpv Backend ────────────────────────────────────────────────────── // One resident daemon for the app's lifetime, controlled over a single // persistent JSON IPC connection with property observation. +/** Thrown by resume() when the daemon restarted (killed/crashed) and the + * previously-loaded file is gone — the fresh daemon is idle, so the + * caller must reload the episode via the full play path instead of + * unpausing (which would silently do nothing). */ +export class PlayerRestartedError extends Error { + constructor() { + super("mpv restarted; episode must be reloaded"); + this.name = "PlayerRestartedError"; + } +} + /** Property observation ids (correlate property-change events). */ const OBS_TIME_POS = 1; const OBS_PAUSE = 2; @@ -318,14 +338,35 @@ export class MpvBackend implements AudioBackend { // ── Daemon lifecycle ───────────────────────────────────────────── private async ensureDaemon(): Promise { - if (this.proc && !this._exited && this.conn) return; + // Healthy = process alive AND its IPC socket open. A socket teardown + // with a living process (rare) is just as unusable as a dead one — + // every command would fail "not-connected" forever. + if (this.proc && !this._exited && this.conn?.isConnected()) return; if (this.startPromise) return this.startPromise; - this.startPromise = this.spawnDaemon().finally(() => { + this.startPromise = this.recoverDaemon().finally(() => { this.startPromise = null; }); return this.startPromise; } + /** Bring up a usable daemon. If the old process still lives with a dead + * IPC connection, kill it so the fresh spawn owns the socket path and + * no orphan lingers — and await its exit so its exit handler can't run + * after spawnDaemon() and clobber the new daemon's `_exited` flag. */ + private async recoverDaemon(): Promise { + const stale = this.proc; + if (stale && !this._exited) { + try { + stale.kill(); + } catch { + /* already gone */ + } + } + if (stale) await stale.exited.catch(() => {}); + this.conn = null; + await this.spawnDaemon(); + } + private async spawnDaemon(): Promise { // Clean up stale socket try { @@ -360,9 +401,15 @@ export class MpvBackend implements AudioBackend { this._exited = false; this.proc.exited .then(() => { + // Daemon died (crash or external kill): every per-file state + // is gone with it. _loadedUrl null forces the next play() + // down the full reload path; _position/_volume/_speed are + // kept so a recovery reload can carry them over. this._exited = true; this._intentPlaying = false; this._loadedUrl = null; + this._loadedPaused = false; + this._ended = false; this._paused = null; }) .catch(() => {}); @@ -590,6 +637,13 @@ export class MpvBackend implements AudioBackend { } async resume(): Promise { + // The daemon may have died while we were paused (crash/kill): bring + // a fresh one up. It starts idle — no file to unpause — so throw + // PlayerRestartedError and let the caller reload the episode. + await this.ensureDaemon(); + if (!this._loadedUrl) { + throw new PlayerRestartedError(); + } if (this._ended && this._loadedUrl) { // Play pressed on a finished episode: replay from the top. this._ended = false; @@ -609,7 +663,12 @@ export class MpvBackend implements AudioBackend { this._loadedPaused = false; } this._ended = false; - await this.send(["set_property", "pause", false]); + const resp = await this.send(["set_property", "pause", false]); + // Never claim success when the unpause didn't land: a dead/restarted + // daemon would otherwise leave the UI "playing" with no audio. + if (resp.error && resp.error !== "success") { + throw new Error(`mpv resume failed: ${resp.error}`); + } this._intentPlaying = true; } diff --git a/tests/audio-backend.test.ts b/tests/audio-backend.test.ts index f591be7..be2b2f8 100644 --- a/tests/audio-backend.test.ts +++ b/tests/audio-backend.test.ts @@ -12,6 +12,10 @@ * it by unpausing — the boot-restore fast path with no second load. * 6. EOF: the episode ends → isPlaying() goes false on its own; pressing * resume() afterwards replays from the top. + * 7. Daemon death: a killed/crashed mpv is detected (isAlive drops); + * resume() refuses to unpause the fresh idle daemon (throws + * PlayerRestartedError) and play() recovers by respawning a fresh + * daemon and loading the file. * * All playback runs silent (volume 0). Requires a real mpv on PATH; * tests skip where it is missing. @@ -19,7 +23,10 @@ import { test, expect } from "bun:test"; import { tmpdir } from "os"; import { join } from "path"; -import { MpvBackend } from "../src/utils/audio-player"; +import { + MpvBackend, + PlayerRestartedError, +} from "../src/utils/audio-player"; const SAMPLE_RATE = 22050; const FREQ = 440; @@ -165,6 +172,46 @@ test.skipIf(!hasMpv)( { timeout: 20000 }, ); +test.skipIf(!hasMpv)( + "daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one", + async () => { + fixtureWavs(); + const backend = new MpvBackend(); + try { + await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 }); + await waitFor("playing", () => backend.isPlaying()); + await waitFor( + "position advances", + async () => (await backend.getPosition()) > 0.5, + ); + + // Simulate a crash: SIGKILL the daemon out from under us. + const proc = (backend as unknown as { proc: { pid: number } }).proc; + expect(proc).toBeTruthy(); + process.kill(proc.pid, "SIGKILL"); + await waitFor("death observed", () => !backend.isAlive()); + + // resume() must NOT silently no-op on the dead daemon: it + // respawns, finds the fresh daemon idle (no file loaded), and + // throws — the hook falls back to the full play path. + await expect(backend.resume()).rejects.toThrow(PlayerRestartedError); + + // play() (the hook's recovery) reuses the respawned daemon and + // plays the file — audio must actually advance again. + await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 }); + expect(backend.isAlive()).toBe(true); + await waitFor( + "recovered playback advances", + async () => + (await backend.getPosition()) > 0.5 && backend.isPlaying(), + ); + } finally { + await cleanup(backend); + } + }, + { timeout: 20000 }, +); + test.skipIf(!hasMpv)( "EOF marks playback ended; resume() then replays from the top", async () => { diff --git a/tests/external-pause-reconcile.test.ts b/tests/external-pause-reconcile.test.ts index 879c3ec..7f43e72 100644 --- a/tests/external-pause-reconcile.test.ts +++ b/tests/external-pause-reconcile.test.ts @@ -16,6 +16,11 @@ * property the same way the OS does and asserts useAudio reconciles in * both directions. Skipped when mpv isn't installed. * + * Also covers daemon crash recovery: killing mpv out from under the app + * must drop the UI out of "playing" (finalizeTrackEnd), and the next Play + * press must respawn a fresh daemon and resume audio from the saved + * position — the play button may never silently no-op on a dead player. + * * Real-timer note: the reconcile path runs on useAudio's real 150ms poll * interval against a real mpv process, with no injectable clock — the * deliberate-exception case from the no-real-timers rule (same as @@ -190,6 +195,27 @@ async function waitFor( } } +/** SIGKILL the backend's mpv daemon — a crash/kill out from under the app. + * The mpv command line carries the IPC socket path, so pgrep finds it by + * that (the socket name is unique to this test process). */ +async function killMpvDaemon(): Promise { + const socket = mpvSocket(); + if (!socket) throw new Error("backend mpv socket not found"); + const pids = (await Bun.$`pgrep -f ${socket}`.quiet().text()) + .split("\n") + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map(Number); + expect(pids.length).toBeGreaterThan(0); + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + /* already gone */ + } + } +} + const episode = { id: "ep1", podcastId: "pod1", @@ -235,6 +261,42 @@ test.skipIf(!hasMpv)( { timeout: 30000 }, ); +test.skipIf(!hasMpv)( + "mpv killed mid-play: UI drops out of playing; pressing play recovers a fresh daemon", + async () => { + const audio = useAudio(); + await audio.play(episode); + // Instant assertion: play() sets isPlaying synchronously when it + // succeeded. (In a shared worker that leaked a store mock from + // another test file, play() fails and this catches it at 0ms + // instead of burning the waitFor timeout below.) + expect(audio.isPlaying()).toBe(true); + // Let the clock advance past the 5s progress-save floor so recovery + // has a saved position to resume from (positions <5s are not stored). + await waitFor(() => audio.position() > 6); + const crashPos = audio.position(); + + // Crash the player out from under the app. + await killMpvDaemon(); + await waitFor(() => !audio.isPlaying()); + expect(audio.isPlaying()).toBe(false); + // The episode stays current — recovery can restart it. + expect(audio.currentEpisode()?.id).toBe("ep1"); + + // Press play: must respawn mpv and resume from the saved position — + // not silently flip the UI to "playing" with no process behind it. + await audio.togglePlayback(); + expect(audio.isPlaying()).toBe(true); + expect(audio.position()).toBeGreaterThanOrEqual(crashPos - 0.5); + // Audio actually advances again — proof a fresh daemon is playing. + await waitFor(() => audio.position() > crashPos + 0.5); + + await audio.stop(); + expect(audio.isPlaying()).toBe(false); + }, + { timeout: 30000 }, +); + // ── Teardown ────────────────────────────────────────────────────────────── afterAll(async () => {