Summary: _auto_discover() re-imports an app module that's already in sys.modules (because it was pulled in transitively before discovery recorded its mtime), producing a second module object. Any module-level singleton (a ContextVar, a connection pool, a cached client) then exists twice — code that imported it early holds object A, code discovered later holds object B. Severity: high (silent; corrupts shared state).
Version: tina4-python 3.13.37 (also confirmed byte-identical in 3.13.38).
Where
tina4_python/core/server.py::_auto_discover() (~lines 59–145). The reload branch (~125–144):
if module_name not in sys.modules:
importlib.import_module(module_name)
_discovered_mtimes[module_name] = current_mtime
elif current_mtime > _discovered_mtimes.get(module_name, 0.0): # default 0.0
if module_name == root_pkg or module_name.startswith(root_pkg + "."):
del sys.modules[module_name]
importlib.import_module(module_name) # <-- duplicates the module object
_discovered_mtimes[module_name] = current_mtime
A module that is in sys.modules but absent from _discovered_mtimes (i.e. it was imported transitively by an earlier-discovered file, not by discovery itself) satisfies current_mtime > 0.0, so it gets del'd + re-imported. The existing scope guard only stops eviction of tina4_python.*/third-party — it still re-imports app (src.*) modules.
How it bit us
A bootstrap module (discovered first, alphabetically) imports src.app.request_context transitively → object A; its module-level ContextVars are bound by the auth middleware. Discovery later reaches request_context.py, re-imports it → object B; route handlers bind object B. Middleware sets claims on A's ContextVars, handlers read B's → every authenticated request 401'd on a valid JWT. (We worked around it by seeding _discovered_mtimes from app code before discovery proceeds.)
Repro / regression test
Fixture package under the discovery root with a module-level singleton, plus a sibling that sorts earlier and imports it transitively at import time:
src/pkgx/singleton.py: VALUE = object()
src/pkgx/_early.py: from src.pkgx import singleton as _s; CAPTURED = id(_s.VALUE)
import sys
from tina4_python.core.server import _auto_discover
import src.pkgx._early as early # transitive load -> object A
before = id(sys.modules["src.pkgx.singleton"].VALUE)
_auto_discover("src") # discovery reaches singleton.py
after = id(sys.modules["src.pkgx.singleton"].VALUE)
assert after == before, "module was re-imported -> duplicate object"
assert early.CAPTURED == after, "early importer left holding a stale module object"
Fails today (ids differ); passes after the fix.
Suggested fix
When a module is already loaded but unrecorded, adopt its current mtime rather than treating "unrecorded" as "changed":
if module_name not in sys.modules:
importlib.import_module(module_name); _discovered_mtimes[module_name] = current_mtime
elif module_name not in _discovered_mtimes:
# Loaded transitively before discovery saw it -> record, don't del+reimport.
_discovered_mtimes[module_name] = current_mtime
elif current_mtime > _discovered_mtimes[module_name]:
... # existing genuine-edit reload path, unchanged
Summary:
_auto_discover()re-imports an app module that's already insys.modules(because it was pulled in transitively before discovery recorded its mtime), producing a second module object. Any module-level singleton (aContextVar, a connection pool, a cached client) then exists twice — code that imported it early holds object A, code discovered later holds object B. Severity: high (silent; corrupts shared state).Version: tina4-python 3.13.37 (also confirmed byte-identical in 3.13.38).
Where
tina4_python/core/server.py::_auto_discover()(~lines 59–145). The reload branch (~125–144):A module that is in
sys.modulesbut absent from_discovered_mtimes(i.e. it was imported transitively by an earlier-discovered file, not by discovery itself) satisfiescurrent_mtime > 0.0, so it getsdel'd + re-imported. The existing scope guard only stops eviction oftina4_python.*/third-party — it still re-imports app (src.*) modules.How it bit us
A bootstrap module (discovered first, alphabetically) imports
src.app.request_contexttransitively → object A; its module-levelContextVars are bound by the auth middleware. Discovery later reachesrequest_context.py, re-imports it → object B; route handlers bind object B. Middleware sets claims on A's ContextVars, handlers read B's → every authenticated request 401'd on a valid JWT. (We worked around it by seeding_discovered_mtimesfrom app code before discovery proceeds.)Repro / regression test
Fixture package under the discovery root with a module-level singleton, plus a sibling that sorts earlier and imports it transitively at import time:
Fails today (ids differ); passes after the fix.
Suggested fix
When a module is already loaded but unrecorded, adopt its current mtime rather than treating "unrecorded" as "changed":