Source code for phenotypic.gui.shell._session
"""``ToolSession`` lifecycle primitive + idle-release daemon.
Each tool the shell hosts (Builder, Viewer, Run console) wraps its heavy state
in a :class:`ToolSession`. The shell drops Python references on ``release()``;
the next ``get()`` rebuilds from disk. RSS may not return to the OS — that's
documented in the Release-button tooltip — but the in-process object graph
shrinks predictably.
Threading model
* ``get`` and ``release`` are both serialised through ``self._lock`` so an
in-flight ``get`` cannot observe a half-torn-down state, and a
``release`` cannot run concurrently with ``get``'s build call.
* ``get`` sets ``_last_access`` *before* invoking ``build``, while still
holding the lock. This pairing prevents the idle daemon from observing
``_state`` set with a stale ``_last_access`` and spuriously releasing
a session that was just built (see ``test_idle_thread_does_not_release``
``_during_active_get``).
* ``touch`` skips the lock and writes a single attribute. Under CPython
the ``STORE_ATTR`` bytecode is indivisible and Python ``float``
objects are immutable, so a concurrent reader sees either the previous
timestamp or the new one — never a torn value. A stale read costs at
most one idle-poll iteration of inaccuracy.
* ``idle_seconds`` reads ``_state`` and ``_last_access`` without the lock
to keep the polling daemon cheap. The values may be racy but, given
the ordering rule above, the worst case is "release fires one tick
late," which is harmless.
Idle release uses a single background daemon thread polling ``time.monotonic()``
every poll-interval seconds (default 60). One-shot ``threading.Timer`` is
deliberately avoided: a Timer scheduled at touch-time and cancelled on every
subsequent touch races with the daemon thread that the timer might also try
to spawn. Polling is simpler and idempotent.
"""
from __future__ import annotations
import gc
import logging
import threading
import time
from typing import Callable, Generic, TypeVar
from phenotypic.gui._config import THREAD_NAME_PREFIX
logger = logging.getLogger(__name__)
__all__ = [
"ToolSession",
"start_idle_release_thread",
]
T = TypeVar("T")
[docs]
class ToolSession(Generic[T]):
"""Lazy, releasable wrapper around a heavy tool's state object.
``ToolSession`` is generic over the state type ``T`` (e.g. ``dash.Dash``
for the viewer, ``OutputRoot`` for measurement loading). The shell
constructs a session at boot but does NOT call ``build`` — that happens
on first ``get()``.
Args:
name: Human-readable label for log lines and ``__repr__``.
build: Zero-argument callable that constructs the heavy state. Called
once per release cycle, under ``self._lock``.
teardown: Optional cleanup callable invoked with the
(now-detached) state **after** the session has cleared its
reference. Runs outside the session's lock; concurrent ``get``
calls may have already triggered a rebuild before ``teardown``
returns. Therefore ``teardown`` must only touch state owned by
the released object, never shared global state that the rebuild
repopulates — e.g. clearing keys on the *released*
``viewer_app.server.config`` is fine, but clearing keys on a
process-wide singleton (``shell_app.server.config``) is a bug
because the rebuild re-installs them. Defaults to a no-op.
Example:
>>> from phenotypic.gui.shell._session import ToolSession
>>> def _build():
... return {"big_dataframe": "loaded"}
>>> session = ToolSession("viewer", build=_build)
>>> session.get() # triggers _build()
{'big_dataframe': 'loaded'}
>>> session.release()
>>> session.get() # _build() runs again
{'big_dataframe': 'loaded'}
"""
def __init__(
self,
name: str,
*,
build: Callable[[], T],
teardown: Callable[[T], None] | None = None,
) -> None:
self.name = name
self._build = build
self._teardown = teardown if teardown is not None else _noop_teardown
self._state: T | None = None
self._last_access: float = 0.0
self._lock = threading.Lock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
def get(self) -> T:
"""Return the live state, building it on first access.
Acquires ``self._lock`` so concurrent first-access calls don't both
run ``build``. Updates ``_last_access`` to the current monotonic
time **before** invoking ``build`` so that the idle daemon (which
reads ``_last_access`` lock-free) cannot observe a freshly-set
``_state`` paired with a stale ``_last_access`` and spuriously
decide to release.
"""
with self._lock:
self._last_access = time.monotonic()
if self._state is None:
logger.debug("ToolSession[%s]: building", self.name)
self._state = self._build()
return self._state
[docs]
def touch(self) -> None:
"""Bump activity timestamp without forcing a build.
Called by Flask blueprints (``/runs/...``, ``/sandbox/api/...``)
whose hits indicate the user is actively using the tool. Without
``touch`` the idle daemon would release tools while the user is
reading an iframed dashboard (the dashboard polls files via the
``/runs/`` blueprint, NOT through Dash callbacks).
Lock-free by design — the only mutation is a 64-bit float write.
"""
self._last_access = time.monotonic()
[docs]
def idle_seconds(self) -> float:
"""Seconds since the last ``get`` or ``touch``.
Returns 0.0 when the session is unbuilt — there is nothing to
release. Reading ``_state`` without the lock is intentional; a
race only changes the answer by one polling cycle.
"""
if self._state is None:
return 0.0
return time.monotonic() - self._last_access
[docs]
def is_built(self) -> bool:
"""True iff the session currently holds built state."""
return self._state is not None
[docs]
def set_build(self, build: Callable[[], T]) -> None:
"""Replace the build callable and drop any existing state.
Used by hand-off endpoints (e.g. the viewer's
``/sandbox/api/viewer/output-root``) that need the next ``get`` to
construct the heavy state with new arguments. The swap and the
release are performed under ``self._lock`` so an in-flight ``get``
cannot observe a half-swapped state, and the next ``get`` is
guaranteed to call the freshly-installed ``build``.
``teardown`` runs outside the lock with the previously-held state,
following the same contract as :meth:`release`.
Args:
build: The new zero-argument constructor for the heavy state.
"""
with self._lock:
self._build = build
stale = self._state
self._state = None
if stale is not None:
try:
self._teardown(stale)
except Exception:
logger.exception(
"ToolSession[%s]: teardown raised during set_build; "
"swap completed anyway",
self.name,
)
del stale
gc.collect()
logger.debug(
"ToolSession[%s]: build swapped + state released", self.name
)
[docs]
def release(self) -> None:
"""Drop the in-memory state. Idempotent.
Runs ``teardown(state)`` first, then drops the reference and calls
``gc.collect()``. The next ``get`` will rebuild from scratch. Held
under ``self._lock`` so an in-flight ``get`` cannot observe a
torn-down state.
"""
with self._lock:
if self._state is None:
return
state = self._state
self._state = None
# Teardown + gc happen outside the lock. ``state`` is a local
# reference; nothing else points at the old value once ``self._state``
# is cleared. Holding the lock through teardown would block ``touch``
# in the unlikely-but-possible case where teardown itself is slow.
try:
self._teardown(state)
except Exception:
logger.exception(
"ToolSession[%s]: teardown raised; releasing anyway",
self.name,
)
del state
gc.collect()
logger.debug("ToolSession[%s]: released", self.name)
# ------------------------------------------------------------------
# Dunders
# ------------------------------------------------------------------
def __repr__(self) -> str: # pragma: no cover - cosmetic
built = "built" if self.is_built() else "empty"
return f"<ToolSession name={self.name!r} {built}>"
def _noop_teardown(_: object) -> None:
return None
# ---------------------------------------------------------------------------
# Idle release daemon
# ---------------------------------------------------------------------------
def start_idle_release_thread(
sessions: "list[ToolSession[object]]",
*,
idle_release_seconds: float,
poll_interval_seconds: float = 60.0,
stop_event: threading.Event | None = None,
) -> threading.Thread:
"""Start a daemon thread that releases idle sessions.
The daemon walks ``sessions`` every ``poll_interval_seconds`` and calls
``release()`` on any session whose ``idle_seconds()`` exceeds
``idle_release_seconds``. The thread is marked daemon so it does not
block process shutdown.
Args:
sessions: A list of :class:`ToolSession` instances to monitor. The
list itself may be mutated by the caller — the daemon snapshots
it on each iteration. **Caller retains ownership.**
idle_release_seconds: Sessions whose ``idle_seconds()`` exceeds this
threshold are released. The shell exposes this via
``--idle-release-minutes`` (multiplied by 60).
poll_interval_seconds: How often the daemon wakes up. Default 60s.
Polling is intentional — a one-shot ``threading.Timer`` races
with ``touch()`` resets, so we trade one wake-up per minute for
a much simpler concurrency model.
stop_event: Optional :class:`threading.Event` that, when set,
causes the daemon to exit. Used in tests to terminate the
thread cleanly. In production this is typically ``None`` and
the thread runs until process exit.
Returns:
The started ``threading.Thread`` instance. Caller can reach into it
to ``.join()`` after setting ``stop_event``, but in production the
daemon is fire-and-forget.
"""
if stop_event is None:
stop_event = threading.Event()
def _loop() -> None:
while not stop_event.is_set():
for session in list(sessions):
if not session.is_built():
continue
if session.idle_seconds() < idle_release_seconds:
continue
logger.info(
"ToolSession[%s]: idle for %.0fs >= %.0fs; releasing",
session.name,
session.idle_seconds(),
idle_release_seconds,
)
try:
session.release()
except Exception:
logger.exception(
"ToolSession[%s]: release raised in idle daemon",
session.name,
)
stop_event.wait(timeout=poll_interval_seconds)
thread = threading.Thread(
target=_loop,
name=f"{THREAD_NAME_PREFIX}-idle-release",
daemon=True,
)
thread.start()
return thread