fix(r5): address the eighth /code-review high round — 7 findings

The worst one was, again, the previous round's own fix.

- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
  also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
  live-session set BEFORE listing the root and rmtree'd outside the lock. Since
  `start_session` reuses a directory path for the same (key, offset, tag), a stale
  name can become a live session at any moment. Two guards now: nothing whose mtime
  is newer than this process's start is ever deleted, and the live check + rmtree
  happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
  0.0 read as "never ran" — unreachable in production, but `now` exists to be
  injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
  failed" is also what ONE dead feed id looks like on a small instance, and a red
  card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
  too — it had the same unprefixed-only reads in three places, including an Escape
  branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
  still on the element and wins. Comment now states the real mechanism.

Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
  band and overlaps NO YouTube control — the collision this round suspected does not
  reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
  level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
  change listener fires exactly once per transition, exit returns to 889.

Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
This commit is contained in:
2026-07-24 04:44:20 +02:00
parent a5336d20e0
commit bdf7c03b59
10 changed files with 251 additions and 52 deletions
+7 -1
View File
@@ -1,7 +1,7 @@
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from fastapi import FastAPI, HTTPException
@@ -82,7 +82,13 @@ async def lifespan(app: FastAPI):
yield
finally:
log.info("Siftlode shutting down")
# Awaited, not just cancelled: a bare `.cancel()` lets the loop close with the task still
# pending ("Task was destroyed but it is pending!" — noise that reads like a bug during
# triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own
# bookkeeping honest.
sweep.cancel()
with suppress(asyncio.CancelledError):
await sweep
shutdown_scheduler()
+31 -6
View File
@@ -47,6 +47,10 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
# and someone else's data.
_SESSION_DIR_RE = re.compile(r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$")
# When this process started. `sweep_orphans` only deletes directories older than this: everything
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
_PROCESS_START = time.time()
_lock = threading.Lock()
_sessions: dict[str, "HlsSession"] = {}
@@ -268,7 +272,7 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
return path.exists()
def sweep_orphans() -> int:
def sweep_orphans(older_than: float | None = None) -> int:
"""Delete OUR segment directories that no live session owns.
Sessions live only in THIS process, so at startup every session directory under `_HLS_ROOT` is
@@ -278,18 +282,39 @@ def sweep_orphans() -> int:
Only entries whose name matches `_SESSION_DIR_RE` are touched: the root is admin-configurable,
so anything else under it belongs to someone else and is left alone. Blocking (rmtree) — call
it off the event loop."""
with _lock:
live = {s.dir for s in _sessions.values()}
it off the event loop.
TWO guards against deleting a LIVE session, because this now runs CONCURRENTLY with request
handling (the caller stopped awaiting it so startup isn't held up):
* `older_than` (default: this process's start) — a directory touched since we booted was made
by THIS run, so it is by definition not a leftover. This is the guard that matters, because
`start_session` REUSES the same path for the same (key, offset, tag): a stale directory can
become a live one at any moment.
* the live-session check and the rmtree happen together under `_lock`, so a session can't be
registered in the gap between them. The lock is held for one directory at a time — long
enough to be atomic, short enough that a boot-time sweep of a big backlog doesn't stall
playback."""
cutoff = _PROCESS_START if older_than is None else older_than
dropped = 0
try:
entries = list(_HLS_ROOT.iterdir())
except OSError: # the root doesn't exist yet — nothing to sweep
return 0
for p in entries:
if p.is_dir() and p not in live and _SESSION_DIR_RE.match(p.name):
if not _SESSION_DIR_RE.match(p.name):
continue
if not p.is_dir():
continue
try:
if p.stat().st_mtime >= cutoff:
continue # this run made (or reused) it — never ours to delete
except OSError: # vanished under us (a concurrent restart of that session)
continue
with _lock:
if any(s.dir == p for s in _sessions.values()):
continue
shutil.rmtree(p, ignore_errors=True)
dropped += 1
dropped += 1
return dropped
+18 -6
View File
@@ -60,13 +60,19 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
Raises RuntimeError when NOTHING could be polled (`failed == total`, total > 0): a run that
achieved literally nothing is a failed run, and only an exception turns the scheduler card red
— a returned count leaves it green with the outage buried in the summary line."""
— a returned count leaves it green with the outage buried in the summary line. The message
carries the FIRST underlying error, because on a small instance "all channels failed" is also
what a single dead feed id looks like."""
if channels is None:
channels = db.execute(select(Channel)).scalars().all()
total = len(channels)
new = 0
done = 0
failed = 0
# Kept for the total-outage message: "failed for all N channels" alone can't tell an egress
# outage from one permanently dead feed id — which is the whole difference on an instance with
# a single subscription, where BOTH render as 100% failed.
first_error: str | None = None
# Fetch feeds concurrently (network-bound, no DB), then apply inserts on this thread as
# each fetch returns — a SQLAlchemy session isn't thread-safe. Pass the channel id (a
# plain str) to the workers; the ORM instance is touched only here on the main thread.
@@ -86,17 +92,20 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
except RssError as exc:
log.warning("RSS poll skipped for channel %s: %s", channel.id, exc)
failed += 1
except Exception:
first_error = first_error or f"{channel.id}: {exc}"
except Exception as exc:
log.exception("RSS fetch failed for channel %s", channel.id)
failed += 1
first_error = first_error or f"{channel.id}: {exc}"
else:
try:
new += apply_rss_feed(db, channel, entries)
except Exception:
except Exception as exc:
# A write failure (read-only DB, failover) leaves the channel just as un-polled
# as an unreachable feed — count it, or a wholly broken run reports "failed=0".
db.rollback()
failed += 1
first_error = first_error or f"{channel.id} (apply): {exc}"
log.exception("RSS apply failed for channel %s", channel.id)
# One place that advances the counter, whatever happened above.
done += 1
@@ -106,9 +115,12 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
if total and failed == total:
# Nothing was polled at all — an egress outage (the tinyproxy-after-reboot incident), a
# dead DNS, a read-only database. Returning normally would record the run as `ok` with the
# failure hidden inside the result string, i.e. a green pill for a total outage; the count
# in the message keeps the summary just as informative.
raise RuntimeError(f"RSS poll failed for all {total} channels")
# failure hidden inside the result string, i.e. a green pill for a total outage.
# The first error TRAVELS WITH the message on purpose: on an instance with one or two
# subscriptions "all channels failed" is also what ONE dead feed id looks like, and a red
# card the user can't act on is its own kind of dishonest. `channel: HTTP 404` says
# "fix this subscription"; `Connection refused` says "the egress is down".
raise RuntimeError(f"RSS poll failed for all {total} channels — first: {first_error}")
return {"new": new, "failed": failed}
+8 -2
View File
@@ -330,11 +330,17 @@ _CANCEL_POLL_INTERVAL_S = 1.0
def _rate_limiter(interval_s: float, *, now=time.monotonic):
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
`now` is injectable so tests drive a clock instead of patching the global one."""
state = {"at": 0.0}
# `None`, not 0.0: "never ran" has to be a VALUE, not a falsy timestamp. A truthiness test here
# reads a legitimate `now() == 0.0` as "never ran" and lets a second call straight through —
# unreachable with real `time.monotonic()`, but `now` exists to be injected, and a test clock
# starting at 0 is the obvious choice. That would let a throttle test encode the wrong
# behaviour and still pass.
state: dict[str, float | None] = {"at": None}
def should_run() -> bool:
t = now()
if state["at"] and t - state["at"] < interval_s:
last = state["at"]
if last is not None and t - last < interval_s:
return False
state["at"] = t
return True