"""Two accumulators in the daemon that nothing ever emptied.
**App tiles.** `host.app.render()` appended to a per-session list with no cap
and no size limit on the payload, which is arbitrary agent-supplied data.
Measured: 2000 renders carrying 50 KB of HTML each — a tile per iteration of an
analysis loop, which is what the API is for — held 100 MB in the daemon for the
life of the session. Nothing outside the cell reads a tile: the only consumer
is `host.app.tiles()`, so none of it was ever displayed to anyone.
**Session history.** A `SessionState` left `_sessions` only on an explicit
close or delete. The idle sweeper stops a cold session's kernels and leaves the
state resident, and its provider history is ~1.1 MB for a 200-turn
conversation — essentially the whole cost. A daemon therefore accumulated every
conversation it had served, holding history for kernels that no longer existed.
The history is recoverable: `_seed_messages` rebuilds it from
`restore_action_history` because the store is the canonical provider history.
Dropping it leaves the session exactly as a daemon restart does, which every
reader already handles — after a restart, no session is resident at all.
"""
from __future__ import annotations
import sys
import pytest
from openai4s.config import Config, LLMConfig
from openai4s.host_dispatch import HostDispatcher, build_dispatcher
from openai4s.server import gateway as gateway_mod
def _deep_size(obj, seen=None) -> int:
seen = seen if seen is not None else set()
if id(obj) in seen:
return 0
seen.add(id(obj))
total = sys.getsizeof(obj)
if isinstance(obj, dict):
for key, value in obj.items():
total += _deep_size(key, seen) + _deep_size(value, seen)
elif isinstance(obj, (list, tuple, set)):
for value in obj:
total += _deep_size(value, seen)
return total
# --------------------------------------------------------------------------
# app tiles
# --------------------------------------------------------------------------
@pytest.fixture
def dispatcher(tmp_path):
workspace = tmp_path / "ws"
workspace.mkdir()
cfg = Config(
data_dir=tmp_path / "data",
llm=LLMConfig(provider="deepseek", api_key="test-key"),
)
return build_dispatcher(cfg, frame_id="f-1", workspace=workspace)
def test_a_render_loop_does_not_grow_the_daemon_without_limit(dispatcher):
"""The defect, as the number that used to have no ceiling.
Distinct payloads on purpose: identical ones are a single interned string,
which makes the leak look 100x smaller than it is.
"""
for index in range(2000):
dispatcher._m_app_render(
{"kind": "html", "payload": f"
" + "x" * 50_000 + "
"}
)
tiles = dispatcher._m_app_tiles()
assert len(tiles) == HostDispatcher.MAX_APP_TILES
assert _deep_size(tiles) < 20_000_000, "the tile list is still unbounded"
def test_the_tiles_kept_are_the_recent_ones(dispatcher):
"""Which end is dropped matters: a cell reads tiles back to see what it
just rendered, so keeping the oldest would make the API useless under the
exact loop that triggers the cap."""
for index in range(HostDispatcher.MAX_APP_TILES + 10):
dispatcher._m_app_render({"kind": "html", "payload": f"tile-{index}"})
payloads = [tile["payload"] for tile in dispatcher._m_app_tiles()]
assert payloads[-1] == f"tile-{HostDispatcher.MAX_APP_TILES + 9}"
assert "tile-0" not in payloads
def test_a_cell_is_told_when_older_tiles_were_dropped(dispatcher):
"""Silent truncation reads as "here is everything". A cell that knows it
lost tiles can do something about it; one that does not, cannot."""
first = dispatcher._m_app_render({"kind": "html", "payload": "a"})
assert "dropped" not in first
for index in range(HostDispatcher.MAX_APP_TILES + 5):
result = dispatcher._m_app_render({"kind": "html", "payload": str(index)})
assert result["dropped"] >= 5
def test_an_oversized_payload_is_refused_rather_than_clipped(dispatcher):
"""Half a document is not a smaller document. The soft-fail shape reaches
the cell as a RuntimeError it can catch and act on."""
result = dispatcher._m_app_render(
{"kind": "html", "payload": "y" * (HostDispatcher.MAX_APP_TILE_CHARS + 1)}
)
assert set(result.keys()) == {"error"}
assert "limit" in result["error"]
assert dispatcher._m_app_tiles() == []
def test_an_ordinary_tile_still_renders(dispatcher):
"""The cap must not have turned the API off."""
result = dispatcher._m_app_render({"kind": "html", "payload": "hi"})
assert result["ok"] is True
assert result["tile_id"]
tiles = dispatcher._m_app_tiles()
assert len(tiles) == 1 and tiles[0]["payload"] == "hi"
def test_a_non_string_payload_is_measured_not_crashed_on(dispatcher):
"""`payload` is whatever a cell passed — a dict of plot data as often as
HTML. Sizing it must not raise, and must not let a huge structure through
just because it is not a string."""
assert dispatcher._m_app_render({"kind": "json", "payload": {"a": [1, 2, 3]}})["ok"]
big = {"rows": ["z" * 1000] * 500} # ~500 KB serialised
assert set(dispatcher._m_app_render({"kind": "json", "payload": big})) == {"error"}
# --------------------------------------------------------------------------
# session history
# --------------------------------------------------------------------------
class _Hub:
def emitter(self, root_frame_id):
return lambda event: None
def broadcast(self, root_frame_id, event):
return None
@pytest.fixture
def runner(tmp_path):
cfg = Config(
data_dir=tmp_path,
llm=LLMConfig(provider="deepseek", api_key="test-key"),
max_turns=1,
)
return gateway_mod.SessionRunner(cfg, _Hub())
def _boot_python_kernel(st):
"""A real worker, because the sweeper only releases a session that has one.
`_release_idle_session` returns False when nothing was stopped, so a state
with no kernel exercises none of the path under test — the first version of
this file asserted against exactly that and proved nothing.
"""
from openai4s.kernel.manager import Kernel
st.kernels.ensure("python", None, lambda: Kernel(cwd=str(st.workspace)))
assert st.kernels.status("python").get("alive")
def _session_with_history(runner):
project = runner.store.create_project(name="p", description="", context="")
if isinstance(project, dict):
project = project["project_id"]
frame = runner.create_session(project)
st = runner._state(frame, project)
st.messages = [{"role": "system", "content": "s" * 4000}]
for index in range(200):
st.messages.append({"role": "user", "content": f"turn {index} " + "u" * 1500})
st.messages.append(
{"role": "assistant", "content": f"reply {index} " + "a" * 3500}
)
return st
def test_a_cold_session_costs_a_megabyte_before_it_is_released(runner):
"""The measurement the fix is sized against. If this ever drops on its own
the fix below stops being worth its risk, and the number here is how anyone
would notice."""
st = _session_with_history(runner)
assert _deep_size(st.messages) > 1_000_000
@pytest.fixture
def expired(runner, monkeypatch):
"""Only the trigger is stubbed. Whether the TTL has elapsed is a clock
question with its own tests; what is under test here is what the release
does once it fires."""
monkeypatch.setattr(runner.recovery, "idle_expired", lambda *_a, **_k: True)
return runner
def test_releasing_an_idle_session_drops_its_resident_history(expired):
"""The defect. The sweeper stopped the kernels and left the conversation
resident, so the daemon kept history for a kernel that no longer existed.
"""
st = _session_with_history(expired)
_boot_python_kernel(st)
released = expired._release_idle_session(st, reason="idle")
assert released, "the sweeper did not release; this test proves nothing"
assert st.messages == []
assert _deep_size(st.messages) < 1000
def test_a_session_the_sweeper_declined_keeps_its_history(expired):
"""Dropping history for a session that was NOT released would discard a
live conversation's context mid-use. The clear belongs inside the branch
that actually stopped something, and a state with no worker is the case
that reaches `if not stopped: return False`."""
st = _session_with_history(expired)
before = len(st.messages)
released = expired._release_idle_session(st, reason="idle")
assert not released
assert len(st.messages) == before
def test_the_history_comes_back_from_the_store(expired):
"""What makes dropping it safe rather than lossy: the store is the
canonical provider history, and reseeding is the same path a daemon restart
takes. Without this, the fix would be data loss with a comment."""
st = _session_with_history(expired)
_boot_python_kernel(st)
assert expired._release_idle_session(st, reason="idle")
assert st.messages == []
expired._seed_messages(st)
assert st.messages, "a released session could not rebuild its history"
assert st.messages[0]["role"] == "system"
def test_the_session_keeps_the_settings_that_only_live_in_memory(expired):
"""The reason this drops the history rather than the whole SessionState.
The model override, plan/explore flags and pinned environment exist nowhere
else; evicting the state would silently reset a user's choices."""
st = _session_with_history(expired)
_boot_python_kernel(st)
st.model = "claude-opus-5"
st.plan = True
st.explore = True
st.desired_env = "protein"
assert expired._release_idle_session(st, reason="idle")
assert st.model == "claude-opus-5"
assert st.plan is True and st.explore is True
assert st.desired_env == "protein"