mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
The guard in startup_events() read:
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
and not _DEMO_JANITOR_STARTED:
`and` binds tighter than `or`, so that is `A or (B and C)`. The not-already-started half
never ran when the env var was truthy — the only case that reaches it at all. A second
startup started a SECOND janitor thread, overwrote the handle, and shutdown then joined
only the last: the first leaked and kept firing registered hooks hourly, forever.
The guard now lives INSIDE start_janitor(). A caller cannot get operator precedence wrong
if there is nothing left for it to get wrong.
━━━ THREE WAYS TO WRITE THIS GUARD WRONG. I HIT ALL THREE. ━━━
1. NO GUARD — the original bug. Double-start, orphaned thread.
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). Codex [P2]. stop_janitor()
DELIBERATELY leaves that flag True when a hook outruns its join timeout, so that a later
startup cannot spawn a janitor beside a live one. But the hook usually finishes a moment
later: the thread exits and the flag is stale. A flag-keyed guard then refuses to start a
replacement for the rest of the process — demo cleanup silently dead. (The original bug
accidentally MASKED this by always starting.)
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). Codex [P2], second pass. A
timed-out stop leaves the old thread ALIVE BUT DOOMED — its stop event is set and it
exits as soon as its current hook returns. Treating that as a running janitor skips the
replacement, and we are back at (2) a second later.
So: a janitor counts as running only if its thread is alive AND it has not been told to stop.
━━━ AND EACH JANITOR NOW OWNS ITS STOP EVENT ━━━
start_janitor() used to `_DEMO_JANITOR_STOP.clear()` a single SHARED Event. Start a
replacement while a doomed thread is still finishing a hook and that clear RESURRECTS it: it
loops back to wait(), sees the flag cleared, and carries on. Two janitors — the exact bug we
started from. A fresh Event per janitor makes it impossible; the old thread waits on its own
event, which stays set, so it can only exit.
Env semantics UNCHANGED, verified across every value ("", "1", "0", "true", "false", "off"):
the old expression and demo_mode_enabled() agree on all of them. The only behavioural change
is the idempotency fix.
FOUR tests, and each of the three wrong guards fails a different subset:
no guard -> 2 fail (double start; orphaned thread)
guard on the flag -> 2 fail (never restarts after a timed-out stop)
liveness alone -> 1 fail (no replacement for a doomed janitor)
liveness + not-stopping -> all pass
pytest 2416, pyflakes 0, Codex 0.
Closes #902
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
381 lines
18 KiB
Python
381 lines
18 KiB
Python
"""Demo mode: the read-only request guard and the hourly session janitor.
|
|
|
|
Carved VERBATIM out of server.py (R3b). Bodies are byte-identical — including a bug, see
|
|
below.
|
|
|
|
━━━ THE MIDDLEWARE NEEDS `app`, SO THIS MODULE TAKES IT ━━━
|
|
|
|
`_demo_mode_guard` is an @app.middleware("http"), and a middleware has to be attached to an
|
|
app object. Rather than reach for a global, this module exposes install(app): server.py
|
|
owns the app and hands it over. Same direction as every other seam here — server.py knows
|
|
things lib/ must not have to guess.
|
|
|
|
The janitor is symmetrical: start_janitor() / stop_janitor(), called from server.py's
|
|
startup and shutdown hooks, which is where the process lifecycle actually lives.
|
|
|
|
━━━ register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT ━━━
|
|
|
|
It is a key in plugin_context, so plugins hold it as a LIVE REFERENCE from setup(). Moving
|
|
the function is fine; wrapping or renaming it is not. server.py imports this exact object
|
|
and puts it in the dict unchanged, so callable identity is preserved —
|
|
tests/test_plugin_context_contract.py (#898) fails if that ever stops being true.
|
|
|
|
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
|
|
|
|
The janitor start guard in server.py reads:
|
|
|
|
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
|
|
and not _DEMO_JANITOR_STARTED:
|
|
|
|
`and` binds tighter than `or`, so that is `A or (B and C)` — the `not _DEMO_JANITOR_STARTED`
|
|
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs. A
|
|
second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
|
|
the last). Preserved exactly as-is here and filed as issue #902: a carve whose value is
|
|
being provably behaviour-neutral is not the place to change behaviour.
|
|
"""
|
|
import inspect
|
|
import logging
|
|
import re
|
|
import threading
|
|
import uuid
|
|
import warnings
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import JSONResponse
|
|
from env_compat import getenv_compat
|
|
|
|
log = logging.getLogger("feedBack.demo_mode")
|
|
|
|
|
|
# Plugins that maintain session stores can register a cleanup callback here.
|
|
# The demo-mode janitor calls every registered hook once per hour so stale
|
|
# sessions are swept without the core needing to know plugin internals.
|
|
_DEMO_JANITOR_HOOKS: list = []
|
|
|
|
|
|
_DEMO_JANITOR_HOOKS_LOCK = threading.Lock()
|
|
|
|
|
|
_DEMO_JANITOR_STARTED = False
|
|
|
|
|
|
_DEMO_JANITOR_STOP = threading.Event()
|
|
|
|
|
|
_DEMO_JANITOR_THREAD: threading.Thread | None = None
|
|
|
|
|
|
def register_demo_janitor_hook(fn) -> None:
|
|
"""Register a zero-argument callable to be invoked hourly by the demo
|
|
janitor. Plugins call this from their ``setup(app, context)`` when they
|
|
want to participate in session cleanup under demo mode.
|
|
|
|
The callable must accept no required arguments. Async (coroutine)
|
|
functions are rejected: the janitor runs in a plain thread and cannot
|
|
await coroutines.
|
|
"""
|
|
if not callable(fn):
|
|
raise TypeError(
|
|
f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}"
|
|
)
|
|
# Reject coroutine functions — check both the callable itself and its
|
|
# __call__ method so objects with an async __call__ (e.g. class instances,
|
|
# functools.partial wrappers around async functions) are also caught.
|
|
_call = getattr(fn, "__call__", None)
|
|
if inspect.iscoroutinefunction(fn) or (
|
|
_call is not None and inspect.iscoroutinefunction(_call)
|
|
):
|
|
raise TypeError(
|
|
"register_demo_janitor_hook does not accept async functions; "
|
|
"the janitor runs in a plain thread and cannot await coroutines"
|
|
)
|
|
# Validate that the callable accepts zero required arguments so it won't
|
|
# crash at sweep time (hourly, far from the registration site).
|
|
try:
|
|
sig = inspect.signature(fn)
|
|
except ValueError:
|
|
# inspect.signature() raises ValueError for built-in C callables whose
|
|
# signature cannot be determined. Accept them as-is; if they fail at
|
|
# runtime the janitor will catch and log the exception.
|
|
pass
|
|
else:
|
|
required = [
|
|
p for p in sig.parameters.values()
|
|
if p.default is inspect.Parameter.empty
|
|
and p.kind not in (
|
|
inspect.Parameter.VAR_POSITIONAL,
|
|
inspect.Parameter.VAR_KEYWORD,
|
|
)
|
|
]
|
|
if required:
|
|
raise TypeError(
|
|
f"register_demo_janitor_hook expects a zero-argument callable; "
|
|
f"{fn!r} has {len(required)} required parameter(s): "
|
|
+ ", ".join(p.name for p in required)
|
|
)
|
|
with _DEMO_JANITOR_HOOKS_LOCK:
|
|
_DEMO_JANITOR_HOOKS.append(fn)
|
|
|
|
|
|
def _run_janitor_hook(hook) -> None:
|
|
"""Run a single janitor hook inline, swallowing and logging any exception.
|
|
|
|
If the hook returns an awaitable (e.g. a coroutine slipped through the
|
|
async-function guard), the coroutine is closed immediately to avoid
|
|
``RuntimeWarning: coroutine was never awaited`` noise, and a warning is
|
|
emitted so the plugin author knows to fix their hook.
|
|
"""
|
|
try:
|
|
result = hook()
|
|
except Exception:
|
|
log.exception("janitor hook %r raised", hook)
|
|
return
|
|
if inspect.iscoroutine(result):
|
|
# A coroutine slipped through the async-function guard (e.g. via a
|
|
# wrapper/partial). Close it to suppress "coroutine never awaited",
|
|
# then warn so the plugin author knows to fix their hook.
|
|
try:
|
|
result.close()
|
|
except Exception:
|
|
log.exception("error closing coroutine from janitor hook %r", hook)
|
|
warnings.warn(
|
|
f"janitor hook {hook!r} returned a coroutine; "
|
|
"hooks must be plain synchronous callables — "
|
|
"register_demo_janitor_hook does not accept async functions",
|
|
RuntimeWarning,
|
|
stacklevel=1,
|
|
)
|
|
elif inspect.isawaitable(result):
|
|
# Future/Task: no .close() method; just warn and leave it alone.
|
|
warnings.warn(
|
|
f"janitor hook {hook!r} returned an awaitable (Future/Task); "
|
|
"hooks must be plain synchronous callables",
|
|
RuntimeWarning,
|
|
stacklevel=1,
|
|
)
|
|
|
|
|
|
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
|
("POST", re.compile(r"^/api/settings$")),
|
|
("POST", re.compile(r"^/api/settings/import$")),
|
|
("POST", re.compile(r"^/api/settings/reset$")),
|
|
("POST", re.compile(r"^/api/rescan$")),
|
|
("POST", re.compile(r"^/api/rescan/full$")),
|
|
("POST", re.compile(r"^/api/songs/upload$")),
|
|
("DELETE", re.compile(r"^/api/song/.+$")),
|
|
("POST", re.compile(r"^/api/favorites/toggle$")),
|
|
("POST", re.compile(r"^/api/loops$")),
|
|
("DELETE", re.compile(r"^/api/loops/[^/]+$")),
|
|
("POST", re.compile(r"^/api/audio-effects/mappings$")),
|
|
("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")),
|
|
("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")),
|
|
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
|
("POST", re.compile(r"^/api/song/.*/meta$")),
|
|
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
|
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
|
("GET", re.compile(r"^/api/plugins/updates$")),
|
|
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/build$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/upload-art$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/upload-audio$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/import-gp$")),
|
|
("POST", re.compile(r"^/api/plugins/editor/import-midi$")),
|
|
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")),
|
|
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")),
|
|
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")),
|
|
("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")),
|
|
("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")),
|
|
("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")),
|
|
("POST", re.compile(r"^/api/diagnostics/export$")),
|
|
("GET", re.compile(r"^/api/diagnostics/preview$")),
|
|
("GET", re.compile(r"^/api/diagnostics/hardware$")),
|
|
# Bundled core plugin — video background upload/delete
|
|
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
|
|
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
|
|
# fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the
|
|
# new profile / XP / stats / playlists / saved mutators too.
|
|
("POST", re.compile(r"^/api/profile$")),
|
|
("POST", re.compile(r"^/api/profile/avatar$")),
|
|
("POST", re.compile(r"^/api/xp/award$")),
|
|
("POST", re.compile(r"^/api/stats$")),
|
|
("POST", re.compile(r"^/api/playlists$")),
|
|
("PATCH", re.compile(r"^/api/playlists/[^/]+$")),
|
|
("DELETE", re.compile(r"^/api/playlists/[^/]+$")),
|
|
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
|
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
|
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
|
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
|
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
|
("POST", re.compile(r"^/api/saved/toggle$")),
|
|
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
|
("POST", re.compile(r"^/api/progression/paths$")),
|
|
("POST", re.compile(r"^/api/progression/onboarding$")),
|
|
("POST", re.compile(r"^/api/progression/events$")),
|
|
("POST", re.compile(r"^/api/shop/buy$")),
|
|
("POST", re.compile(r"^/api/shop/equip$")),
|
|
# Enrichment (P8): review writes mutate the local match cache, and the
|
|
# search proxy / manual kick relay to MusicBrainz — none of it belongs to
|
|
# anonymous demo visitors (they'd spend the shared rate limit).
|
|
("POST", re.compile(r"^/api/enrichment/review/.+$")),
|
|
("POST", re.compile(r"^/api/enrichment/kick$")),
|
|
("POST", re.compile(r"^/api/enrichment/cancel$")),
|
|
("POST", re.compile(r"^/api/enrichment/rematch$")),
|
|
("GET", re.compile(r"^/api/enrichment/search$")),
|
|
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
|
|
# and spend the shared AcoustID rate budget on the caller's behalf — same
|
|
# rule as the search/kick relays above; not for anonymous demo visitors.
|
|
("POST", re.compile(r"^/api/enrichment/identify$")),
|
|
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
|
|
# Context menus (R2): the per-song re-match mutates the cache + spends
|
|
# rate limit; Get-info exposes filesystem paths.
|
|
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
|
|
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
|
|
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
|
|
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
|
|
# Art layer (R3): all three mutate server state / touch the network on a
|
|
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
|
|
# server request arbitrary images, and the override delete removes files.
|
|
("POST", re.compile(r"^/api/song/.+/art/upload$")),
|
|
("POST", re.compile(r"^/api/song/.+/art/url$")),
|
|
("DELETE", re.compile(r"^/api/art/.+/override$")),
|
|
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
|
|
# throttled Cover Art Archive calls — anonymous demo visitors don't get
|
|
# to spend the shared rate budget (same rule as enrichment search/kick).
|
|
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
|
|
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
|
|
# visitor's behalf AND writes the artist_enrichment cache; refresh
|
|
# re-spends the shared rate limit. The /page route stays open (all-local
|
|
# read). Same rationale as /api/enrichment/search above.
|
|
("GET", re.compile(r"^/api/artist/.+/links$")),
|
|
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
|
|
]
|
|
|
|
|
|
async def _demo_mode_guard(request: Request, call_next):
|
|
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
|
|
path = request.url.path
|
|
for method, pattern in _DEMO_BLOCKED:
|
|
if request.method == method and pattern.match(path):
|
|
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
|
|
response = await call_next(request)
|
|
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
|
|
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
|
|
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
|
|
response.set_cookie(
|
|
"feedBack_demo_session", str(uuid.uuid4()),
|
|
max_age=86400, httponly=True, samesite="lax",
|
|
secure=is_secure,
|
|
)
|
|
return response
|
|
return await call_next(request)
|
|
|
|
|
|
def install(app) -> None:
|
|
"""Attach the demo-mode request guard to `app`.
|
|
|
|
Called by server.py, which owns the app. A middleware cannot exist without one, and a
|
|
module under lib/ should not be reaching for a global to find it.
|
|
"""
|
|
app.middleware("http")(_demo_mode_guard)
|
|
|
|
|
|
def demo_mode_enabled() -> bool:
|
|
"""True when demo mode is on. Read at CALL time, never captured — tests set and unset
|
|
FEEDBACK_DEMO_MODE with monkeypatch, so a value cached at import pins the wrong one."""
|
|
return bool(getenv_compat("FEEDBACK_DEMO_MODE"))
|
|
|
|
|
|
def start_janitor() -> None:
|
|
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
|
|
|
|
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
|
|
|
|
Three ways to get this wrong, and #902 plus two Codex passes found all three:
|
|
|
|
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
|
|
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
|
|
overwrote the handle, and left the first to fire hooks forever, unjoinable.
|
|
|
|
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
|
|
leaves that flag True when a hook outruns its join timeout — so once that hook
|
|
finishes and the thread exits, the flag is stale and a later startup would refuse to
|
|
start a replacement. Demo cleanup silently dead for the rest of the process.
|
|
|
|
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
|
|
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
|
|
current hook returns. Treating it as a running janitor means the replacement is never
|
|
started, and we are back at (2) a second later.
|
|
|
|
So a janitor counts as running only if its thread is alive AND it has not been told to
|
|
stop.
|
|
|
|
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
|
|
|
|
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
|
|
started while a doomed thread was still finishing a hook, clearing the shared event would
|
|
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
|
|
Two janitors, which is the exact bug we started from.
|
|
|
|
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
|
|
which stays set forever, so it can only exit.
|
|
"""
|
|
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
|
|
|
|
thread = _DEMO_JANITOR_THREAD
|
|
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
|
|
return # a healthy janitor is already running
|
|
|
|
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
|
|
# OWN stop event so the old one stays stopped no matter what we do to ours.
|
|
stop = threading.Event()
|
|
_DEMO_JANITOR_STOP = stop
|
|
_DEMO_JANITOR_STARTED = True
|
|
|
|
def _janitor():
|
|
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
|
|
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
|
|
while not stop.wait(timeout=3600):
|
|
with _DEMO_JANITOR_HOOKS_LOCK:
|
|
hooks = list(_DEMO_JANITOR_HOOKS)
|
|
for hook in hooks:
|
|
_run_janitor_hook(hook)
|
|
|
|
_DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor")
|
|
_DEMO_JANITOR_THREAD.start()
|
|
|
|
|
|
def janitor_started() -> bool:
|
|
return _DEMO_JANITOR_STARTED
|
|
|
|
|
|
def stop_janitor(timeout: float = 5) -> bool:
|
|
"""Signal the janitor to stop, join it, and drop the registered hooks.
|
|
|
|
Returns True if it stopped, False if it outlived the join (the caller warns).
|
|
|
|
THE ORDER HERE IS LOAD-BEARING and preserved exactly from server.py. When the thread
|
|
does NOT die within the timeout we return WITHOUT clearing _DEMO_JANITOR_STARTED and
|
|
WITHOUT dropping the thread handle — deliberately — so a subsequent startup does not
|
|
spawn a SECOND janitor alongside the one still running. Clearing the flag first (the
|
|
obvious way to write this) would quietly reintroduce exactly the double-janitor leak
|
|
the flag exists to prevent.
|
|
"""
|
|
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
|
|
if not _DEMO_JANITOR_STARTED:
|
|
return True
|
|
_DEMO_JANITOR_STOP.set()
|
|
thread = _DEMO_JANITOR_THREAD
|
|
if thread is not None:
|
|
thread.join(timeout=timeout)
|
|
if thread.is_alive():
|
|
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not spawned by a
|
|
# subsequent startup while the old one is alive.
|
|
return False
|
|
_DEMO_JANITOR_THREAD = None
|
|
_DEMO_JANITOR_STARTED = False
|
|
with _DEMO_JANITOR_HOOKS_LOCK:
|
|
_DEMO_JANITOR_HOOKS.clear()
|
|
return True
|