fix(audio): recover playback when the mpv daemon dies or restarts

This commit is contained in:
2026-08-13 17:46:30 -04:00
parent 878d1e01ab
commit badbc6a037
4 changed files with 197 additions and 4 deletions

View File

@@ -20,6 +20,7 @@ import {
import {
createAudioBackend,
detectPlayers,
PlayerRestartedError,
type AudioBackend,
type BackendName,
type DetectedPlayer,
@@ -502,8 +503,25 @@ async function pause(): Promise<void> {
}
}
/** 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<void> {
const ep = currentEpisode();
if (ep && ep.audioUrl) {
await play(ep);
} else {
setError("Player is not running");
}
}
async function resume(): Promise<void> {
if (!backend) return;
if (!backend.isAlive()) {
await recoverPlayback();
return;
}
try {
await backend.resume();
setIsPlaying(true);
@@ -515,6 +533,13 @@ async function resume(): Promise<void> {
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");
}
}

View File

@@ -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<void> {
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<void> {
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<void> {
// 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<void> {
// 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;
}

View File

@@ -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 () => {

View File

@@ -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<void> {
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 () => {