perf(/api/session): six frontend callers omit msg_limit and pull the full transcript + full session tool_calls (10 s / 28 MB vs 7 ms / 80 KB bounded on a 5k-row session)
Summary
Six frontend code paths call GET /api/session?session_id=<sid> with no msg_limit (and no messages=0). On a long WebUI-native session that shape returns the entire display transcript plus the entire session-level tool_calls list, redacts all of it, and serializes it — ~10 s and a 28 MB body on the session below — while the bounded msg_limit=30 request against the same session is 7 ms / 80 KB. The uncapped shape is what fires on the paths that run most often on mobile: offline/bfcache recovery, stream_end settle, cancel sync.
The _MAX_MSG_LIMIT = 500 clamp in _parse_msg_limit() only applies when the client sends a limit; the no-limit path is documented as the intentional "full transcript for callers that need it" escape hatch, so it is uncapped by design. None of the six callers appear to actually need the full transcript.
This is the "which container is hot in redaction" discriminator requested in #7310, measured offline on exp-v0.52.291 against a real session. Related: #6491 (same symptom for foreign/CLI sessions — this one is WebUI-native), #6351, #7081.
Environment
- hermes-webui
exp-v0.52.291(master71689c6c), Python 3.11, Linux 6.8, self-hosted, single user, Tailscale-only access from Android Chrome - Session: WebUI-native, 5,003 display rows (state.db: 2,591 tool / 2,062 assistant / 359 user), transcript text ≈ 9.9 MB, sidecar 41 MB, previously manually compressed (anchor at row ~2139). Content redacted throughout; only sizes/timings below.
Access-log evidence (one day, one session)
Bare /api/session?session_id=<sid> — no msg_limit, no messages=0 — 16 hits, every one 5–16 s:
01:35:02 5466ms 01:39:58 5102ms 01:40:04 5737ms 02:05:57 6084ms
02:07:55 5997ms 02:14:35 5995ms 03:01:19 9089ms 03:44:41 16526ms
03:44:46 4812ms 03:48:55 5368ms 17:07:03 7751ms 18:03:21 7321ms
19:08:41 5025ms 19:13:33 6054ms 19:17:44 9257ms 20:42:19 5011msNote the pairs 6 s apart (01:39:58/01:40:04, 03:44:41/03:44:46): two recovery paths firing back-to-back on one reconnect. The msg_limit=30&expand_renderable=1 tail loads for the same session (31 that day) were all fast.
Matching slow-stage records:
[SLOW] session_id=<sid> get_session=4156.2ms model_resolve=0.0ms compact=1151.6ms redact=5071.0ms json_write=4008.0ms total=14386.9ms
[SLOW] session_id=<sid> get_session=41.4ms model_resolve=38.9ms compact=657.0ms redact=3886.8ms json_write=1460.6ms total=6084.7msOffline measurement of the route's stages (same session, isolated process)
Harness: get_session() → _merged_session_messages_for_display() → compact() | {"messages", "tool_calls"} → per-key redact_session_data() dispatch → json.dumps. Sizes are len(json.dumps(field)).
What enters redaction on the no-limit path, by top-level key:
| key | serialized | redact time |
|---|---|---|
messages |
23,559,317 B | 3,906 ms |
tool_calls (session-level) |
6,167,074 B | 1,053 ms |
| everything else combined | < 1 KB | ~0 ms |
So for #7310's decision tree: the hot frames are _redact_messages and _redact_tool_calls on the two transcript-shaped containers, not _copy_json_value on a nested compact-session field and not load_settings(). compression_anchor_details, context_engine_state, compression_recovery, gateway_routing_history are all 2 bytes here.
Whole-response cost, uncapped vs bounded, same session:
| request shape | redact | json_dumps | body | notes |
|---|---|---|---|---|
no msg_limit (5,003 rows + 6.2 MB tool_calls) |
10,190 ms | 331 ms | 28.0 MB | what the six callers get |
msg_limit=30, tool_calls windowed via _tool_calls_for_message_window (12 kept) |
7 ms | <1 ms | 0.08 MB | what the tail view gets |
msg_limit=30 but tool_calls not windowed |
1,973 ms | 72 ms | 6.2 MB | see second finding |
Redaction time scales linearly with bytes redacted (~2.4 s per 6 MB across all three rows), so this is not a pathological regex; it is just the volume.
Transient memory per uncapped request in this harness: +49 MB Session object, +36 MB redacted copy, +9 MB body (28 MB bytes) — peak 450 MB for one request. Two of these overlapping (the 6-second pairs above) on a ThreadingHTTPServer is ~1 GB of transient allocation, which on our 2.2 GB MemoryHigh cgroup was enough to push the process into mem_cgroup_handle_over_high throttling and a multi-hour silent stall (alive, 0 requests served, needed SIGKILL) — the symptom that led here.
Second finding: session-level tool_calls windowing is gated on msg_limit
In the route:
_windowed_messages = (load_messages and msg_limit is not None and (...))
...
if _windowed_messages:
_session_tool_calls = _tool_calls_for_message_window(_session_tool_calls, _messages_offset, len(_truncated_msgs))So any path that does not window messages also ships the whole session-level tool_calls list — 6.2 MB / 1 s on this session, and that list is a legacy structure the frontend only needs for rows lacking per-message tool_calls. It is a large fraction of the bounded-request cost whenever windowing is skipped.
The six callers (all static/, current master)
| file:line | path | trigger | needs full transcript? |
|---|---|---|---|
ui.js:10127 refreshSession() |
?session_id= |
_recoverFromOfflineSoftly() — every bfcache/offline/visibility recovery on mobile |
no — needs active_stream_id, counts, tail |
messages.js:6996 _restoreSettledSession() |
?session_id= |
stream_end without done, stream-end recovery timer |
no — settles pane on canonical tail |
messages.js:6906 |
?session_id= |
cancel sync after *Task cancelled.* |
no |
commands.js:968 |
?session_id= |
/compress preflight "does session still exist" |
no — could be messages=0 |
commands.js:1773 |
?session_id= |
after /undo — replaces S.messages wholesale |
arguably, but bounded + hasServerOlder would do |
sessions.js:3906, outline.js:115 |
&messages=1&resolve_model=0 |
load-older / outline jump | outline comment says it intentionally requests the full transcript |
refreshSession and _restoreSettledSession are the ones firing repeatedly in the log; they run on exactly the flaky-network / background-tab conditions where a 10 s request is most likely to overlap with itself.
Proposed fix (two independent halves)
Server (closes the class):
- Apply a default window when
msg_limitis absent andmessages=1: treat missing asmsg_limit=_MAX_MSG_LIMIT(500), set_messages_truncated/_messages_offsetas the existing clamp path already does, so the frontend's "Load earlier" gate works. Callers that genuinely need everything can page withmsg_before, which already exists and is already uncapped in its own read path. If an explicit escape hatch is wanted, make it opt-in (msg_limit=all) rather than the default. - Decouple
tool_callswindowing frommsg_limit is not None: window session-leveltool_callsto the returned message range whenever a message range is returned, including the no-limit case (where the range is the whole list, so behaviour is unchanged there, but the code path stops depending on the limit flag).
Client (removes the traffic):
3. Change refreshSession(), _restoreSettledSession(), cancel sync and the /compress preflight to the bounded shape (&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1, or messages=0 where only metadata is needed) and preserve hasServerOlder from _messages_truncated. The /undo and outline paths can decide whether they truly need the escape hatch.
(1) alone would have turned every 5–16 s request in the log above into a sub-100 ms one on this install, without any client change.
What I could not verify
- I did not run py-spy against the live process (ptrace_scope=1, no root); the per-key timing is from an isolated process running the same functions on the same store. It agrees with the in-process
[SLOW]redact times (3.9–5.1 s in the log vs 3.9 s formessagesalone here). - I have not confirmed which recovery caller produced each specific log line; the access log does not carry the client call site. The 6-second pairs are consistent with
_recoverFromOfflineSoftly→refreshSessionfollowed by stream reattach →_restoreSettledSession. - Not tested on a foreign/CLI session (that is #6491's territory).
Happy to test a patch against this session — it is a convenient worst case (5k rows, 41 MB sidecar) and I can re-run the harness before/after.
Source: nesquena/hermes-webui