Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2161,7 +2161,9 @@ def _record_polling_progress(self, generation: int) -> None:
return
self._polling_progress_event.set()
self._polling_network_error_count = 0
if generation == self._polling_conflict_recovery_generation:
# Bare/test adapters may not have run ``__init__``; treat missing as
# "no conflict recovery in flight" (same defensive shape as teardown).
if generation == getattr(self, "_polling_conflict_recovery_generation", None):
self._polling_conflict_recovery_generation = None
else:
self._polling_conflict_count = 0
Expand Down
102 changes: 102 additions & 0 deletions tests/tui_gateway/test_projects_session_limit_clamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Clamp projects.tree / projects.project_sessions session+preview limits."""

from __future__ import annotations

from tui_gateway import methods_config as cfg
from tui_gateway import server


def test_coerce_project_session_limit_bounds():
assert cfg._coerce_project_session_limit(-1, default=2000) == 1
assert cfg._coerce_project_session_limit(0, default=2000) == 1
assert cfg._coerce_project_session_limit(None, default=2000) == 2000
assert cfg._coerce_project_session_limit("", default=5000) == 5000
assert cfg._coerce_project_session_limit("nope", default=2000) == 2000
assert cfg._coerce_project_session_limit(50_000, default=2000) == 10_000
assert cfg._coerce_project_session_limit(3500, default=2000) == 3500


def test_coerce_project_preview_limit_bounds():
assert cfg._coerce_project_preview_limit(-5, default=3) == 0
assert cfg._coerce_project_preview_limit(0, default=3) == 0
assert cfg._coerce_project_preview_limit(None, default=3) == 3
assert cfg._coerce_project_preview_limit(999, default=3) == 50


def _patch_tree(monkeypatch, captured: dict):
monkeypatch.setattr(server, "_get_db", lambda: object())

def fake_build(db, **kwargs):
captured.update(kwargs)
return {"projects": [], "scoped_session_ids": []}, None

monkeypatch.setattr(server, "_build_project_tree", fake_build)


def test_projects_tree_clamps_negative_session_limit(monkeypatch):
captured: dict = {}
_patch_tree(monkeypatch, captured)
resp = server.handle_request(
{
"id": "1",
"method": "projects.tree",
"params": {"session_limit": -1, "preview_limit": -2},
}
)
assert "result" in resp
assert captured["session_limit"] == 1
assert captured["preview_limit"] == 0


def test_projects_tree_clamps_excessive_limits(monkeypatch):
captured: dict = {}
_patch_tree(monkeypatch, captured)
resp = server.handle_request(
{
"id": "1",
"method": "projects.tree",
"params": {"session_limit": 10_000_000, "preview_limit": 10_000},
}
)
assert "result" in resp
assert captured["session_limit"] == 10_000
assert captured["preview_limit"] == 50


def test_projects_tree_default_limits(monkeypatch):
captured: dict = {}
_patch_tree(monkeypatch, captured)
resp = server.handle_request({"id": "1", "method": "projects.tree", "params": {}})
assert "result" in resp
assert captured["session_limit"] == 2000
assert captured["preview_limit"] == 3


def test_projects_project_sessions_clamps_session_limit(monkeypatch):
captured: dict = {}
_patch_tree(monkeypatch, captured)
resp = server.handle_request(
{
"id": "1",
"method": "projects.project_sessions",
"params": {"project_id": "p1", "session_limit": -1},
}
)
assert "result" in resp
assert captured["session_limit"] == 1
assert captured["preview_limit"] == 0
assert captured["hydrate"] is True


def test_projects_project_sessions_default_session_limit(monkeypatch):
captured: dict = {}
_patch_tree(monkeypatch, captured)
resp = server.handle_request(
{
"id": "1",
"method": "projects.project_sessions",
"params": {"project_id": "p1"},
}
)
assert "result" in resp
assert captured["session_limit"] == 5000
43 changes: 40 additions & 3 deletions tui_gateway/methods_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,34 @@
method = _registry.method
_profile_scoped = _registry.profile_scoped

# Project-tree session fetches feed SQLite ``LIMIT``. Negative LIMIT in SQLite
# means *no bound*, and a hostile client can force a multi-thousand-row CTE
# join via an oversized positive limit — keep both paths bounded.
_PROJECT_SESSION_LIMIT_MAX = 10_000
_PROJECT_PREVIEW_LIMIT_MAX = 50


def _coerce_project_session_limit(raw, *, default: int) -> int:
try:
if raw is None or raw == "":
value = default
else:
value = int(raw)
except (TypeError, ValueError):
value = default
return max(1, min(value, _PROJECT_SESSION_LIMIT_MAX))


def _coerce_project_preview_limit(raw, *, default: int = 3) -> int:
try:
if raw is None or raw == "":
value = default
else:
value = int(raw)
except (TypeError, ValueError):
value = default
return max(0, min(value, _PROJECT_PREVIEW_LIMIT_MAX))


@method("projects.discover_repos")
def _(rid, params: dict) -> dict:
Expand Down Expand Up @@ -119,9 +147,9 @@ def _(rid, params: dict) -> dict:

tree, active_id = _build_project_tree(
db,
preview_limit=int(params.get("preview_limit") or 3),
preview_limit=_coerce_project_preview_limit(params.get("preview_limit"), default=3),
hydrate=False,
session_limit=int(params.get("session_limit") or 2000),
session_limit=_coerce_project_session_limit(params.get("session_limit"), default=2000),
include_discovered=True,
)
return _ok(
Expand Down Expand Up @@ -149,7 +177,10 @@ def _(rid, params: dict) -> dict:
# Drill-in only needs the entered project (which has sessions), so skip
# the zero-session discovery tier entirely.
tree, _active = _build_project_tree(
db, preview_limit=0, hydrate=True, session_limit=int(params.get("session_limit") or 5000),
db,
preview_limit=0,
hydrate=True,
session_limit=_coerce_project_session_limit(params.get("session_limit"), default=5000),
include_discovered=False,
)
proj = next((p for p in tree["projects"] if p["id"] == project_id), None)
Expand Down Expand Up @@ -419,4 +450,10 @@ def _(rid, params: dict) -> dict:

def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry."""
# Handlers are rebound to server globals (see method_ctx); expose the
# clamp helpers they call by bare name.
server._coerce_project_session_limit = _coerce_project_session_limit
server._coerce_project_preview_limit = _coerce_project_preview_limit
server._PROJECT_SESSION_LIMIT_MAX = _PROJECT_SESSION_LIMIT_MAX
server._PROJECT_PREVIEW_LIMIT_MAX = _PROJECT_PREVIEW_LIMIT_MAX
_registry.install(server)
Loading