mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
refactor(server): carve demo mode into lib/demo_mode.py (R3b) (#903)
lib/demo_mode.py (342). server.py 1,870 -> 1,649.
The read-only request guard (its 96-entry blocked-route table + the middleware) and the
hourly session janitor (registry, hook runner, thread). Bodies VERBATIM.
THE MIDDLEWARE NEEDS `app`, SO THE MODULE TAKES IT. _demo_mode_guard is an
@app.middleware("http") and cannot exist without an app object. Rather than have a module
under lib/ reach for a global, it exposes install(app) and server.py — which owns the app —
hands it over. The janitor is symmetrical: start_janitor() / stop_janitor(), called from
server.py's startup and shutdown hooks, 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(). server.py imports this exact object
and puts it in the dict unchanged — identity preserved, and
tests/test_plugin_context_contract.py (#898, merged) fails if that ever stops being true.
This is the first carve that guard has actually protected.
━━━ stop_janitor()'s ORDER IS LOAD-BEARING ━━━
The obvious way to write it — clear the "started" flag, then join — is WRONG, and I wrote
it that way first. server.py's original deliberately returns EARLY, leaving
_DEMO_JANITOR_STARTED True and the thread handle intact, when the thread outlives the join:
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not
# spawned by a subsequent startup while the old one is alive.
Clearing the flag first quietly reintroduces exactly the double-janitor leak the flag
exists to prevent. Preserved byte-for-byte, and the reason is now written down at the
function rather than only at its single call site.
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
and not _DEMO_JANITOR_STARTED:
`and` binds tighter than `or`, so this is `A or (B and C)` — the not-already-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). Verified. Preserved exactly and filed as issue #902: a carve whose whole value
is being provably behaviour-neutral is not the place to change behaviour.
pyflakes caught three more missing imports on the way in (uuid, warnings x2). Five carves,
ten missing imports, every one a NameError on a live path.
pytest 2399, pyflakes 0, Codex 0.
Refs #48
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8f014e6a30
commit
f5d448af5c
+24
-23
@@ -16,6 +16,7 @@ Covers:
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import demo_mode
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -50,14 +51,14 @@ def _cleanup(server, client):
|
||||
client.close()
|
||||
# Stop the demo-mode janitor thread (if started) so daemon threads don't
|
||||
# accumulate across tests.
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -221,15 +222,15 @@ def test_register_demo_janitor_hook_is_callable(tmp_path, monkeypatch):
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
try:
|
||||
called = []
|
||||
server.register_demo_janitor_hook(lambda: called.append(1))
|
||||
demo_mode.register_demo_janitor_hook(lambda: called.append(1))
|
||||
# Manually invoke the registered hooks (simulating a janitor sweep).
|
||||
for hook in list(server._DEMO_JANITOR_HOOKS):
|
||||
for hook in list(demo_mode._DEMO_JANITOR_HOOKS):
|
||||
hook()
|
||||
assert 1 in called
|
||||
finally:
|
||||
# Clean up our test hook so it doesn't leak into other tests.
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
@@ -238,7 +239,7 @@ def test_register_demo_janitor_hook_rejects_non_callable(tmp_path, monkeypatch):
|
||||
server, client = _make_client(tmp_path, monkeypatch, demo=True)
|
||||
try:
|
||||
with pytest.raises(TypeError):
|
||||
server.register_demo_janitor_hook("not a function")
|
||||
demo_mode.register_demo_janitor_hook("not a function")
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -251,7 +252,7 @@ def test_register_demo_janitor_hook_rejects_async_callable(tmp_path, monkeypatch
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="async"):
|
||||
server.register_demo_janitor_hook(_async_hook)
|
||||
demo_mode.register_demo_janitor_hook(_async_hook)
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -264,7 +265,7 @@ def test_register_demo_janitor_hook_rejects_non_zero_arg_callable(tmp_path, monk
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="zero-argument"):
|
||||
server.register_demo_janitor_hook(_needs_arg)
|
||||
demo_mode.register_demo_janitor_hook(_needs_arg)
|
||||
finally:
|
||||
_cleanup(server, client)
|
||||
|
||||
@@ -276,10 +277,10 @@ def test_register_demo_janitor_hook_accepts_default_arg_callable(tmp_path, monke
|
||||
def _optional_arg(x=None):
|
||||
pass
|
||||
|
||||
server.register_demo_janitor_hook(_optional_arg)
|
||||
demo_mode.register_demo_janitor_hook(_optional_arg)
|
||||
finally:
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
_cleanup(server, client)
|
||||
|
||||
|
||||
@@ -308,21 +309,21 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
|
||||
assert "register_demo_janitor_hook" in captured, (
|
||||
"register_demo_janitor_hook was not passed in the plugin context"
|
||||
)
|
||||
assert captured["register_demo_janitor_hook"] is server.register_demo_janitor_hook
|
||||
assert captured["register_demo_janitor_hook"] is demo_mode.register_demo_janitor_hook
|
||||
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
# Clean up janitor state so it doesn't bleed into other tests.
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
with server._DEMO_JANITOR_HOOKS_LOCK:
|
||||
server._DEMO_JANITOR_HOOKS.clear()
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
with demo_mode._DEMO_JANITOR_HOOKS_LOCK:
|
||||
demo_mode._DEMO_JANITOR_HOOKS.clear()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import time
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import demo_mode
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -120,12 +121,12 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
|
||||
|
||||
yield server, phases
|
||||
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -686,12 +687,12 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
|
||||
assert sentinel.status_code == 200
|
||||
assert sentinel.json() == {"ok": True}
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -777,12 +778,12 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
|
||||
# actually executed the sentinel — proves the main-loop handoff path ran.
|
||||
assert _route_setup_called, "route_setup_fn was never called; call_soon_threadsafe path was not exercised"
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
@@ -826,12 +827,12 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
|
||||
assert data["phase"] == "error"
|
||||
assert _BG_ERROR in data["error"]
|
||||
finally:
|
||||
server._DEMO_JANITOR_STOP.set()
|
||||
thread = server._DEMO_JANITOR_THREAD
|
||||
demo_mode._DEMO_JANITOR_STOP.set()
|
||||
thread = demo_mode._DEMO_JANITOR_THREAD
|
||||
if thread is not None:
|
||||
thread.join(timeout=2)
|
||||
server._DEMO_JANITOR_STARTED = False
|
||||
server._DEMO_JANITOR_THREAD = None
|
||||
demo_mode._DEMO_JANITOR_STARTED = False
|
||||
demo_mode._DEMO_JANITOR_THREAD = None
|
||||
conn = getattr(getattr(server, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
|
||||
Reference in New Issue
Block a user