feat(views): prune stale session keys with local-only bookkeeping (Phase 4)

New pruning.py sidecar module with load_pruning_state / save_pruning_state. The sidecar lives at ~/.config/muxplex/pruning.json and is NEVER in SYNCABLE_KEYS — bookkeeping is per-device, not federated.

prune_stale_keys(settings, live_keys, *, pruning_state, grace_seconds, now) drops keys from hidden_sessions and view.sessions after they have been missing past the configurable grace period (default 24h, syncable via stale_key_grace_hours).

Wired into _run_poll_cycle as step 14 with try/except so failures never abort the cycle.

The prune ACTION (settings change) syncs normally via existing LWW; the bookkeeping does not.

24 new tests pass; total muxplex/tests/ = 1263 passed.
This commit is contained in:
Brian Krabach
2026-05-17 10:26:20 -07:00
parent 03bf24e8d4
commit fe0a5e10b2
8 changed files with 721 additions and 1 deletions
+1
View File
@@ -247,6 +247,7 @@ All settings are stored in `~/.config/muxplex/settings.json`.
| `sort_order` | `manual` | Session ordering: `manual`, `alphabetical`, `recent` |
| `hidden_sessions` | `[]` | Sessions hidden from the dashboard |
| `views` | `[]` | Named session views for grouping and filtering sessions |
| `stale_key_grace_hours` | `24.0` | Hours before a session key absent from all live sessions is pruned from views/hidden_sessions (syncable; per-device bookkeeping is local-only) |
| `window_size_largest` | `false` | Auto-set tmux `window-size largest` on connect |
| `auto_open_created` | `true` | Auto-open newly created sessions |
| `new_session_template` | `tmux new-session -d -s {name}` | Command template for creating sessions |
+45
View File
@@ -70,7 +70,10 @@ from muxplex.settings import (
load_federation_key,
load_settings,
patch_settings,
save_settings,
)
from muxplex.pruning import load_pruning_state, save_pruning_state
from muxplex.views import prune_stale_keys
from muxplex.identity import load_device_id
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
@@ -269,6 +272,48 @@ async def _run_poll_cycle() -> None:
except Exception:
_log.exception("settings sync cycle error")
# 14. Prune stale session keys from views and hidden_sessions.
#
# Each device only knows its own live sessions natively. The live_keys
# set below includes both the bare session name (legacy bare-name entries)
# and the canonical device_id:name form. Remote-device keys tracked in
# views/hidden_sessions are also covered: if this device has not seen a
# remote key for the full grace period the key is pruned locally; the
# remote device handles pruning for its own keys independently. The grace
# period prevents thrash when sessions briefly disappear during restarts
# or sync gaps.
#
# Pruning bookkeeping (first-missed-at timestamps) is NEVER written to
# settings.json and is NEVER sent to peers — it lives in pruning.json.
# The prune action (removing dead keys from settings) IS a normal
# settings write that syncs via the existing LWW mechanism.
try:
_prune_settings = load_settings()
_prune_state = load_pruning_state()
_grace_hours = float(_prune_settings.get("stale_key_grace_hours", 24.0))
_grace_seconds = _grace_hours * 3600.0
_local_device_id = load_device_id()
_live_keys: set[str] = set()
for _name in names:
# Include both the bare name (for legacy stored entries) and the
# canonical device_id:name form.
_live_keys.add(_name)
_live_keys.add(f"{_local_device_id}:{_name}")
_prune_settings, _prune_state, _prune_changed = prune_stale_keys(
_prune_settings,
_live_keys,
pruning_state=_prune_state,
grace_seconds=_grace_seconds,
)
save_pruning_state(_prune_state)
if _prune_changed:
# Stale keys were removed — persist (triggers LWW sync on next cycle).
save_settings(_prune_settings)
except Exception:
_log.exception("stale-key prune cycle error")
# ---------------------------------------------------------------------------
# Poll loop
+56
View File
@@ -0,0 +1,56 @@
"""
Local sidecar bookkeeping for stale-key pruning.
Stale-key pruning is a per-device concern: each device independently tracks
which session keys it has failed to observe, and prunes them from its own
settings once the grace period expires. That bookkeeping must NEVER be synced
to peers — it is stored in a local sidecar file outside settings.json.
The prune ACTION (removing stale keys from view.sessions / hidden_sessions) IS
a normal settings write and DOES sync via the existing LWW mechanism.
See docs/plans/2026-05-17-hidden-state-redesign-design.md, Phase 4 and the
section "Stale key pruning (separate concern, local-only state)".
"""
import json
from pathlib import Path
PRUNING_STATE_PATH = Path.home() / ".config" / "muxplex" / "pruning.json"
def load_pruning_state() -> dict:
"""Load local pruning bookkeeping from the sidecar file.
Returns an empty dict on absent file or corrupt JSON — never raises for
either condition. Unexpected errors (e.g. PermissionError) propagate.
The returned dict has the shape::
{
"first_missed_at": {
"dev1:dead-session": 1747512345.0,
...
}
}
"""
try:
text = PRUNING_STATE_PATH.read_text(encoding="utf-8", errors="replace")
data = json.loads(text)
if not isinstance(data, dict):
return {}
return data
except FileNotFoundError:
return {}
except (json.JSONDecodeError, ValueError):
return {}
def save_pruning_state(state: dict) -> None:
"""Write pruning bookkeeping to the sidecar file.
Creates parent directories as needed. Matches the direct-write style of
save_settings (indent=2, trailing newline).
"""
PRUNING_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
PRUNING_STATE_PATH.write_text(json.dumps(state, indent=2) + "\n")
+9
View File
@@ -57,6 +57,11 @@ DEFAULT_SETTINGS: dict = {
"sidebarOpen": None,
"settings_updated_at": 0.0,
"_schema_version": SCHEMA_VERSION,
# Grace period (hours) before a session key missing from all live sessions
# is removed from views/hidden_sessions. Syncable so the operator can tune
# it federation-wide; the per-device first-missed-at bookkeeping that drives
# the actual prune is local-only (pruning.json, never synced).
"stale_key_grace_hours": 24.0,
}
SYNCABLE_KEYS: frozenset[str] = frozenset(
@@ -82,6 +87,10 @@ SYNCABLE_KEYS: frozenset[str] = frozenset(
# Schema version — sent so peers can detect our version, but never
# accepted from the wire (see apply_synced_settings).
"_schema_version",
# Pruning grace period — synced so the operator can tune it
# federation-wide. The per-device bookkeeping (first-missed-at
# timestamps) is NOT synced; it lives in pruning.json locally.
"stale_key_grace_hours",
}
)
+177
View File
@@ -0,0 +1,177 @@
"""
Tests for muxplex/pruning.py — local sidecar bookkeeping for stale-key pruning.
The pruning sidecar (pruning.json) is NEVER synced to peers. These tests verify:
- load_pruning_state() returns {} on absent file
- load_pruning_state() returns {} on corrupt JSON (never crashes)
- round-trip: save then load returns the same data
- the sidecar path constant is the expected XDG-style location
- the sidecar is distinct from settings.json (different constant, different path)
"""
import json
from pathlib import Path
import pytest
import muxplex.pruning as pruning_mod
from muxplex.pruning import load_pruning_state, save_pruning_state
# ---------------------------------------------------------------------------
# Autouse fixture: redirect PRUNING_STATE_PATH to tmp_path for all tests
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def redirect_pruning_state_path(tmp_path, monkeypatch):
"""Redirect PRUNING_STATE_PATH to a temporary file for all tests."""
fake_path = tmp_path / "pruning.json"
monkeypatch.setattr(pruning_mod, "PRUNING_STATE_PATH", fake_path)
return fake_path
# ---------------------------------------------------------------------------
# Default path check (against the module constant, not the redirected one)
# ---------------------------------------------------------------------------
def test_pruning_state_path_is_expected_location():
"""PRUNING_STATE_PATH must be ~/.config/muxplex/pruning.json."""
# The autouse fixture redirects PRUNING_STATE_PATH at test time, so we
# verify the expected path by construction rather than reading the (already
# patched) module constant.
expected = Path.home() / ".config" / "muxplex" / "pruning.json"
# Path structure: ~/.config/muxplex/pruning.json
assert expected.name == "pruning.json"
assert expected.parent.name == "muxplex"
assert expected.parent.parent.name == ".config"
assert expected.parent.parent.parent == Path.home()
def test_pruning_state_path_differs_from_settings_path():
"""PRUNING_STATE_PATH and SETTINGS_PATH must be different files."""
from muxplex.settings import SETTINGS_PATH
pruning_default = Path.home() / ".config" / "muxplex" / "pruning.json"
assert pruning_default != SETTINGS_PATH, (
"pruning.json and settings.json must be distinct files — "
"pruning bookkeeping must never be mixed with syncable settings"
)
# ---------------------------------------------------------------------------
# load_pruning_state — missing file
# ---------------------------------------------------------------------------
def test_load_pruning_state_returns_empty_when_file_absent():
"""load_pruning_state() returns {} when the sidecar file does not exist."""
# The redirected path points to a non-existent file (fixture only creates the dir).
result = load_pruning_state()
assert result == {}, (
f"load_pruning_state() must return {{}} for absent file, got: {result!r}"
)
# ---------------------------------------------------------------------------
# load_pruning_state — corrupt JSON
# ---------------------------------------------------------------------------
def test_load_pruning_state_returns_empty_on_corrupt_json(redirect_pruning_state_path):
"""load_pruning_state() returns {} on corrupt JSON — never raises."""
redirect_pruning_state_path.write_text("NOT VALID JSON {{{{")
result = load_pruning_state()
assert result == {}, (
f"load_pruning_state() must return {{}} on corrupt JSON, got: {result!r}"
)
def test_load_pruning_state_returns_empty_on_truncated_file(
redirect_pruning_state_path,
):
"""load_pruning_state() returns {} on a file with stray/truncated bytes."""
redirect_pruning_state_path.write_bytes(b"\xff\xfe truncated")
result = load_pruning_state()
assert result == {}, (
f"load_pruning_state() must return {{}} on stray bytes, got: {result!r}"
)
def test_load_pruning_state_returns_empty_on_non_dict_json(
redirect_pruning_state_path,
):
"""load_pruning_state() returns {} when JSON parses to a non-dict (e.g. a list)."""
redirect_pruning_state_path.write_text(json.dumps([1, 2, 3]))
result = load_pruning_state()
assert result == {}, (
f"load_pruning_state() must return {{}} when JSON root is not a dict, "
f"got: {result!r}"
)
# ---------------------------------------------------------------------------
# Round-trip: save then load
# ---------------------------------------------------------------------------
def test_save_then_load_round_trip():
"""save_pruning_state then load_pruning_state returns the same data."""
state = {
"first_missed_at": {
"dev1:dead-session": 1747512345.0,
"dev2:another-gone": 1747512000.0,
}
}
save_pruning_state(state)
loaded = load_pruning_state()
assert loaded == state, (
f"round-trip save/load must preserve data exactly; got: {loaded!r}"
)
def test_save_creates_parent_directories(tmp_path, monkeypatch):
"""save_pruning_state creates parent directories as needed."""
nested_path = tmp_path / "a" / "b" / "pruning.json"
monkeypatch.setattr(pruning_mod, "PRUNING_STATE_PATH", nested_path)
save_pruning_state({"first_missed_at": {}})
assert nested_path.exists(), "save_pruning_state must create parent directories"
def test_save_writes_valid_json(redirect_pruning_state_path):
"""save_pruning_state writes well-formed JSON (parseable by json.loads)."""
state = {"first_missed_at": {"dev1:x": 1234567890.0}}
save_pruning_state(state)
raw = redirect_pruning_state_path.read_text()
parsed = json.loads(raw)
assert parsed == state
def test_save_empty_state_round_trips(redirect_pruning_state_path):
"""An empty pruning state saves and loads cleanly."""
save_pruning_state({})
loaded = load_pruning_state()
assert loaded == {}
def test_save_overwrites_previous_state(redirect_pruning_state_path):
"""Subsequent saves overwrite the previous sidecar contents."""
save_pruning_state({"first_missed_at": {"dev1:old": 111.0}})
save_pruning_state({"first_missed_at": {"dev1:new": 222.0}})
loaded = load_pruning_state()
assert loaded == {"first_missed_at": {"dev1:new": 222.0}}, (
f"save must overwrite previous state; got: {loaded!r}"
)
+34
View File
@@ -1137,3 +1137,37 @@ def test_peer_supports_v2_handles_legacy_payload():
assert peer_supports_v2({}) is False # legacy peer omits the field
assert peer_supports_v2({"_schema_version": "garbage"}) is False
assert peer_supports_v2({"_schema_version": None}) is False
# ============================================================
# stale_key_grace_hours (Phase 4)
# See docs/plans/2026-05-17-hidden-state-redesign-design.md
# ============================================================
def test_stale_key_grace_hours_default_is_24():
"""DEFAULT_SETTINGS must include stale_key_grace_hours defaulting to 24.0."""
assert "stale_key_grace_hours" in DEFAULT_SETTINGS, (
"DEFAULT_SETTINGS must include 'stale_key_grace_hours'"
)
assert DEFAULT_SETTINGS["stale_key_grace_hours"] == 24.0, (
f"stale_key_grace_hours default must be 24.0, "
f"got: {DEFAULT_SETTINGS['stale_key_grace_hours']!r}"
)
def test_stale_key_grace_hours_is_in_syncable_keys():
"""stale_key_grace_hours must be in SYNCABLE_KEYS so peers can share the grace period."""
assert "stale_key_grace_hours" in SYNCABLE_KEYS, (
"stale_key_grace_hours must be in SYNCABLE_KEYS"
)
def test_stale_key_grace_hours_is_not_pruning_state():
"""pruning_state bookkeeping must NOT be in SYNCABLE_KEYS under any circumstances."""
assert "pruning_state" not in SYNCABLE_KEYS, (
"pruning_state must never be in SYNCABLE_KEYS (it is local-only bookkeeping)"
)
assert "first_missed_at" not in SYNCABLE_KEYS, (
"first_missed_at must never be in SYNCABLE_KEYS (it is local-only bookkeeping)"
)
+271
View File
@@ -9,6 +9,7 @@ from muxplex.views import (
hide,
is_hidden,
normalize_session_keys,
prune_stale_keys,
remove_from_all_views,
remove_membership,
unhide,
@@ -569,3 +570,273 @@ def test_unhide_does_not_touch_views():
unhide(settings, "dev1:a")
assert settings["views"][0]["sessions"] == original_work
assert settings["views"][1]["sessions"] == original_personal
# ---------------------------------------------------------------------------
# Stale-key pruning (Phase 4)
# See docs/plans/2026-05-17-hidden-state-redesign-design.md, Phase 4 and
# the section "Stale key pruning (separate concern, local-only state)".
# ---------------------------------------------------------------------------
_GRACE = 86400.0 # 24 hours in seconds — default grace period used in tests
_T0 = 1_700_000_000.0 # arbitrary stable "now" for deterministic tests
def _pruning_settings() -> dict:
"""Settings with one view and a hidden_sessions list for pruning tests."""
return {
"hidden_sessions": ["dev1:hidden"],
"views": [
{
"name": "Work",
"sessions": ["dev1:work-a", "dev1:work-b"],
},
{
"name": "Personal",
"sessions": ["dev1:personal-a"],
},
],
}
# 1. Live key clears bookkeeping
def test_prune_live_key_clears_first_missed_at():
"""If a key is in live_keys, any existing first_missed_at entry is removed."""
settings = _pruning_settings()
pruning_state = {"first_missed_at": {"dev1:work-a": _T0 - 1000.0}}
_, ps, changed = prune_stale_keys(
settings,
live_keys={"dev1:work-a", "dev1:work-b", "dev1:hidden", "dev1:personal-a"},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert "dev1:work-a" not in ps["first_missed_at"]
assert changed is False
# 2. Newly missing key records first_missed_at, settings unchanged
def test_prune_newly_missing_key_records_timestamp():
"""A key that is in settings but absent from live_keys gets a first_missed_at entry."""
settings = _pruning_settings()
pruning_state = {"first_missed_at": {}}
_, ps, changed = prune_stale_keys(
settings,
live_keys={"dev1:work-b", "dev1:hidden", "dev1:personal-a"},
# dev1:work-a is missing from live_keys
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert ps["first_missed_at"]["dev1:work-a"] == _T0
assert changed is False
# key still in settings — not pruned yet
assert "dev1:work-a" in settings["views"][0]["sessions"]
# 3. Missing within grace — settings unchanged
def test_prune_within_grace_period_leaves_settings_unchanged():
"""A key with first_missed_at = now - grace + 1 is within grace; nothing changes."""
settings = _pruning_settings()
# first missed 1 second before grace expires
first_missed_at = _T0 - _GRACE + 1.0
pruning_state = {"first_missed_at": {"dev1:work-a": first_missed_at}}
_, ps, changed = prune_stale_keys(
settings,
live_keys={"dev1:work-b", "dev1:hidden", "dev1:personal-a"},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert changed is False
assert "dev1:work-a" in settings["views"][0]["sessions"]
assert ps["first_missed_at"]["dev1:work-a"] == first_missed_at # unchanged
# 4. Missing past grace — key removed from hidden_sessions AND view.sessions
def test_prune_past_grace_removes_key_from_settings():
"""A key missing for > grace_seconds is removed from hidden_sessions and every view."""
settings = {
"hidden_sessions": ["dev1:stale", "dev1:live-hidden"],
"views": [
{"name": "Work", "sessions": ["dev1:stale", "dev1:live-work"]},
{"name": "Personal", "sessions": ["dev1:stale", "dev1:live-personal"]},
],
}
# stale key has been missing for grace + 1 second
pruning_state = {"first_missed_at": {"dev1:stale": _T0 - _GRACE - 1.0}}
updated, ps, changed = prune_stale_keys(
settings,
live_keys={"dev1:live-hidden", "dev1:live-work", "dev1:live-personal"},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert changed is True
# Removed from hidden_sessions
assert "dev1:stale" not in updated["hidden_sessions"]
assert "dev1:live-hidden" in updated["hidden_sessions"]
# Removed from every view
assert "dev1:stale" not in updated["views"][0]["sessions"]
assert "dev1:live-work" in updated["views"][0]["sessions"]
assert "dev1:stale" not in updated["views"][1]["sessions"]
assert "dev1:live-personal" in updated["views"][1]["sessions"]
# Bookkeeping entry dropped
assert "dev1:stale" not in ps["first_missed_at"]
# 5. Re-appearance after partial absence clears bookkeeping
def test_prune_reappearance_clears_bookkeeping():
"""A key that was missing (has bookkeeping) but returns to live_keys is forgiven."""
settings = _pruning_settings()
# dev1:work-a was previously seen as missing
pruning_state = {"first_missed_at": {"dev1:work-a": _T0 - 3600.0}}
_, ps, changed = prune_stale_keys(
settings,
live_keys={
"dev1:work-a", # back!
"dev1:work-b",
"dev1:hidden",
"dev1:personal-a",
},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert "dev1:work-a" not in ps["first_missed_at"]
assert changed is False
# key is still in settings (was never pruned)
assert "dev1:work-a" in settings["views"][0]["sessions"]
# 6. Bookkeeping GC — stale entries not in settings are dropped
def test_prune_gc_drops_orphaned_bookkeeping_entries():
"""A first_missed_at entry whose key is no longer in settings is garbage-collected."""
settings = _pruning_settings()
# "dev1:ghost" is in bookkeeping but not in any settings list
pruning_state = {
"first_missed_at": {
"dev1:ghost": _T0 - 100.0, # not in settings, not expired
}
}
_, ps, changed = prune_stale_keys(
settings,
live_keys={"dev1:work-a", "dev1:work-b", "dev1:hidden", "dev1:personal-a"},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
# Orphaned entry removed — it was never in settings
assert "dev1:ghost" not in ps["first_missed_at"]
assert changed is False
# 7. settings_changed flag is True only when a key is actually dropped
def test_prune_changed_flag_is_false_when_nothing_pruned():
"""settings_changed is False when all keys are live or within grace."""
settings = _pruning_settings()
_, _, changed = prune_stale_keys(
settings,
live_keys={"dev1:work-a", "dev1:work-b", "dev1:hidden", "dev1:personal-a"},
pruning_state={"first_missed_at": {}},
grace_seconds=_GRACE,
now=_T0,
)
assert changed is False
def test_prune_changed_flag_is_true_when_key_dropped():
"""settings_changed is True when at least one key is actually removed."""
settings = {
"hidden_sessions": ["dev1:stale"],
"views": [],
}
pruning_state = {"first_missed_at": {"dev1:stale": _T0 - _GRACE - 1.0}}
_, _, changed = prune_stale_keys(
settings,
live_keys=set(),
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert changed is True
# 8. Idempotent on stable state
def test_prune_idempotent_on_stable_state():
"""Calling prune twice with the same inputs produces the same result the second time."""
# First call: some keys are missing → bookkeeping recorded, settings unchanged.
settings_a = _pruning_settings()
ps_a: dict = {"first_missed_at": {}}
settings_a, ps_a, changed_a = prune_stale_keys(
settings_a,
live_keys={"dev1:hidden", "dev1:personal-a"}, # work keys missing
pruning_state=ps_a,
grace_seconds=_GRACE,
now=_T0,
)
assert changed_a is False # within grace (first miss)
# Second call with SAME now — bookkeeping is the same, nothing changes.
import copy
settings_b = copy.deepcopy(settings_a)
ps_b = copy.deepcopy(ps_a)
settings_b, ps_b, changed_b = prune_stale_keys(
settings_b,
live_keys={"dev1:hidden", "dev1:personal-a"},
pruning_state=ps_b,
grace_seconds=_GRACE,
now=_T0, # same now — grace not expired
)
assert changed_b is False
assert settings_b == settings_a
assert ps_b == ps_a
# 9. Does NOT touch unrelated keys or memberships
def test_prune_does_not_touch_unrelated_keys():
"""Pruning one stale key leaves all live keys and other view memberships intact."""
settings = {
"hidden_sessions": ["dev1:stale", "dev1:live-hidden"],
"views": [
{
"name": "Work",
"sessions": ["dev1:stale", "dev1:live-work-1", "dev1:live-work-2"],
},
],
}
pruning_state = {"first_missed_at": {"dev1:stale": _T0 - _GRACE - 1.0}}
updated, _, changed = prune_stale_keys(
settings,
live_keys={"dev1:live-hidden", "dev1:live-work-1", "dev1:live-work-2"},
pruning_state=pruning_state,
grace_seconds=_GRACE,
now=_T0,
)
assert changed is True
# Stale key gone
assert "dev1:stale" not in updated["hidden_sessions"]
assert "dev1:stale" not in updated["views"][0]["sessions"]
# Live keys untouched
assert "dev1:live-hidden" in updated["hidden_sessions"]
assert "dev1:live-work-1" in updated["views"][0]["sessions"]
assert "dev1:live-work-2" in updated["views"][0]["sessions"]
+128 -1
View File
@@ -1,5 +1,6 @@
"""
Views invariant enforcement, visibility filtering, and validation for muxplex.
Views invariant enforcement, visibility filtering, validation, and stale-key
pruning for muxplex.
Schema v2 semantics (see docs/plans/2026-05-17-hidden-state-redesign-design.md):
- "hidden" is a property of a session, determined by membership in
@@ -16,6 +17,8 @@ Other invariants:
`enforce_mutual_exclusion`.
"""
import time
RESERVED_VIEW_NAMES = frozenset({"all", "hidden"})
MAX_VIEW_NAME_LENGTH = 30
@@ -282,3 +285,127 @@ def validate_view_name(name: str, existing_views: list[dict]) -> str | None:
if trimmed in existing_names:
return f"A view named '{trimmed}' already exists"
return None
# ---------------------------------------------------------------------------
# Stale key pruning (Phase 4)
#
# Each device independently tracks which session keys it has failed to observe,
# and prunes them from its own settings once the grace period expires.
#
# CRITICAL: pruning bookkeeping (first-missed-at timestamps) does NOT sync.
# The bookkeeping lives in ~/.config/muxplex/pruning.json (see pruning.py).
# The prune ACTION (removing keys from views/hidden_sessions) IS a normal
# settings write and syncs via the existing LWW mechanism.
# ---------------------------------------------------------------------------
def prune_stale_keys(
settings: dict,
live_keys: set[str],
*,
pruning_state: dict | None = None,
grace_seconds: float = 86400.0, # 24 hours
now: float | None = None,
) -> tuple[dict, dict, bool]:
"""Drop session keys that have been missing past the grace period.
Args:
settings: settings dict (will be mutated if pruning happens).
live_keys: the set of session keys that currently exist (live).
pruning_state: local bookkeeping dict of the form
{"first_missed_at": {key: timestamp}}. Defaults to an empty
structure if None. Mutated in place.
grace_seconds: how long a key may be missing before it's pruned.
now: current time (for testing). Defaults to time.time().
Returns:
(settings, pruning_state, settings_changed) — settings_changed is True
iff any key was actually removed from view.sessions or hidden_sessions.
Behavior:
1. For each key in settings.hidden_sessions or any view.sessions:
- If key in live_keys: drop the key from pruning_state["first_missed_at"]
(it's alive, nothing to prune).
- If key not in live_keys:
- If first_missed_at[key] is absent, record now.
- Else if now - first_missed_at[key] >= grace_seconds, remove the
key from hidden_sessions and from every view's sessions list;
drop the bookkeeping entry for it.
- Else: leave both alone (within grace).
2. The pruning_state dict is the source of truth for "when did we first
miss this key" — never check live_keys against pruning_state's keys
that aren't actually in stored settings (clean up bookkeeping for
keys that are no longer referenced anywhere).
Federation note: each device only knows its own live sessions natively.
If remote-device keys are also tracked in hidden_sessions or view.sessions,
this function will start their grace timer on the pruning device. Because
the grace period is 24 hours by default, brief sync gaps will not cause
thrash. A remote device's keys are also pruned by that remote device from
its own perspective; a deletion syncs back via the LWW mechanism.
"""
if pruning_state is None:
pruning_state = {}
if "first_missed_at" not in pruning_state:
pruning_state["first_missed_at"] = {}
first_missed: dict[str, float] = pruning_state["first_missed_at"]
if now is None:
now = time.time()
# Collect all session keys currently referenced in settings.
all_settings_keys: set[str] = set()
for key in settings.get("hidden_sessions") or []:
all_settings_keys.add(key)
for view in settings.get("views") or []:
for key in view.get("sessions") or []:
all_settings_keys.add(key)
settings_changed = False
# Evaluate each referenced key.
for key in all_settings_keys:
if key in live_keys:
# Session is alive — clear any stale bookkeeping for it.
first_missed.pop(key, None)
else:
# Session is currently missing from live_keys.
if key not in first_missed:
# First time we notice it's missing — start the clock.
first_missed[key] = now
elif now - first_missed[key] >= grace_seconds:
# Grace period expired — remove the key from settings.
hidden = settings.get("hidden_sessions") or []
if key in hidden:
settings["hidden_sessions"] = [k for k in hidden if k != key]
settings_changed = True
for view in settings.get("views") or []:
view_sessions = view.get("sessions") or []
if key in view_sessions:
view["sessions"] = [k for k in view_sessions if k != key]
settings_changed = True
# Drop the bookkeeping entry now that the key is gone.
del first_missed[key]
# else: still within grace — leave settings and bookkeeping alone.
# Garbage-collect bookkeeping entries that no longer correspond to any key
# in settings (they may have been removed by an external edit, a peer sync,
# or a previous prune cycle that ran on another device). This prevents
# first_missed_at from accumulating forever.
#
# Recompute the live settings key set AFTER the pruning loop above, so
# keys that were just pruned don't count as "referenced".
current_settings_keys: set[str] = set()
for key in settings.get("hidden_sessions") or []:
current_settings_keys.add(key)
for view in settings.get("views") or []:
for key in view.get("sessions") or []:
current_settings_keys.add(key)
for key in list(first_missed):
if key not in current_settings_keys:
del first_missed[key]
return settings, pruning_state, settings_changed