mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-14 23:29:24 +00:00
_drawWhirlpool re-armed requestAnimationFrame forever whenever its element had never been connected to the document. The grace period is there so a spinner can keep drawing between start() and the caller appending the element, but it had no deadline: while the element has never been connected _wpWasConnected stays false, so the guard stays true and the else branch is unreachable. Any caller that starts a spinner and then takes an early return, such as an aborted request or a panel that resolved from cache, leaves a loop redrawing an 84-segment spiral into a detached canvas at one frame per displayed frame until the tab closes. Put a 2 second deadline on the grace period. Callers append in the same task as start(), so that is far more slack than any of them need. A spinner that is actually in the document is unaffected. Two supporting changes in the same file: - Both self-terminate paths now call stop() instead of setting isRunning directly, so termination always runs one cancelAnimationFrame and never depends solely on inferring DOM connectivity. Both draw functions bail at the top when they are no longer running, and _requestFrame() clears rafId as the callback enters so it is a truthful "a frame is pending" flag. - start() arms a visibilitychange listener and stop() removes it. A hidden tab cancels the pending frame, a re-shown tab re-arms it. Chrome throttles background rAF but does not reliably stop the canvas work, and owning the listener from start/stop means a dead spinner never leaves one behind. Adds tests/test_spinner_stops_when_never_attached_js.py, which drives the real module under node with a fake clock and a manual frame pump. It covers all four exits and, importantly, the converse: a spinner that is attached keeps running well past the grace window.
319 lines
11 KiB
Python
319 lines
11 KiB
Python
"""Pin the self-termination contract of the canvas spinners in static/js/spinner.js.
|
|
|
|
Background: the whirlpool spinner drives itself with requestAnimationFrame and
|
|
decides whether to keep going by looking at `element.isConnected`. It used to
|
|
re-arm forever whenever the element had *never* been connected, on the theory
|
|
that start() runs before the caller appends the element. Callers that start a
|
|
spinner and then take an early return - an aborted request, a panel that
|
|
resolved from cache before the loading row was inserted - therefore left a rAF
|
|
loop redrawing an 84-segment spiral into a detached canvas until the tab closed.
|
|
Measured on an idle app: ~110 whirlpool frames per second with zero canvases in
|
|
the document.
|
|
|
|
These tests lock in all four exits (never attached, attached-then-removed,
|
|
stop(), tab hidden) and, just as importantly, the one case that must NOT stop:
|
|
a spinner that is actually on screen.
|
|
|
|
Driven through `node --input-type=module` so the real module runs, same idiom as
|
|
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
|
|
path because the repo has no `"type": "module"` in package.json; spinner.js has
|
|
no imports of its own, so inlining is exact. A fake clock and a manual frame
|
|
pump replace performance.now()/requestAnimationFrame, so nothing here depends on
|
|
wall-clock time or real frame timing.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_REPO = Path(__file__).resolve().parent.parent
|
|
_MODULE = _REPO / "static" / "js" / "spinner.js"
|
|
_HAS_NODE = shutil.which("node") is not None
|
|
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
|
|
|
|
# Browser stand-ins, installed before the module body runs. `clock` is advanced
|
|
# only by pump(), so every timing decision in the module is deterministic.
|
|
_STUBS = r"""
|
|
let clock = 0;
|
|
Object.defineProperty(globalThis, 'performance', {
|
|
value: { now: () => clock }, configurable: true, writable: true,
|
|
});
|
|
|
|
const pending = new Map();
|
|
let nextFrameId = 1;
|
|
let framesRun = 0;
|
|
globalThis.requestAnimationFrame = (cb) => {
|
|
const id = nextFrameId++;
|
|
pending.set(id, cb);
|
|
return id;
|
|
};
|
|
globalThis.cancelAnimationFrame = (id) => { pending.delete(id); };
|
|
|
|
/** Advance the clock `steps` frames of `msPerFrame` and run whatever is queued. */
|
|
function pump(steps, msPerFrame = 16) {
|
|
for (let i = 0; i < steps; i++) {
|
|
clock += msPerFrame;
|
|
const due = [...pending.values()];
|
|
pending.clear();
|
|
for (const cb of due) { framesRun++; cb(); }
|
|
}
|
|
}
|
|
function framesPending() { return pending.size; }
|
|
function framesSince(mark) { return framesRun - mark; }
|
|
function frameMark() { return framesRun; }
|
|
|
|
function makeCtx() {
|
|
const noop = () => {};
|
|
return {
|
|
clearRect: noop, beginPath: noop, arc: noop, moveTo: noop, lineTo: noop,
|
|
stroke: noop, fill: noop, save: noop, restore: noop,
|
|
strokeStyle: '', fillStyle: '', lineWidth: 0, globalAlpha: 1,
|
|
lineCap: '', lineJoin: '',
|
|
};
|
|
}
|
|
|
|
function makeElement(tag) {
|
|
const el = {
|
|
tagName: tag, className: '', textContent: '', innerHTML: '',
|
|
width: 0, height: 0, isConnected: false, parentNode: null,
|
|
style: { cssText: '' },
|
|
children: [],
|
|
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
|
getContext: () => makeCtx(),
|
|
appendChild(child) {
|
|
child.parentNode = this;
|
|
this.children.push(child);
|
|
return child;
|
|
},
|
|
removeChild(child) {
|
|
this.children = this.children.filter((c) => c !== child);
|
|
child.parentNode = null;
|
|
return child;
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
const docListeners = [];
|
|
globalThis.document = {
|
|
hidden: false,
|
|
documentElement: makeElement('html'),
|
|
createElement: makeElement,
|
|
createTextNode: (t) => ({ textContent: t }),
|
|
addEventListener: (type, fn) => { docListeners.push([type, fn]); },
|
|
removeEventListener: (type, fn) => {
|
|
const i = docListeners.findIndex(([t, f]) => t === type && f === fn);
|
|
if (i >= 0) docListeners.splice(i, 1);
|
|
},
|
|
};
|
|
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
|
|
|
function visibilityListeners() {
|
|
return docListeners.filter(([t]) => t === 'visibilitychange').length;
|
|
}
|
|
function fireVisibility(hidden) {
|
|
document.hidden = hidden;
|
|
for (const [t, fn] of [...docListeners]) if (t === 'visibilitychange') fn();
|
|
}
|
|
|
|
/** A started whirlpool spinner whose element is not in the document. */
|
|
function startedWhirlpool() {
|
|
const sp = new Spinner('', 'clean', 'whirlpool');
|
|
sp.createElement();
|
|
sp.start();
|
|
return sp;
|
|
}
|
|
"""
|
|
|
|
|
|
def _run(body: str) -> dict:
|
|
"""Run `body` with the real spinner module and the browser stubs in scope."""
|
|
js = _STUBS + "\n" + _SRC + "\n" + body
|
|
proc = subprocess.run(
|
|
["node", "--input-type=module"],
|
|
input=js, capture_output=True, text=True, encoding="utf-8",
|
|
cwd=str(_REPO), timeout=30,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr
|
|
return json.loads(proc.stdout.strip())
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_never_attached_whirlpool_stops_itself():
|
|
# The leak: element created, spinner started, element never inserted. Past
|
|
# the grace window it must give up rather than re-arm forever.
|
|
body = """
|
|
const sp = startedWhirlpool();
|
|
pump(30); // 480 ms - inside the grace window
|
|
const early = { running: sp.isRunning, pending: framesPending() };
|
|
pump(120); // ~2.4 s total - past the grace window
|
|
const mark = frameMark();
|
|
pump(60); // nothing should be left to run
|
|
console.log(JSON.stringify({
|
|
early,
|
|
running: sp.isRunning,
|
|
rafId: sp.rafId,
|
|
pending: framesPending(),
|
|
framesAfterStop: framesSince(mark),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["early"] == {"running": True, "pending": 1}, "gave up during the grace window"
|
|
assert out["running"] is False
|
|
assert out["rafId"] is None
|
|
assert out["pending"] == 0
|
|
assert out["framesAfterStop"] == 0, "loop kept drawing after it gave up"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_attached_whirlpool_keeps_running_past_the_grace_window():
|
|
# The converse guard: the fix must not kill spinners that are on screen.
|
|
body = """
|
|
const sp = new Spinner('', 'clean', 'whirlpool');
|
|
sp.createElement();
|
|
sp.element.isConnected = true;
|
|
sp.start();
|
|
pump(400); // ~6.4 s, far past the grace window
|
|
const mark = frameMark();
|
|
pump(10);
|
|
console.log(JSON.stringify({
|
|
running: sp.isRunning,
|
|
pending: framesPending(),
|
|
framesDrawn: framesSince(mark),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["running"] is True
|
|
assert out["pending"] == 1
|
|
assert out["framesDrawn"] == 10, "a visible spinner stopped animating"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_attached_then_removed_whirlpool_stops():
|
|
# The pre-existing exit - a loading row replaced by results - still works.
|
|
body = """
|
|
const sp = new Spinner('', 'clean', 'whirlpool');
|
|
sp.createElement();
|
|
sp.element.isConnected = true;
|
|
sp.start();
|
|
pump(200);
|
|
const whileAttached = sp.isRunning;
|
|
sp.element.isConnected = false; // results arrived, row swapped out
|
|
pump(3);
|
|
const mark = frameMark();
|
|
pump(20);
|
|
console.log(JSON.stringify({
|
|
whileAttached,
|
|
running: sp.isRunning,
|
|
pending: framesPending(),
|
|
framesAfterRemoval: framesSince(mark),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["whileAttached"] is True
|
|
assert out["running"] is False
|
|
assert out["pending"] == 0
|
|
assert out["framesAfterRemoval"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_loading_row_helper_stops_when_the_row_is_never_inserted():
|
|
# createLoadingRow() starts the spinner for the caller and hands back a
|
|
# detached row, so a caller that early-returns is the real leak shape.
|
|
body = """
|
|
const row = createLoadingRow('Loading...', 16);
|
|
pump(200);
|
|
const mark = frameMark();
|
|
pump(40);
|
|
console.log(JSON.stringify({
|
|
pending: framesPending(),
|
|
framesAfterStop: framesSince(mark),
|
|
rowHasChildren: row.children.length > 0,
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["rowHasChildren"] is True, "harness built the wrong row"
|
|
assert out["pending"] == 0
|
|
assert out["framesAfterStop"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_stop_cancels_the_pending_frame_and_releases_the_listener():
|
|
# stop() must be authoritative: no queued frame survives it, and it leaves
|
|
# no visibilitychange listener behind on a dead spinner.
|
|
body = """
|
|
const before = visibilityListeners();
|
|
const sp = new Spinner('', 'clean', 'whirlpool');
|
|
sp.createElement();
|
|
sp.element.isConnected = true;
|
|
sp.start();
|
|
const armed = visibilityListeners();
|
|
sp.stop();
|
|
const mark = frameMark();
|
|
pump(20);
|
|
console.log(JSON.stringify({
|
|
before, armed, after: visibilityListeners(),
|
|
running: sp.isRunning,
|
|
rafId: sp.rafId,
|
|
pending: framesPending(),
|
|
framesAfterStop: framesSince(mark),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert (out["before"], out["armed"], out["after"]) == (0, 1, 0)
|
|
assert out["running"] is False
|
|
assert out["rafId"] is None
|
|
assert out["pending"] == 0
|
|
assert out["framesAfterStop"] == 0
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_hidden_tab_pauses_frames_and_showing_resumes_them():
|
|
body = """
|
|
const sp = new Spinner('', 'clean', 'whirlpool');
|
|
sp.createElement();
|
|
sp.element.isConnected = true;
|
|
sp.start();
|
|
pump(5);
|
|
fireVisibility(true);
|
|
const hiddenMark = frameMark();
|
|
pump(30);
|
|
const whileHidden = { drawn: framesSince(hiddenMark), pending: framesPending() };
|
|
fireVisibility(false);
|
|
const shownMark = frameMark();
|
|
pump(10);
|
|
console.log(JSON.stringify({
|
|
whileHidden,
|
|
running: sp.isRunning,
|
|
drawnAfterShow: framesSince(shownMark),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["whileHidden"] == {"drawn": 0, "pending": 0}, "kept drawing in a hidden tab"
|
|
assert out["running"] is True
|
|
assert out["drawnAfterShow"] == 10, "did not resume when the tab came back"
|
|
|
|
|
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
def test_restarted_spinner_gets_a_fresh_grace_window():
|
|
# The grace deadline is per-run. A spinner reused after stop() must not
|
|
# inherit the previous run's timestamp and die on its first frame.
|
|
body = """
|
|
const sp = startedWhirlpool();
|
|
pump(200); // times out, never attached
|
|
const stopped = sp.isRunning;
|
|
sp.element.isConnected = true; // now inserted for real
|
|
sp.start();
|
|
pump(30);
|
|
console.log(JSON.stringify({
|
|
stopped,
|
|
running: sp.isRunning,
|
|
pending: framesPending(),
|
|
}));
|
|
"""
|
|
out = _run(body)
|
|
assert out["stopped"] is False
|
|
assert out["running"] is True
|
|
assert out["pending"] == 1
|