mpv has never decoded a video frame in this app: the backend sets `video: no` unconditionally, because Linux video has always been the webview's job and decoding it twice would burn a core for a picture nobody sees. The render path built in the previous commit therefore had nothing to draw. With native video on, mpv is configured for video *and* `vo=libmpv` — the render API only works through that output, and the default would try to open a window of its own. Set at construction, because mpv resolves its video output when it initialises and flipping the property later does not re-open one. The flag lives in `player::native_video`, read by all three things that must agree: the backend (configured before anything plays), the surface (nothing to draw otherwise), and `get_player_status` (which tells the frontend whether to use a `<video>` element — two decoders on one stream would fight over the audio). A function rather than three `env::var` checks, because a capability answered in several places is a capability whose answers drift: four separate bugs this cycle came from exactly that shape. Also fixes an ordering bug the first run exposed. The surface was attached in `setup` before the player backend was constructed, and the mpv handle is registered *during* that construction — so it found nothing every time and logged "no mpv handle". Attaching after the backend exists is the whole fix. Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the libepoxy data-symbol handling is right, since a wrong `get_proc_address` would have taken SIGSEGV on the first GL call rather than returning cleanly. No frame has reached the screen yet. The webview is still opaque, so it will paint over anything drawn beneath it until transparency is set up. Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls quick-xml 0.41. Licences, bans and sources still pass. UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything unrecognised all mean off. A half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle. Also removes a wall-clock timer from the waitForRepository late-arrival test, which failed once under load. The assertion is about ordering, so it now publishes on a microtask and cannot race.
118 lines
3.9 KiB
TypeScript
118 lines
3.9 KiB
TypeScript
/**
|
|
* Waiting for the repository rather than racing it.
|
|
*
|
|
* The defect: the player page asks for the repository *on mount*, but the
|
|
* session is restored asynchronously at startup. Losing that race produced
|
|
* "Not connected to a server" as a fatal playback error for a stream that was
|
|
* perfectly fine.
|
|
*
|
|
* These test the waiting contract itself rather than the auth store's internals,
|
|
* because the contract is the part the player depends on: resolve as soon as it
|
|
* exists, still reject when it genuinely is not there, and never settle twice.
|
|
*
|
|
* TRACES: UR-002, UR-004 | DR-013 | UT-215
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
type Listener = () => void;
|
|
|
|
/**
|
|
* The shape `waitForRepository` is built on: a store you can subscribe to, and
|
|
* a value that appears at some later point. Mirrors the real implementation
|
|
* without dragging in Tauri.
|
|
*/
|
|
function makeWaiter() {
|
|
let repository: object | null = null;
|
|
const listeners = new Set<Listener>();
|
|
|
|
const subscribe = (fn: Listener) => {
|
|
listeners.add(fn);
|
|
fn(); // stores fire synchronously on subscribe
|
|
return () => listeners.delete(fn);
|
|
};
|
|
const publish = (value: object | null) => {
|
|
repository = value;
|
|
listeners.forEach((fn) => fn());
|
|
};
|
|
|
|
async function waitForRepository(timeoutMs = 5000): Promise<object> {
|
|
if (repository) return repository;
|
|
return new Promise<object>((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
unsubscribe();
|
|
fn();
|
|
};
|
|
const unsubscribe = subscribe(() => {
|
|
if (repository) finish(() => resolve(repository as object));
|
|
});
|
|
const timer = setTimeout(
|
|
() => finish(() => reject(new Error("Not connected to a server"))),
|
|
timeoutMs,
|
|
);
|
|
});
|
|
}
|
|
|
|
return { waitForRepository, publish, listenerCount: () => listeners.size };
|
|
}
|
|
|
|
describe("waitForRepository", () => {
|
|
it("resolves immediately when the session is already restored", async () => {
|
|
const w = makeWaiter();
|
|
const repo = {};
|
|
w.publish(repo);
|
|
await expect(w.waitForRepository(50)).resolves.toBe(repo);
|
|
});
|
|
|
|
it("resolves when the session arrives later — the race the player lost", async () => {
|
|
const w = makeWaiter();
|
|
const repo = {};
|
|
const pending = w.waitForRepository(1000);
|
|
// Nothing yet; the page has already mounted and asked. Published on a
|
|
// microtask rather than a timer: the point is *ordering* (asked before it
|
|
// arrived), and a wall-clock delay would make this a race under load.
|
|
await Promise.resolve();
|
|
w.publish(repo);
|
|
await expect(pending).resolves.toBe(repo);
|
|
});
|
|
|
|
it("still rejects when there genuinely is no session", async () => {
|
|
vi.useFakeTimers();
|
|
const w = makeWaiter();
|
|
const pending = w.waitForRepository(500);
|
|
const assertion = expect(pending).rejects.toThrow("Not connected to a server");
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
await assertion;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("unsubscribes once settled, so a later change cannot resolve it twice", async () => {
|
|
const w = makeWaiter();
|
|
const repo = {};
|
|
const pending = w.waitForRepository(1000);
|
|
expect(w.listenerCount()).toBe(1);
|
|
w.publish(repo);
|
|
await pending;
|
|
expect(w.listenerCount()).toBe(0);
|
|
// A further change must not throw or re-settle.
|
|
expect(() => w.publish(null)).not.toThrow();
|
|
});
|
|
|
|
it("does not leave a pending timer that fires after success", async () => {
|
|
vi.useFakeTimers();
|
|
const w = makeWaiter();
|
|
const repo = {};
|
|
const pending = w.waitForRepository(200);
|
|
w.publish(repo);
|
|
await expect(pending).resolves.toBe(repo);
|
|
// If the timeout were still armed it would reject an already-settled
|
|
// promise, which surfaces as an unhandled rejection rather than a failure.
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|