Merge branch 'pr-5803' into release/stage-5803

This commit is contained in:
t committed 2026-07-09 06:05:44 +00:00
commit c81463a30d
3 files changed
+331 -29

No files matched your search

+126 -5
View File
@@ -3095,24 +3095,122 @@ def _cached_session_lags_disk(cached) -> bool:
id. Serving the cache then makes recent assistant results disappear from
GET /api/session even though disk and _index.json are correct. Compare only
cheap metadata here; full reload happens only if disk is strictly ahead.
perf(webui/session-load-latency) cheap-first ordering: the function used to
call Session.load_metadata_only(sid) on every cache hit, which parses the
full sidecar JSON (~15-20ms even for 1.3MB sidecars on Celeron+ eMMC).
For draft auto-saves that hit get_session() on every keystroke debounce
(every ~400ms while typing), that 15-20ms multiplied out to ~75% of the
request's wall time on the Chromebook. We now do a single fast check
first: read only the JSON metadata prefix to compare message counts.
The full Session.load_metadata_only() and its anchor-scene comparisons
only run when the count check is inconclusive or when disk appears to be
ahead of cache.
"""
if cached is None:
return False
sid = getattr(cached, 'session_id', None)
if not sid:
return False
cached_count = len(getattr(cached, 'messages', None) or [])
# Fast path: prefix read of just the metadata header.
disk_count = _persisted_message_count(sid)
if disk_count is not None:
if disk_count > cached_count:
return True
# Disk is at most as far as cache. Even when counts match, anchor scene
# records can advance independently (api/routes.py saves a session
# with `s.save(touch_updated_at=False, skip_index=True)` after editing
# only the scene dict; message_count is len(messages) so it stays the
# same). Greptile flagged this in PR review. Cheaply check the disk's
# scene records from the same prefix we already read.
cached_scenes = getattr(cached, 'anchor_activity_scenes', None) or {}
if not isinstance(cached_scenes, dict):
cached_scenes = {}
# Track whether the cheap scene check was inconclusive — when it is,
# we must fall through to the full metadata comparison instead of
# returning False for inactive sessions with matching counts.
_scene_check_inconclusive = False
if cached_scenes:
disk_meta_quick = _persisted_session_meta_prefix(sid)
if disk_meta_quick is not None:
disk_scenes = disk_meta_quick.get('anchor_activity_scenes') or {}
if not isinstance(disk_scenes, dict):
disk_scenes = {}
if disk_scenes:
# Directional: only reload when disk is strictly ahead
# of cache. Mirror master's subset comparison — cache
# that is ahead of disk must NOT force a reload, or
# un-persisted scene data is silently dropped.
disk_keys = {str(key) for key, value in disk_scenes.items()
if key and isinstance(value, dict)}
cached_keys = _anchor_scene_record_keys(cached)
if disk_keys and not disk_keys.issubset(cached_keys):
return True
# Same key set (or disk is subset): check the latest
# updated_at timestamp.
def _max_updated(records):
latest = 0.0
for record in records.values():
if not isinstance(record, dict):
continue
try:
ua = float(record.get('updated_at') or 0)
except (TypeError, ValueError):
ua = 0.0
if ua > latest:
latest = ua
return latest
if _max_updated(disk_scenes) > _anchor_scene_records_updated_at(cached):
return True
# disk has no scenes, cache does -> cache is ahead; keep it.
else:
# Can't cheaply verify scene freshness from disk. The prefix
# reader may have failed while _persisted_message_count
# succeeded via index fallback. Fall through to the full
# metadata load so we don't serve stale scenes on the next
# equal-count inactive session path. Greptile P1.
_scene_check_inconclusive = True
else:
# Cached session has no scene records. Check if disk has gained
# the first scene record — without this the fast-path would miss
# a newly persisted scene and return the stale cache. Greptile P1.
disk_meta_quick = _persisted_session_meta_prefix(sid)
if disk_meta_quick is not None:
disk_scenes = disk_meta_quick.get('anchor_activity_scenes') or {}
if isinstance(disk_scenes, dict) and disk_scenes:
return True
else:
# Prefix read failed (may still succeed via index fallback for
# message count). Mark inconclusive so we fall through to the
# full metadata comparison instead of returning False with
# stale cache. Greptile P1 (discussion_r3548650345).
_scene_check_inconclusive = True
if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None):
# Active session: messages may be in flight; fall through to the
# full check to be safe.
pass
elif _scene_check_inconclusive:
# Could not cheaply verify scene freshness from disk; fall through
# to the full metadata comparison rather than returning False
# (which would serve a potentially stale cache). Greptile P1.
pass
else:
# Inactive session, count matches, scene records match — cache is
# at parity with disk.
return False
try:
disk_meta = Session.load_metadata_only(sid)
except Exception:
return False
if disk_meta is None:
return False
cached_count = len(getattr(cached, 'messages', None) or [])
disk_count = _parse_nonnegative_int(getattr(disk_meta, '_metadata_message_count', None))
if disk_count is None:
disk_count = _lookup_index_message_count(sid)
if disk_count is not None and disk_count > cached_count:
return True
disk_count = _parse_nonnegative_int(getattr(disk_meta, '_metadata_message_count', None))
if disk_count is None:
disk_count = _lookup_index_message_count(sid)
if disk_count is not None and disk_count > cached_count:
return True
if not getattr(cached, 'active_stream_id', None) and not getattr(cached, 'pending_user_message', None):
cached_scene_keys = _anchor_scene_record_keys(cached)
disk_scene_keys = _anchor_scene_record_keys(disk_meta)
@@ -3153,6 +3251,29 @@ def _persisted_message_count(sid) -> int | None:
return _parse_nonnegative_int(_lookup_index_message_count(sid))
def _persisted_session_meta_prefix(sid) -> dict | None:
"""Return the parsed metadata prefix dict for *sid*, or None on error.
Used by ``_cached_session_lags_disk`` to compare additional fields (e.g.
anchor scene records) cheaply against the cached in-memory session without
paying for a full ``Session.load_metadata_only`` parse. Returns the same
shape as ``_persisted_message_count`` callers would expect — a dict that
only contains the metadata-prefix fields, NOT ``messages`` / ``tool_calls``.
"""
if not is_safe_session_id(sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
return None
try:
prefix = _read_metadata_json_prefix(p)
if not prefix:
return None
return json.loads(prefix)
except Exception:
return None
def _session_is_evictable(s) -> bool:
"""Return True only when *s* can be safely dropped from the LRU (#4765).
+35 -9
View File
@@ -106,11 +106,22 @@ class RequestDiagnostics:
logger: logging.Logger | None = None,
timeout_seconds: float | None = None,
auto_start: bool = True,
print_fn: "callable | None" = None,
) -> None:
self.request_id = uuid.uuid4().hex[:10]
self.method = str(method or "-")
self.path = str(path or "-").split("?", 1)[0]
self.logger = logger or logging.getLogger(__name__)
# perf(webui/session-load-latency) tier2c: when the WebUI process
# boots without configuring a root-logger handler, logger.warning
# calls are silently dropped (the [SLOW] / "Slow WebUI request"
# lines have been invisible in production for some time — only the
# per-request ms line, which goes through _safe_webui_print, kept
# working). Callers that need the structured log to actually land
# in the systemd journal can pass print_fn=handler._safe_webui_print.
# When provided, finish() and _on_timeout() route through it
# instead of self.logger.warning.
self._print_fn = print_fn
self.timeout_seconds = _slow_request_seconds() if timeout_seconds is None else max(0.0, float(timeout_seconds))
self.started_monotonic = time.monotonic()
self.started_wall = time.time()
@@ -134,6 +145,7 @@ class RequestDiagnostics:
path: str,
*,
logger: logging.Logger | None = None,
print_fn: "callable | None" = None,
) -> "RequestDiagnostics | None":
clean_path = str(path or "").split("?", 1)[0]
if (method.upper(), clean_path) not in {
@@ -144,9 +156,17 @@ class RequestDiagnostics:
# of the multi-second waterfalls in the slow-request logs.
("GET", "/api/profiles"),
("GET", "/api/models"),
# perf(webui/session-load-latency) tier2c: /api/session is the
# chat-open hot path. The handler also calls maybe_start() and
# finish() at its _t0/_t6 boundaries, so adding this entry to
# the allowlist is NOT a dead-code addition (the Greptile P1
# trap for previous similar entries): without the handler call
# the watchdog entry would never register and the structured
# log would never fire.
("GET", "/api/session"),
}:
return None
return cls(method, clean_path, logger=logger)
return cls(method, clean_path, logger=logger, print_fn=print_fn)
def stage(self, name: str) -> None:
now = time.monotonic()
@@ -163,6 +183,18 @@ class RequestDiagnostics:
self._current_stage = clean
self._current_stage_started = now
def _emit_slow(self, prefix: str, record: dict) -> None:
payload = json.dumps(record, sort_keys=True)
log_msg = f"{prefix} %s"
if self._print_fn is not None:
try:
self._print_fn(log_msg % payload)
except Exception:
# print_fn is best-effort; never break a request thread.
pass
else:
self.logger.warning(log_msg, payload)
def finish(self) -> None:
record = None
with self._lock:
@@ -175,10 +207,7 @@ class RequestDiagnostics:
# bounded by the number of in-flight requests).
_watchdog_unregister(self.request_id)
if record and self.timeout_seconds > 0 and record["elapsed_ms"] >= self.timeout_seconds * 1000:
self.logger.warning(
"Slow WebUI request completed: %s",
json.dumps(record, sort_keys=True),
)
self._emit_slow("Slow WebUI request completed:", record)
def _on_timeout(self) -> None:
with self._lock:
@@ -186,10 +215,7 @@ class RequestDiagnostics:
return
self._watchdog_logged = True
record = self._build_record_locked(include_stacks=True)
self.logger.warning(
"Slow WebUI request still running: %s",
json.dumps(record, sort_keys=True),
)
self._emit_slow("Slow WebUI request still running:", record)
def _build_record_locked(self, *, include_stacks: bool) -> dict[str, Any]:
now = time.monotonic()
+170 -15
View File
@@ -6262,6 +6262,15 @@ def _read_profile_model_config(
does not override the session provider. ``profile_default_model`` is still
returned for suffix repair (#5127) only when the profile's configured
provider matches ``requested_provider`` after normalization.
perf(webui/session-load-latency) tier2a: the parse is wrapped in a
per-process LRU keyed by (profile_name, config_mtime, size). The
function fires on every chat-open for sessions under a named
profile (resolve_model=1 path), and the YAML parse alone is
hundreds of µs to single-digit ms on the Chromebook. Cache TTL
60s is a backstop in case mtime resolution is poor on a given
filesystem; under normal edits the mtime changes and invalidates
immediately.
"""
if not getattr(session, "profile", None):
return None, None, None
@@ -6269,15 +6278,13 @@ def _read_profile_model_config(
try:
from api.profiles import get_hermes_home_for_profile
_profile_home = get_hermes_home_for_profile(session.profile)
_profile_name = str(session.profile or "")
_profile_home = get_hermes_home_for_profile(_profile_name)
_profile_cfg_path = os.path.join(str(_profile_home), "config.yaml")
if not os.path.isfile(_profile_cfg_path):
return None, None, None
import yaml
with open(_profile_cfg_path, encoding="utf-8") as _f:
_pcfg = yaml.safe_load(_f) or {}
if not isinstance(_pcfg, dict):
_pcfg = _read_profile_config_cached(_profile_name, _profile_cfg_path)
if _pcfg is None:
return None, None, None
_model_cfg = _pcfg.get("model") or {}
if not isinstance(_model_cfg, dict):
@@ -6301,6 +6308,89 @@ def _read_profile_model_config(
return _provider, _default, _pcfg
# perf(webui/session-load-latency) tier2a: process-wide cache for parsed
# profile config.yaml. Key = (profile_name, inode, mtime, size); value = parsed
# dict. inode tracks atomic-rename edits (most editors replace files, giving a
# new inode on Linux). mtime+size auto-invalidates on in-place edits; a 60s TTL
# is the backstop in case of coarse mtime resolution (some network filesystems
# round mtime to whole seconds — the size guard catches a write of equal-length
# bytes within the same second). On cache hit, a full-content comparison catches
# any in-place rewrite that inode+mtime+size missed (Greptile P1, PR#5803
# discussion_r3548477915). Reading and comparing the full file content (~1-10KB)
# is much cheaper than yaml.safe_load(). Reads are guarded by a single Lock to
# keep the hot path simple; the underlying yaml.safe_load is the slow step, not
# the lock, so contention is bounded.
_PROFILE_CONFIG_CACHE: "dict[tuple, tuple[float, str, dict]]" = {}
_PROFILE_CONFIG_CACHE_TTL_SECONDS = 60.0
_PROFILE_CONFIG_CACHE_LOCK = threading.Lock()
def _read_profile_config_cached(profile_name: str, cfg_path: str) -> dict | None:
"""Return parsed profile config, caching by (inode, mtime, size) with
TTL backstop and full-content verification.
The full-content comparison reads the current file and compares it to a
copy stored in the cache entry. This catches any in-place rewrite where
inode+mtime+size are identical, regardless of where in the file the change
occurs — unlike a fixed-length prefix comparison, edits to fields after the
first N characters are always detected. Reading and comparing the full file
content (~1-10KB for a typical config.yaml) is much cheaper than
yaml.safe_load().
NOTE: The cache key uses inode+mtime+size to handle the common cases
(atomic-rename editors -> new inode; in-place editors -> mtime/size
change). The full-content comparison is a backstop for the rare case where
all three collide (e.g., sed -i on a filesystem with coarse mtime
resolution, writing the same byte count).
"""
try:
st = os.stat(cfg_path)
except OSError:
return None
mtime = float(getattr(st, "st_mtime", 0.0) or 0.0)
size = int(getattr(st, "st_size", 0) or 0)
inode = int(getattr(st, "st_ino", 0) or 0)
key = (str(profile_name or ""), inode, mtime, size)
now = time.monotonic()
with _PROFILE_CONFIG_CACHE_LOCK:
cached = _PROFILE_CONFIG_CACHE.get(key)
if cached is not None:
cached_at, cached_content, cached_dict = cached
if (now - cached_at) <= _PROFILE_CONFIG_CACHE_TTL_SECONDS:
# Full content comparison catches any in-place rewrite where
# inode+mtime+size are identical but the file content changed.
# Reading and comparing the full file (~1-10KB) is cheaper than
# yaml.safe_load(). Unlike a fixed-length prefix, this detects
# edits anywhere in the file. Greptile P1 (PR#5803).
_current_content = None
try:
with open(cfg_path, "r", encoding="utf-8") as _f:
_current_content = _f.read()
except Exception:
pass
if _current_content == cached_content:
return cached_dict
# Content changed while key collided — fall through to re-parse
import yaml
try:
with open(cfg_path, encoding="utf-8") as _f:
content = _f.read()
parsed = yaml.safe_load(content) or {}
except Exception:
return None
if not isinstance(parsed, dict):
return None
with _PROFILE_CONFIG_CACHE_LOCK:
_PROFILE_CONFIG_CACHE[key] = (now, content, parsed)
# Cap the cache at 32 entries; profiles are bounded in practice
# and unbounded growth would be a leak.
if len(_PROFILE_CONFIG_CACHE) > 32:
# Drop the oldest entry by insertion order (dict is ordered).
for old_key in list(_PROFILE_CONFIG_CACHE.keys())[:max(0, len(_PROFILE_CONFIG_CACHE) - 32)]:
_PROFILE_CONFIG_CACHE.pop(old_key, None)
return parsed
def _load_profile_config_dict(session) -> dict | None:
"""Load the session profile's config.yaml as a dict, or None."""
if not getattr(session, "profile", None):
@@ -11506,7 +11596,7 @@ def handle_get(handler, parsed) -> bool:
# which the request-thread wrapper could not reach. See
# api.config.get_available_models cold path + profile_scope_for_detached_worker.
freshness = parse_qs(parsed.query or "").get("freshness", [""])[0].strip().lower()
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger)
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
try:
diag.stage(f"enter:freshness={freshness or 'default'}") if diag else None
if freshness == "session_visit":
@@ -11710,9 +11800,22 @@ def handle_get(handler, parsed) -> bool:
import time as _time
_t0 = _time.monotonic()
_debug_slow = os.environ.get("HERMES_DEBUG_SLOW", "")
# perf(webui/session-load-latency) tier2c: per-stage breakdown via
# RequestDiagnostics. maybe_start() returns None for paths not in
# the allowlist, in which case the existing _tN-driven [SLOW] log
# is the only signal — same as before.
_diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
# perf(webui/session-load-latency) tier2c-followup: every early-return
# in this handler calls `_diag.finish()` before returning so the
# watchdog's _watchdog_pending dict stays bounded to in-flight requests.
# Greptile flagged this in PR review — finish() unregisters the
# pending watchdog entry; without it the entry stays for the full
# 5s slow-request timeout and emits a spurious "Slow WebUI request
# still running" log. Idempotent — finish() no-ops if already called.
query = parse_qs(parsed.query)
sid = query.get("session_id", [""])[0]
if not sid:
if _diag: _diag.finish()
return j(handler, {"error": "session_id is required"}, status=400)
# ?messages=0 skips the message payload for fast session switching.
# The frontend uses this when switching conversations in the sidebar
@@ -11745,12 +11848,14 @@ def handle_get(handler, parsed) -> bool:
expand_renderable = str(_expand_renderable).strip() in ("1", "true", "True")
try:
_t1 = _time.monotonic()
if _diag: _diag.stage("t1_after_get_session_check")
s = get_session(sid, metadata_only=(not load_messages))
_session_profile = getattr(s, 'profile', None) or None
if not _session_visible_to_active_profile(_session_profile, handler):
if _session_profile:
# Valid session owned by a KNOWN other profile: 409 so the
# client can offer to switch to it (#5419).
if _diag: _diag.finish()
return j(handler, {
"error": "Session belongs to a different profile",
"code": "session_profile_mismatch",
@@ -11762,6 +11867,7 @@ def handle_get(handler, parsed) -> bool:
# fires. _profiles_match coerces None->'default', so a truly
# missing/legacy session under a non-default active profile would
# otherwise emit a useless 409 with profile=null.
if _diag: _diag.finish()
return bad(handler, "Session not found", 404)
original_stream_id = getattr(s, "active_stream_id", None)
_clear_stale_stream_state(s)
@@ -11799,6 +11905,7 @@ def handle_get(handler, parsed) -> bool:
# honor #2827's TLS-vs-thread fix.
metadata_summary = _metadata_only_message_summary(sid, profile=_session_profile)
_t2 = _time.monotonic()
if _diag: _diag.stage("t2_after_state_db_load")
effective_model = (
_resolve_effective_session_model_for_display(s)
if resolve_model
@@ -11810,6 +11917,7 @@ def handle_get(handler, parsed) -> bool:
else None
)
_t3 = _time.monotonic()
if _diag: _diag.stage("t3_after_model_resolve")
if load_messages:
if is_messaging_session and cli_messages:
# Recovery/aggregate sidecars can intentionally contain a
@@ -12052,6 +12160,7 @@ def handle_get(handler, parsed) -> bool:
raw["_messages_truncated"] = _truncated
raw["_messages_offset"] = _messages_offset
_t4 = _time.monotonic()
if _diag: _diag.stage("t4_after_compact_and_merge")
if effective_model:
raw["model"] = effective_model
if effective_provider:
@@ -12067,22 +12176,42 @@ def handle_get(handler, parsed) -> bool:
raw["read_only"] = True
redact = redact_session_data(raw)
_t5 = _time.monotonic()
if _diag: _diag.stage("t5_after_redact")
resp = j(handler, {"session": redact})
_t6 = _time.monotonic()
if _diag: _diag.stage("t6_after_json_write")
_total_ms = (_t6 - _t0) * 1000
# Always log when slow (>2s) so we don't need HERMES_DEBUG_SLOW env var
# to diagnose latency regressions. Opt-in env var still forces
# logging on every request for development.
if _debug_slow or _total_ms >= 2000:
logger.warning(
# perf(webui/session-load-latency) tier2c: route the [SLOW] line
# through handler._safe_webui_print() rather than logger.warning().
# The WebUI process starts the root logger without any handler, so
# logger.warning() calls are silently dropped (the [SLOW] line
# previously worked only on PIDs that happened to have a logger
# handler set up by an earlier run; today the line is invisible).
# _safe_webui_print writes to the systemd journal socket directly,
# same as the per-request ms line — which is why THAT line keeps
# working.
handler._safe_webui_print(
"[SLOW] session_id=%s get_session=%.1fms model_resolve=%.1fms "
"compact=%.1fms redact=%.1fms json_write=%.1fms total=%.1fms",
sid,
(_t2-_t1)*1000, (_t3-_t2)*1000, (_t4-_t3)*1000,
(_t5-_t4)*1000, (_t6-_t5)*1000, _total_ms,
"compact=%.1fms redact=%.1fms json_write=%.1fms total=%.1fms" % (
sid,
(_t2-_t1)*1000, (_t3-_t2)*1000, (_t4-_t3)*1000,
(_t5-_t4)*1000, (_t6-_t5)*1000, _total_ms,
)
)
if _diag: _diag.finish()
return resp
except KeyError:
# perf(webui/session-load-latency) tier2c-followup: fire
# _diag.finish() in the exception branch too. Greptile flagged
# this in PR review — finish() unregisters the pending watchdog
# entry; without it the entry stays for the full 5s slow-request
# timeout and emits a spurious "Slow WebUI request still
# running" log. Idempotent — finish() no-ops if already called.
if _diag: _diag.finish()
# No WebUI sidecar. Delegate to the shared foreign-session
# synthesizer so GET and POST have symmetric writeable/read-only
# behaviour for CLI/TUI/Desktop sessions. The helper enforces the
@@ -12214,7 +12343,7 @@ def handle_get(handler, parsed) -> bool:
return j(handler, {"results": get_results(sid)})
if parsed.path == "/api/sessions":
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger)
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
try:
from api import profiles as profiles_api
@@ -12713,7 +12842,7 @@ def handle_get(handler, parsed) -> bool:
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api import profiles as profiles_api
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger)
diag = RequestDiagnostics.maybe_start("GET", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
try:
diag.stage("list_profiles_api") if diag else None
profiles_payload = profiles_api.list_profiles_api()
@@ -12969,7 +13098,7 @@ def _validate_session_toolsets_shape(toolsets):
def handle_post(handler, parsed) -> bool:
"""Handle all POST routes. Returns True if handled, False for 404."""
diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger)
diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None))
if parsed.path == "/api/csp-report":
if diag:
diag.stage("csp_report")
@@ -13650,6 +13779,13 @@ def handle_post(handler, parsed) -> bool:
# GET ?session_id=X → return current draft
# POST body → save draft { session_id, text?, files? }
# HTTP method is in handler.command (e.g. "POST", "GET"), parsed has no .method
import time as _draft_time
_draft_t0 = _draft_time.monotonic()
_draft_stages = []
def _draft_mark(name):
_draft_stages.append((name, _draft_time.monotonic()))
_draft_mark("enter")
if handler.command == "GET":
query = parse_qs(parsed.query)
sid = query.get("session_id", [""])[0] if parsed.query else ""
@@ -13689,8 +13825,10 @@ def handle_post(handler, parsed) -> bool:
s = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
_draft_mark("after_get_session")
unchanged = False
with _get_session_agent_lock(sid):
_draft_mark("acquired_lock")
current_draft = dict(getattr(s, "composer_draft", {}) or {})
next_draft = dict(current_draft)
if text is not None:
@@ -13706,12 +13844,29 @@ def handle_post(handler, parsed) -> bool:
# here makes the active-session external-refresh poll force-reload the
# current chat every few seconds while the user is typing, and that
# delayed reload can restore an older draft over newer local input.
_draft_mark("before_save")
s.save(touch_updated_at=False, skip_index=True)
_draft_mark("after_save")
saved_draft = s.composer_draft
_draft_mark("released_lock")
payload = {"ok": True, "draft": saved_draft}
if unchanged:
payload["unchanged"] = True
_draft_mark("before_json")
j(handler, payload)
_draft_mark("after_json")
_draft_stages.append(("end", _draft_time.monotonic()))
if _draft_stages[-1][1] - _draft_t0 > 0.2:
parts = " ".join(
f"{n}={((t - prev[1]) * 1000):.1f}ms"
for (n, t), prev in zip(_draft_stages[1:], _draft_stages[:-1], strict=True)
)
handler._safe_webui_print(
"[SLOW] /api/session/draft total=%.1fms stages: %s" % (
(_draft_stages[-1][1] - _draft_t0) * 1000,
parts,
)
)
return True
if parsed.path == "/api/session/update":