fix(audio): recover playback when the mpv daemon dies or restarts
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user