Skip to content

Auto-discover re-imports already-loaded modules → duplicate module objects (breaks module-level singletons) #53

@justin-k-bruce

Description

@justin-k-bruce

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions