fix(r5): address the fifth /code-review high round — 6 findings
- run_ffmpeg stops the process from ONE exit path (`except BaseException`), so an exception out of on_progress (a DB write) can no longer orphan a live ffmpeg after a 10s join on its pipes; _stop is now a no-op on a dead process - both run_ffmpeg callbacks are throttled to ~1s (they were a DB round-trip per output line: ~10^5 queries on a long re-encode), mirroring the download hook - volume arithmetic extracted to lib/playerVolume with unit tests; a corrupt level now falls back to the LAST GOOD one instead of full blast, and the sanitising happens where the stored value enters - RSS is back to two except clauses (the shared tail already sits after the try) - new tests: callback-exception cleanup, the two throttles; the fake Popen grew poll() and its wait count is now asserted Mutation-checked: each new group goes red when its fix is reverted.
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
@@ -188,12 +189,12 @@ export default function PlayerModal({
|
||||
// (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a
|
||||
// ref anyway, and the hook's write lives inside a `setState` updater — which is both a second
|
||||
// write on every settle and not something React must run while the component is unmounting.
|
||||
// `readAccountMerged` (not readAccount) so a partial/older stored object still gets the default
|
||||
// — a missing `volume` would otherwise travel as undefined into the arithmetic below. Read via
|
||||
// a lazy `useState` initializer so it happens once, not on every render (the house pattern —
|
||||
// see the frozen queue above).
|
||||
const [storedVolume] = useState(
|
||||
() => readAccountMerged(LS.ytPlayerPrefs, { volume: 100 }).volume,
|
||||
// Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see
|
||||
// lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of
|
||||
// travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not
|
||||
// on every render (the house pattern — see the frozen queue above).
|
||||
const [storedVolume] = useState(() =>
|
||||
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
||||
);
|
||||
const volumeRef = useRef(storedVolume);
|
||||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||||
@@ -227,10 +228,10 @@ export default function PlayerModal({
|
||||
};
|
||||
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
|
||||
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
|
||||
const applyVolume = (level: number, flash = true) => {
|
||||
// A non-finite level (a corrupt stored value, a player that answered NaN) must not become a
|
||||
// clamped NaN: that would mute the player AND persist `null`, poisoning every later session.
|
||||
const next = Number.isFinite(level) ? Math.max(0, Math.min(100, Math.round(level))) : 100;
|
||||
const applyVolume = (level: unknown, flash = true) => {
|
||||
// An unusable level (a player that answered NaN) falls back to the LAST GOOD one, never to the
|
||||
// default: jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
||||
const next = normalizeVolume(level, volumeRef.current);
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.setVolume === "function") {
|
||||
if (next > 0) p.unMute?.();
|
||||
@@ -255,12 +256,8 @@ export default function PlayerModal({
|
||||
const now = Date.now();
|
||||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||||
lastNudgeAtRef.current = now;
|
||||
const cur = continuing
|
||||
? volumeRef.current
|
||||
: p.isMuted?.()
|
||||
? 0
|
||||
: (p.getVolume?.() ?? volumeRef.current);
|
||||
applyVolume(cur + delta);
|
||||
const cur = continuing ? volumeRef.current : p.isMuted?.() ? 0 : p.getVolume?.();
|
||||
applyVolume(stepVolume(cur, delta, volumeRef.current), true);
|
||||
};
|
||||
|
||||
// The player can navigate to other videos (YouTube links in a description). The
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
|
||||
|
||||
describe("normalizeVolume", () => {
|
||||
it("keeps a valid level", () => {
|
||||
expect(normalizeVolume(0)).toBe(0);
|
||||
expect(normalizeVolume(37)).toBe(37);
|
||||
expect(normalizeVolume(100)).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps out-of-range levels and rounds to whole percents", () => {
|
||||
expect(normalizeVolume(-20)).toBe(0);
|
||||
expect(normalizeVolume(150)).toBe(100);
|
||||
expect(normalizeVolume(42.6)).toBe(43);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing key", undefined],
|
||||
["an explicit null", null],
|
||||
["a stringified number", "80"],
|
||||
["NaN from a broken player", NaN],
|
||||
["a whole object", { volume: 50 }],
|
||||
])("falls back on %s", (_label, raw) => {
|
||||
// The regression: a partial stored object made the level undefined, which Math.round turned
|
||||
// into NaN — muting playback and persisting `null` for every later session.
|
||||
expect(normalizeVolume(raw, 15)).toBe(15);
|
||||
});
|
||||
|
||||
it("prefers the last known level over the default, so a quiet session stays quiet", () => {
|
||||
expect(normalizeVolume(NaN, 15)).toBe(15);
|
||||
expect(normalizeVolume(NaN, 15)).not.toBe(DEFAULT_VOLUME);
|
||||
});
|
||||
|
||||
it("uses the default when the fallback is unusable too", () => {
|
||||
expect(normalizeVolume(NaN, NaN)).toBe(DEFAULT_VOLUME);
|
||||
expect(normalizeVolume(null, undefined as unknown as number)).toBe(DEFAULT_VOLUME);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepVolume", () => {
|
||||
it("steps up and down from the current level", () => {
|
||||
expect(stepVolume(60, 5, 100)).toBe(65);
|
||||
expect(stepVolume(60, -5, 100)).toBe(55);
|
||||
});
|
||||
|
||||
it("clamps at both ends instead of wrapping", () => {
|
||||
expect(stepVolume(98, 5, 100)).toBe(100);
|
||||
expect(stepVolume(3, -5, 100)).toBe(0);
|
||||
});
|
||||
|
||||
it("steps from the fallback when the player answers garbage", () => {
|
||||
// A spin must not lose notches to a stale/unusable reading — it continues from what we last set.
|
||||
expect(stepVolume(undefined, -5, 90)).toBe(85);
|
||||
expect(stepVolume(NaN, -5, 90)).toBe(85);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// The YouTube player's volume arithmetic, kept out of PlayerModal so it can be unit-tested. Every
|
||||
// rule here was established by a bug: a wheel spin that lost notches to the iframe's lagging
|
||||
// getVolume, a partial stored object that became NaN and persisted as `null`, and a "corrupt →
|
||||
// default" fallback that jumped an already-quiet session to full blast.
|
||||
|
||||
/** Level used when nothing trustworthy is available. */
|
||||
export const DEFAULT_VOLUME = 100;
|
||||
|
||||
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
|
||||
|
||||
/**
|
||||
* Coerce anything that claims to be a volume into a usable 0–100 level.
|
||||
*
|
||||
* Only a real, finite `number` is accepted: the stored blob is JSON, so a level is a number or it
|
||||
* is corrupt — `null`, `undefined`, `"80"` and `NaN` all mean "we don't know", and `Number(null)`
|
||||
* being `0` (silently muting the player) is exactly the coercion to avoid. `fallback` is the last
|
||||
* level known to work; it is itself validated, so a corrupt value can never travel through it.
|
||||
*/
|
||||
export function normalizeVolume(raw: unknown, fallback: number = DEFAULT_VOLUME): number {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return clamp(raw);
|
||||
if (typeof fallback === "number" && Number.isFinite(fallback)) return clamp(fallback);
|
||||
return DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
/** One volume step (wheel notch / arrow key) from `current`, falling back to `fallback` when the
|
||||
* player answered something unusable. */
|
||||
export function stepVolume(current: unknown, delta: number, fallback: number): number {
|
||||
const base = normalizeVolume(current, fallback);
|
||||
return clamp(base + delta);
|
||||
}
|
||||
Reference in New Issue
Block a user