fix(audio): recover playback when the mpv daemon dies or restarts
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
|
PlayerRestartedError,
|
||||||
type AudioBackend,
|
type AudioBackend,
|
||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
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> {
|
async function resume(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await backend.resume();
|
await backend.resume();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -515,6 +533,13 @@ async function resume(): Promise<void> {
|
|||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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");
|
setError(err instanceof Error ? err.message : "Resume failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
* fills its demuxer cache ahead of time; the first real play just flips
|
* 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
|
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||||
* user's first Play.
|
* 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";
|
import { platform } from "os";
|
||||||
@@ -274,12 +278,28 @@ class MpvConnection {
|
|||||||
}
|
}
|
||||||
this.handleTeardown();
|
this.handleTeardown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the Unix socket is open — a live, reachable daemon. */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.sock !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// One resident daemon for the app's lifetime, controlled over a single
|
// One resident daemon for the app's lifetime, controlled over a single
|
||||||
// persistent JSON IPC connection with property observation.
|
// 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). */
|
/** Property observation ids (correlate property-change events). */
|
||||||
const OBS_TIME_POS = 1;
|
const OBS_TIME_POS = 1;
|
||||||
const OBS_PAUSE = 2;
|
const OBS_PAUSE = 2;
|
||||||
@@ -318,14 +338,35 @@ export class MpvBackend implements AudioBackend {
|
|||||||
// ── Daemon lifecycle ─────────────────────────────────────────────
|
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||||
|
|
||||||
private async ensureDaemon(): Promise<void> {
|
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;
|
if (this.startPromise) return this.startPromise;
|
||||||
this.startPromise = this.spawnDaemon().finally(() => {
|
this.startPromise = this.recoverDaemon().finally(() => {
|
||||||
this.startPromise = null;
|
this.startPromise = null;
|
||||||
});
|
});
|
||||||
return this.startPromise;
|
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> {
|
private async spawnDaemon(): Promise<void> {
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
try {
|
try {
|
||||||
@@ -360,9 +401,15 @@ export class MpvBackend implements AudioBackend {
|
|||||||
this._exited = false;
|
this._exited = false;
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.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._exited = true;
|
||||||
this._intentPlaying = false;
|
this._intentPlaying = false;
|
||||||
this._loadedUrl = null;
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
this._paused = null;
|
this._paused = null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -590,6 +637,13 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
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) {
|
if (this._ended && this._loadedUrl) {
|
||||||
// Play pressed on a finished episode: replay from the top.
|
// Play pressed on a finished episode: replay from the top.
|
||||||
this._ended = false;
|
this._ended = false;
|
||||||
@@ -609,7 +663,12 @@ export class MpvBackend implements AudioBackend {
|
|||||||
this._loadedPaused = false;
|
this._loadedPaused = false;
|
||||||
}
|
}
|
||||||
this._ended = 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;
|
this._intentPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
* it by unpausing — the boot-restore fast path with no second load.
|
* it by unpausing — the boot-restore fast path with no second load.
|
||||||
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||||
* resume() afterwards replays from the top.
|
* 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;
|
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||||
* tests skip where it is missing.
|
* tests skip where it is missing.
|
||||||
@@ -19,7 +23,10 @@
|
|||||||
import { test, expect } from "bun:test";
|
import { test, expect } from "bun:test";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { MpvBackend } from "../src/utils/audio-player";
|
import {
|
||||||
|
MpvBackend,
|
||||||
|
PlayerRestartedError,
|
||||||
|
} from "../src/utils/audio-player";
|
||||||
|
|
||||||
const SAMPLE_RATE = 22050;
|
const SAMPLE_RATE = 22050;
|
||||||
const FREQ = 440;
|
const FREQ = 440;
|
||||||
@@ -165,6 +172,46 @@ test.skipIf(!hasMpv)(
|
|||||||
{ timeout: 20000 },
|
{ 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)(
|
test.skipIf(!hasMpv)(
|
||||||
"EOF marks playback ended; resume() then replays from the top",
|
"EOF marks playback ended; resume() then replays from the top",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
* property the same way the OS does and asserts useAudio reconciles in
|
* property the same way the OS does and asserts useAudio reconciles in
|
||||||
* both directions. Skipped when mpv isn't installed.
|
* 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
|
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
||||||
* interval against a real mpv process, with no injectable clock — the
|
* interval against a real mpv process, with no injectable clock — the
|
||||||
* deliberate-exception case from the no-real-timers rule (same as
|
* 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 = {
|
const episode = {
|
||||||
id: "ep1",
|
id: "ep1",
|
||||||
podcastId: "pod1",
|
podcastId: "pod1",
|
||||||
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
|
|||||||
{ timeout: 30000 },
|
{ 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 ──────────────────────────────────────────────────────────────
|
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user