Skip to content

feat(tui_gateway): async-side shared heavy-read bound for session.list (seam a) - #215

Merged
Kyzcreig merged 1 commit into
mainfrom
wt/sl-phase2
Jul 7, 2026
Merged

Kyzcreig merged 1 commit into
mainfrom
wt/sl-phase2

Conversation

@Kyzcreig

@Kyzcreig Kyzcreig commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Phase 2 of docs/desktop/2026-07-05-session-list-loop-starvation-PRD.md (D-4/D-5/INV-4/INV-6). Shared per-loop async SessionDB heavy-read gate (default 2, config dashboard.heavy_read_max_concurrency, lazy/loop-bound NOT import-frozen). REST + WS list_sessions_rich (and siblings) acquire the SAME semaphore async-side BEFORE asyncio.to_thread dispatch (seam a — not inside the worker pool, so cheap ws ops never starve). Saturation: bounded wait + queue_wait log + shed stats + retryable backend_busy (503/JSON-RPC). Dashboard status exposes session_db_heavy_reads stats. 349 gateway tests green (Apollo re-ran per-file runner). Reviewed by Apollo. Live K=8 incident-regime certify = Apollo post-merge.

Share a loop-local async SessionDB heavy-read gate between dashboard REST reads and WebSocket session-list RPCs. The gate reads dashboard.heavy_read_max_concurrency lazily, queues with a bounded wait, logs queue_wait, exposes shed stats, and returns retryable backend-busy errors on saturation.\n\nVerified:\n- scripts/run_tests.sh tests/test_web_server_sessiondb_eventloop.py -v (14 passed)\n- scripts/run_tests.sh -j 16 tests/tui_gateway tests/test_tui_gateway*.py tests/test_web_server*.py tests/hermes_cli/test_web_server*.py (1133 passed)\n- git diff --check\n- /Users/alexgierczyk/.hermes/hermes-agent/venv/bin/python -m py_compile hermes_cli/session_db_heavy_gate.py hermes_cli/web_server.py tui_gateway/server.py tui_gateway/ws.py\n\nNot pushed.
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a shared async gate for heavy SessionDB reads in the gateway. The main changes are:

  • A loop-local SessionDB heavy-read semaphore with busy responses and stats.
  • REST heavy reads routed through the shared gate.
  • Websocket SessionDB-heavy methods routed through the same gate before worker dispatch.
  • A dashboard config default and status field for heavy-read concurrency.
  • Tests for shared REST/WS bounding, shedding, and queued websocket behavior.

Confidence Score: 4/5

The shared gate needs fixes around semaphore replacement and websocket executor isolation before merging.

  • A config change can let old and new semaphores admit heavy reads at the same time.
  • Gated websocket long handlers now run in the default executor instead of the gateway RPC pool.
  • Status polling can add synchronous config reads to the event loop.

hermes_cli/session_db_heavy_gate.py, tui_gateway/ws.py, tui_gateway/server.py

Important Files Changed

Filename Overview
hermes_cli/config.py Adds the default dashboard heavy-read concurrency setting.
hermes_cli/session_db_heavy_gate.py Adds the shared gate, busy error, stats, and test reset helper; config reload and status stats need attention.
hermes_cli/web_server.py Routes REST heavy SessionDB reads through the new gate and exposes gate stats in status.
tui_gateway/server.py Adds heavy-method classification, busy JSON-RPC errors, and a bound synchronous request helper.
tui_gateway/ws.py Routes selected websocket SessionDB-heavy methods through the shared gate before running handlers.
tests/test_web_server_sessiondb_eventloop.py Adds tests for shared bounding, queue shedding, lazy sizing, and websocket non-starvation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant REST as REST endpoint
  participant WS as WS endpoint
  participant Gate as Shared heavy-read gate
  participant Exec as Worker thread
  participant DB as SessionDB

  REST->>Gate: request heavy read slot
  WS->>Gate: request heavy read slot
  alt slot acquired
    Gate->>Exec: run blocking read
    Exec->>DB: scan sessions
    DB-->>Exec: result
    Exec-->>Gate: return
    Gate-->>REST: response
    Gate-->>WS: JSON-RPC response
  else wait timeout
    Gate-->>REST: 503 backend_busy
    Gate-->>WS: JSON-RPC backend_busy
  end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant REST as REST endpoint
  participant WS as WS endpoint
  participant Gate as Shared heavy-read gate
  participant Exec as Worker thread
  participant DB as SessionDB

  REST->>Gate: request heavy read slot
  WS->>Gate: request heavy read slot
  alt slot acquired
    Gate->>Exec: run blocking read
    Exec->>DB: scan sessions
    DB-->>Exec: result
    Exec-->>Gate: return
    Gate-->>REST: response
    Gate-->>WS: JSON-RPC response
  else wait timeout
    Gate-->>REST: 503 backend_busy
    Gate-->>WS: JSON-RPC backend_busy
  end
Loading

Reviews (1): Last reviewed commit: "feat(tui_gateway): bound heavy session r..." | Re-trigger Greptile

Comment on lines +98 to +103
# drained. Replacing while permits are checked out would let old and
# new semaphores admit work simultaneously and temporarily exceed
# both bounds.
if getattr(existing_semaphore, "_value", 0) < existing_limit:
return existing_semaphore
semaphore = asyncio.Semaphore(limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Queued Waiters Cross Gate Swap

When the config value changes while tasks are already waiting on the old semaphore, _value can still look fully drained because queued acquire() calls have not decremented it yet. This replaces the loop entry with a new semaphore, so new requests can enter through the new gate while old waiters later enter through the old gate, temporarily exceeding the configured heavy-read bound.

Comment on lines +120 to +124
def session_db_heavy_read_stats() -> dict[str, float | int]:
with _STATS_LOCK:
stats = dict(_STATS)
stats["max_concurrency"] = _configured_max_concurrency()
stats["queue_timeout_seconds"] = _QUEUE_WAIT_TIMEOUT_S

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Status Polls Load Config

session_db_heavy_read_stats() now runs inside the async status endpoint, but it calls _configured_max_concurrency(), which synchronously loads dashboard config. Frequent status polling or a slow config filesystem can block the event loop and delay unrelated REST or websocket work.

Comment thread tui_gateway/ws.py
Comment on lines +298 to +303
try:
async with session_db_heavy_read_slot(
surface="ws",
operation=str(req_method),
):
return await asyncio.to_thread(server.handle_request_bound, req, transport)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Heavy Handlers Leave RPC Pool

For heavy methods that are also long handlers, this path bypasses server.dispatch() and runs the full handler in asyncio.to_thread() instead of the gateway RPC pool. With a small or shared default executor, two slow session.list or projects.tree scans can occupy the default executor and delay otherwise cheap websocket offloads.

Comment thread tui_gateway/server.py
Comment on lines +1298 to +1301
"""
t = transport or _stdio_transport
token = bind_transport(t)
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Handler Errors Become Dispatch Crashes

handle_request_bound() calls handle_request() without the exception mapping that dispatch() applies for long-handler workers. If a gated websocket handler raises, clients now receive a generic -32603 internal error from handle_ws instead of the previous handler error response, and telemetry counts it as a dispatch crash.

@Kyzcreig
Kyzcreig merged commit 66d98d3 into main Jul 7, 2026
35 checks passed
@Kyzcreig
Kyzcreig deleted the wt/sl-phase2 branch July 7, 2026 03:00
Kyzcreig added a commit that referenced this pull request Jul 7, 2026
Kyzcreig added a commit that referenced this pull request Jul 7, 2026
* Revert "fix(state): force recency backfill v3 (#216)"

This reverts commit cd05a73.

* Revert "feat(tui_gateway): bound heavy session reads (#215)"

This reverts commit 66d98d3.

* Revert "feat(state): denormalize session.list recency (effective_last_active + two-stage query) (#213)"

This reverts commit 5d67b3f.
Kyzcreig added a commit that referenced this pull request Sep 25, 2026
… 3 premises re-checked (t_2a1bd9cd)

#115 DROP->KEEP (swiftui-skills skill is live), #215/#441 DROP->UNRESOLVED
(tui_gateway/ws.py still consumes the gate; dashboard live), nopr:8a8b81638c
UPSTREAM->UNRESOLVED (leak needs the fork-only auto-attach detector).
Branches: 5 revert branches built + targeted pytest green; upstream-617 and
upstream-466 hand-ported onto upstream/main with tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant