Merge pull request #6096 from nesquena/stage/6083-brick
Release exp: fix new-conversations-unstartable brick + eviction leak bound (#6083)
This commit is contained in:
commit
57e8aafb16
6 changed files with 197 additions and 20 deletions
|
|
@ -67,6 +67,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- **New conversations can no longer become unstartable seconds after you create them.** On installs with a saved browser/password-manager credential and more than a few hundred sessions, the manager would autofill the account name into the sidebar conversation filter, silently firing a content search that walked every session through the cache and evicted the brand-new, not-yet-persisted session (its only copy lived in the in-memory cache) — so `/api/session/draft` and `/api/chat/start` then 404'd and the chat could never be started. Two fixes: the search/filter inputs are now `type="search"` with per-manager opt-out attributes and the Settings + onboarding password fields get a real `<form>` owner (`display:contents`, layout unchanged) so managers stop treating the sidebar filter as a username field; and `_session_is_evictable()` now proves persistence before evicting even a zero-message session, so an unsaved session you're still composing stays resident. Thanks @ai-ag2026. (#6083, #4765)
|
||||
|
||||
- **Live reasoning no longer drops or double-renders when the turn anchor defers a paint.** On the streaming reasoning render, when the assistant-turn anchor accepted a reasoning event but deferred actually painting it, the reasoning text could go missing — and earlier attempts to fix that could briefly show the same reasoning under two owners or, with multiple reasoning segments, update the wrong row. The fallback now matches the exact anchor row identity (`run_id:seq`), so the right segment updates in place, other rows are untouched, and no duplicate owner appears — across Transparent, Compact, and hide-all modes. Thanks @franksong2702. (#6048, #5720)
|
||||
|
||||
- **Deleting a session now cleans up its delegated subagent sessions too (no orphaned children).** When you delete a parent session, the delegated/subagent sessions it spawned (and their on-disk transcript artifacts) are now cascaded and removed, including Agent-migrated legacy delegates — while unrelated sessions, sibling branches, and any still-live child are never touched. The cascade fails closed on any uncertainty (can't confirm liveness, missing/unqueryable state.db, or no cross-process lock primitive → it skips destructive cleanup rather than risk over-deleting), cross-process cleanup is serialized with a file lock so two WebUI workers can't race, and a partial cleanup is reported honestly instead of shown as full success. Thanks @pxxD1998. (#5926, #5307)
|
||||
|
|
|
|||
|
|
@ -4043,6 +4043,35 @@ def _persisted_session_meta_prefix(sid) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
def _session_sidecar_exists(sid) -> bool | None:
|
||||
"""Return whether *sid*'s sidecar file exists on disk.
|
||||
|
||||
True = the sidecar is confirmed present.
|
||||
False = the sidecar is confirmed absent (a truly never-persisted session).
|
||||
None = existence is indeterminate (unsafe id, or the stat raised).
|
||||
|
||||
``_session_is_evictable`` uses this to distinguish a genuinely
|
||||
never-persisted empty shell (safe to grace-evict once abandoned) from a
|
||||
session whose count merely could not be read this pass (stay resident).
|
||||
"""
|
||||
if not is_safe_session_id(sid):
|
||||
return None
|
||||
try:
|
||||
return (SESSION_DIR / f'{sid}.json').exists()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
# Grace window (seconds) during which a never-persisted, empty, draftless session
|
||||
# shell is protected from LRU eviction. new_session() defers the first disk write
|
||||
# only in the SESSIONS cache; evicting it there permanently 404s the chat (#6083).
|
||||
# After this window a still-empty, still-draftless, never-saved shell is treated as
|
||||
# abandoned and becomes evictable again, so these shells can't grow unbounded past
|
||||
# the cache cap. Generous (30 min) so a user composing slowly is never dropped;
|
||||
# a real draft persists to disk on the first keystroke and is reloadable anyway.
|
||||
_UNSAVED_SHELL_GRACE_S = 1800
|
||||
|
||||
|
||||
def _session_is_evictable(s) -> bool:
|
||||
"""Return True only when *s* can be safely dropped from the LRU (#4765).
|
||||
|
||||
|
|
@ -4056,10 +4085,30 @@ def _session_is_evictable(s) -> bool:
|
|||
on-disk ``message_count`` being at least the in-memory message count.
|
||||
A metadata-only stub is inherently backed by disk, so it is evictable.
|
||||
|
||||
Anything we cannot positively prove is safe stays resident. Using slightly
|
||||
more RAM for a session we are unsure about is strictly better than evicting
|
||||
an active or unsaved session (task safety invariant: a half-done memory fix
|
||||
that loses a session is worse than none).
|
||||
The persistence requirement holds even for a session with ZERO messages,
|
||||
but only for a bounded grace window. ``new_session()`` deliberately does not
|
||||
touch disk until the first message (#1171), so between "New Conversation" and
|
||||
the first send this cache is the session's ONLY copy. Evicting it there
|
||||
discards an un-recreatable shell: ``get_session()`` has no recreate path and
|
||||
raises ``KeyError``, so the very next ``/api/session/draft`` or
|
||||
``/api/chat/start`` 404s and the session can never be started (#6083). We
|
||||
therefore protect a never-persisted empty shell while it is fresh (the user
|
||||
just opened it and is composing) OR while it has an active composer draft.
|
||||
|
||||
A never-persisted empty shell that is BOTH stale (older than
|
||||
``_UNSAVED_SHELL_GRACE_S``) AND draftless is treated as an abandoned
|
||||
"New Conversation" tab the user opened and walked away from — it becomes
|
||||
evictable again so ``sessions_cache_max`` still bounds these shells and they
|
||||
cannot accumulate without limit (a slow leak / OOM on installs that open many
|
||||
empty chats). Anything the user is actually composing persists a draft via
|
||||
``s.save()`` on the first keystroke, so it is disk-backed and reloadable well
|
||||
before the grace window expires; the window only covers the empty-and-untouched
|
||||
gap right after "New Conversation".
|
||||
|
||||
Anything else we cannot positively prove is safe stays resident. Using
|
||||
slightly more RAM for a session we are unsure about is strictly better than
|
||||
evicting an active or unsaved session (task safety invariant: a half-done
|
||||
memory fix that loses a session is worse than none).
|
||||
"""
|
||||
if s is None:
|
||||
return True # nothing to protect; let the caller drop it
|
||||
|
|
@ -4077,14 +4126,37 @@ def _session_is_evictable(s) -> bool:
|
|||
if getattr(s, '_loaded_metadata_only', False):
|
||||
return True
|
||||
in_memory_count = len(getattr(s, 'messages', None) or [])
|
||||
if in_memory_count == 0:
|
||||
# A zero-message session has nothing to lose. If it was never persisted
|
||||
# (brand new, no sidecar) dropping it only discards an empty shell; the
|
||||
# next access recreates it. If it is persisted, it is trivially clean.
|
||||
return True
|
||||
disk_count = _persisted_message_count(sid)
|
||||
if disk_count is None:
|
||||
return False # cannot prove it is on disk → keep it resident
|
||||
# disk_count is None for TWO distinct reasons: the sidecar is confirmed
|
||||
# absent (truly never persisted → this cache is the only copy), OR the
|
||||
# sidecar exists but its count could not be read this pass (transient I/O,
|
||||
# mid-write). Only the CONFIRMED-ABSENT case is eligible for grace-based
|
||||
# shell eviction; an indeterminate existing-sidecar session stays resident
|
||||
# (conservative — never grace-evict something we cannot prove is gone).
|
||||
if in_memory_count > 0:
|
||||
return False # holds unsaved messages → never drop
|
||||
composer_draft = getattr(s, 'composer_draft', None)
|
||||
if composer_draft:
|
||||
return False # user is composing (draft present) → keep resident
|
||||
if _session_sidecar_exists(sid) is not False:
|
||||
# Sidecar present or existence indeterminate → not a never-persisted
|
||||
# shell; do not enter the abandoned-shell grace path.
|
||||
return False
|
||||
# Confirmed never-persisted empty draftless shell. Protect it while fresh
|
||||
# (the compose window right after "New Conversation"); once stale it is an
|
||||
# abandoned tab and becomes evictable so these shells cannot accumulate
|
||||
# unbounded past the cache cap (#6083 follow-up).
|
||||
created_at = getattr(s, 'created_at', None)
|
||||
if isinstance(created_at, (int, float)):
|
||||
if (time.time() - created_at) <= _UNSAVED_SHELL_GRACE_S:
|
||||
return False # fresh empty shell → protect the compose window
|
||||
return True # stale, empty, draftless, never-saved → abandoned, evictable
|
||||
# No usable created_at timestamp → be conservative, keep it resident.
|
||||
return False
|
||||
if in_memory_count == 0:
|
||||
# Persisted and empty → trivially clean, nothing to lose.
|
||||
return True
|
||||
return disk_count >= in_memory_count
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
|
||||
<div class="session-search sidebar-search"><div class="session-search-field"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="sessionSearch" type="search" placeholder="Filter conversations..." data-i18n-placeholder="filter_conversations" oninput="filterSessions()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"><button type="button" id="sessionSearchClear" class="session-search-clear has-tooltip has-tooltip--bottom-right" aria-label="Clear conversation filter" data-tooltip="Clear conversation filter" onclick="clearSessionSearch()" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div></div>
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
</div>
|
||||
<!-- Tasks (cron) panel -->
|
||||
|
|
@ -225,7 +225,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="kanban-filter-stack">
|
||||
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()"></div>
|
||||
<div class="sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="kanbanSearch" type="search" placeholder="Search tasks" data-i18n-placeholder="kanban_search_tasks" oninput="filterKanban()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
|
||||
<select id="kanbanAssigneeFilter" onchange="loadKanban(true)" aria-label="Assignee filter"></select>
|
||||
<select id="kanbanTenantFilter" onchange="loadKanban(true)" aria-label="Tenant filter"></select>
|
||||
<label class="kanban-check"><input id="kanbanIncludeArchived" type="checkbox" onchange="loadKanban(true)"> <span data-i18n="kanban_include_archived">Include archived</span></label>
|
||||
|
|
@ -253,7 +253,7 @@
|
|||
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="openSkillCreate()" data-tooltip="New skill" data-i18n-title="new_skill" aria-label="New skill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()"></div>
|
||||
<div class="skills-search sidebar-search"><svg class="sidebar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg><input id="skillsSearch" type="search" placeholder="Search skills..." data-i18n-placeholder="search_skills" oninput="filterSkills()" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other"></div>
|
||||
<div class="skills-list" id="skillsList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
|
||||
</div>
|
||||
<!-- Memory panel -->
|
||||
|
|
@ -353,8 +353,9 @@
|
|||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input id="settingsSearch" data-i18n-placeholder="settings_search_placeholder"
|
||||
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
|
||||
<input id="settingsSearch" type="search" data-i18n-placeholder="settings_search_placeholder"
|
||||
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off"
|
||||
data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other">
|
||||
<div id="settingsSearchResults" class="settings-search-results" style="display:none"></div>
|
||||
</div>
|
||||
<div class="settings-menu-items">
|
||||
|
|
@ -1541,11 +1542,20 @@
|
|||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<div id="settingsAuthStatus" style="margin-bottom:8px;display:none"></div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<!-- These password inputs need an explicit <form> owner. Without one they are
|
||||
"unowned" fields, so Chromium groups every field in the document into a
|
||||
single synthetic password form and picks the first text input in the DOM
|
||||
(the sidebar conversation filter) as the form's username field — which it
|
||||
then autofills with the saved account name. display:contents keeps the
|
||||
layout byte-identical; onsubmit is neutralised because the panel saves via
|
||||
JS, not a form submit. -->
|
||||
<form id="settingsPasswordForm" style="display:contents" autocomplete="off" onsubmit="return false">
|
||||
<input type="password" id="settingsPassword" autocomplete="new-password" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<div id="settingsCurrentPasswordBlock" style="display:none;margin-top:6px">
|
||||
<label for="settingsCurrentPassword" style="font-size:11px;display:block;margin-bottom:4px" data-i18n="current_password_label">Current Password</label>
|
||||
<input type="password" id="settingsCurrentPassword" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<input type="password" id="settingsCurrentPassword" autocomplete="current-password" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
</form>
|
||||
<div id="settingsPasswordEnvLock" data-i18n="password_env_var_locked" style="display:none;margin-top:6px;padding:8px 10px;font-size:11px;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;line-height:1.45">The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.</div>
|
||||
</div>
|
||||
<div id="settingsAuthDisabledWarning" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid rgba(220,80,80,.35);background:rgba(220,80,80,.08)">
|
||||
|
|
|
|||
|
|
@ -197,7 +197,11 @@ function _renderOnboardingApiKeyField(){
|
|||
const labelKey=keyOptional?'onboarding_api_key_label_optional':'onboarding_api_key_label';
|
||||
const placeholderKey=keyOptional?'onboarding_api_key_placeholder_optional':'onboarding_api_key_placeholder';
|
||||
const helpHtml=keyOptional?`<p class="onboarding-copy onboarding-api-key-help">${esc(t('onboarding_api_key_help_keyless')||'')}</p>`:'';
|
||||
return `<label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label>${helpHtml}`;
|
||||
// The <form> owner is load-bearing, not decoration: an unowned password input
|
||||
// makes Chromium treat the whole document as one synthetic password form and
|
||||
// autofill the saved account name into the first text input it finds (the
|
||||
// sidebar conversation filter). display:contents keeps the layout unchanged.
|
||||
return `<form style="display:contents" autocomplete="off" onsubmit="return false"><label class="onboarding-field" id="onboardingApiKeyField"><span>${t(labelKey)}</span><input id="onboardingApiKeyInput" type="password" autocomplete="off" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t(placeholderKey)}" oninput="ONBOARDING.form.apiKey=this.value" onblur="_runOnboardingProbe()"></label></form>${helpHtml}`;
|
||||
}
|
||||
|
||||
function _getOnboardingSelectedModel(){
|
||||
|
|
@ -392,11 +396,15 @@ function _renderOnboardingBody(){
|
|||
|
||||
if(key==='password'){
|
||||
_setOnboardingNotice(settings.password_enabled?t('onboarding_notice_password_enabled'):t('onboarding_notice_password_recommended'), settings.password_enabled?'success':'info');
|
||||
// See the api-key field above: the <form> owner keeps this password input from
|
||||
// turning the whole document into one synthetic credential form.
|
||||
body.innerHTML=`
|
||||
<form style="display:contents" autocomplete="off" onsubmit="return false">
|
||||
<label class="onboarding-field">
|
||||
<span>${t('onboarding_password_label')}</span>
|
||||
<input id="onboardingPasswordInput" type="password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
<input id="onboardingPasswordInput" type="password" autocomplete="new-password" value="${esc(ONBOARDING.form.password||'')}" placeholder="${t('onboarding_password_placeholder')}" oninput="ONBOARDING.form.password=this.value">
|
||||
</label>
|
||||
</form>
|
||||
<p class="onboarding-copy">${t('onboarding_password_help')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1565,7 +1565,12 @@
|
|||
.session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;}
|
||||
.sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;}
|
||||
.session-search-field{position:relative;display:flex;align-items:center;width:100%;}
|
||||
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;}
|
||||
.sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;-webkit-appearance:none;appearance:none;}
|
||||
/* The search inputs are type="search" so password managers don't mistake them
|
||||
for username fields. Suppress WebKit's native clear affordance: these fields
|
||||
ship their own clear button (e.g. #sessionSearchClear). */
|
||||
.sidebar-search input::-webkit-search-cancel-button,
|
||||
.sidebar-search input::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none;}
|
||||
.session-search input{padding-right:34px;}
|
||||
.sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
|
||||
.sidebar-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||
|
|
|
|||
|
|
@ -333,3 +333,83 @@ def test_eviction_skips_active_but_still_bounds_clean_entries(isolated_session_e
|
|||
# It may briefly sit slightly above cap because actives are non-evictable,
|
||||
# but it must NOT grow unbounded with the 40 churned sessions.
|
||||
assert len(SESSIONS) <= _cfg.SESSIONS_MAX + len(actives)
|
||||
|
||||
|
||||
def test_unsaved_new_session_survives_churn_and_stays_startable(isolated_session_env):
|
||||
"""A brand-new, never-persisted session must not be evicted.
|
||||
|
||||
``new_session()`` keeps a session in RAM only until its first message
|
||||
(#1171), so the cache is its ONLY copy. The original #4765 predicate treated
|
||||
any zero-message session as evictable, reasoning that an empty shell "is
|
||||
recreated on next access" — but ``get_session()`` has no recreate path and
|
||||
raises ``KeyError``, so ``/api/session/draft`` and ``/api/chat/start`` both
|
||||
404 and the session can never be started.
|
||||
|
||||
Real-world trigger: a browser password manager autofilled the sidebar
|
||||
conversation filter, whose content search pulls every hit through
|
||||
``get_session()``. That churn blew past the cap and dropped the session the
|
||||
user was composing in.
|
||||
"""
|
||||
from api import config as _cfg
|
||||
from api.config import SESSIONS
|
||||
from api.models import get_session, new_session
|
||||
|
||||
_cfg.SESSIONS_MAX = 5
|
||||
|
||||
composing = new_session()
|
||||
sid = composing.session_id
|
||||
assert not (_cfg.SESSION_DIR / f"{sid}.json").exists(), (
|
||||
"precondition: new_session() must not persist before the first message"
|
||||
)
|
||||
|
||||
# Content-search-style churn: far more persisted sessions than the cap.
|
||||
for i in range(40):
|
||||
_insert(_make_persisted_session(i))
|
||||
|
||||
assert sid in SESSIONS, "unsaved new session was evicted — its only copy is gone"
|
||||
|
||||
# The chokepoint both failing routes go through.
|
||||
assert get_session(sid, metadata_only=True) is not None
|
||||
assert get_session(sid).session_id == sid
|
||||
|
||||
|
||||
def test_stale_draftless_unsaved_shell_is_evictable(isolated_session_env):
|
||||
"""An OLD, empty, draftless, never-saved shell must NOT be immortal (#6083 follow-up).
|
||||
|
||||
The #6083 fix protects a fresh unsaved shell so a just-opened "New
|
||||
Conversation" is not evicted mid-compose. But protecting EVERY zero-message
|
||||
never-saved shell forever would let abandoned "New Conversation" tabs
|
||||
accumulate past ``sessions_cache_max`` without bound (a slow leak / OOM).
|
||||
A shell that is empty AND draftless AND older than the grace window is
|
||||
treated as abandoned and becomes evictable again.
|
||||
"""
|
||||
from api.models import _session_is_evictable, _UNSAVED_SHELL_GRACE_S, new_session
|
||||
|
||||
shell = new_session()
|
||||
# Freshly created → protected (inside the grace window).
|
||||
assert _session_is_evictable(shell) is False, (
|
||||
"a fresh empty shell must be protected during the compose window"
|
||||
)
|
||||
# Age it past the grace window with no draft and no messages → abandoned.
|
||||
shell.created_at = time.time() - (_UNSAVED_SHELL_GRACE_S + 60)
|
||||
assert _session_is_evictable(shell) is True, (
|
||||
"a stale, empty, draftless, never-saved shell must be evictable so these "
|
||||
"shells cannot accumulate unbounded past the cache cap"
|
||||
)
|
||||
|
||||
|
||||
def test_stale_unsaved_shell_with_draft_stays_resident(isolated_session_env):
|
||||
"""A stale shell the user is still composing (has a draft) stays protected.
|
||||
|
||||
Even past the grace window, a never-saved shell that carries a composer
|
||||
draft is something the user is actively working on and must not be dropped —
|
||||
its draft lives only in this cache entry until the first send.
|
||||
"""
|
||||
from api.models import _session_is_evictable, _UNSAVED_SHELL_GRACE_S, new_session
|
||||
|
||||
shell = new_session()
|
||||
shell.created_at = time.time() - (_UNSAVED_SHELL_GRACE_S + 60)
|
||||
shell.composer_draft = {"text": "half-written thought", "files": []}
|
||||
assert _session_is_evictable(shell) is False, (
|
||||
"a stale shell with an active composer draft must stay resident"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue