fix: avoid adaptive title refresh session lock deadlock

This commit is contained in:
Manfred
2026-05-05 12:48:40 +02:00
parent 1cde702d47
commit 52e7916cb8
3 changed files with 58 additions and 2 deletions

View File

@@ -938,7 +938,12 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option
'need', 'needs', 'want', 'wants', 'user', 'assistant', 'could', 'would',
'should', 'about', 'there', 'here', 'test', 'testing', 'title', 'summary',
}
tokens = re.findall(r'[A-Za-z0-9][A-Za-z0-9_./+-]*', head)
# Unicode-aware Latin tokenization: keep the old "no leading underscore"
# and non-Latin placeholder behavior while allowing letters such as ä/ö/ü/ß.
# The previous ASCII-only pattern turned "führe" into "f" + "hre"; the short
# "f" was filtered and the broken "hre" became part of the title.
latin_word = r'A-Za-z0-9À-ÖØ-öø-ÿ'
tokens = re.findall(rf'[{latin_word}][{latin_word}_./+-]*', head)
if not tokens:
return 'Conversation topic'
@@ -1092,8 +1097,12 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex
return
s.title = next_title
s.llm_title_generated = True
s.save(touch_updated_at=False)
effective_title = s.title
# Session.save() calls _write_session_index(), which acquires LOCK.
# Keep the per-session agent lock for mutation serialization, but
# release the global session LOCK before persisting to avoid a
# self-deadlock in the background title-refresh thread.
s.save(touch_updated_at=False)
_put_title_status(put_event, session_id, 'refreshed', llm_status, effective_title, raw_preview)
put_event('title', {'session_id': session_id, 'title': effective_title})
logger.info("Adaptive title refresh: session=%s new_title=%r", session_id, effective_title)

View File

@@ -287,6 +287,40 @@ class TestRunBackgroundTitleRefresh:
assert len(title_events) == 1
assert title_events[0][1]['title'] == 'New Refreshed Title'
def test_saves_refreshed_title_outside_global_lock(self):
"""Refreshing an existing title must not call Session.save() while holding LOCK."""
class TrackingLock:
def __init__(self):
self.held = False
def __enter__(self):
assert not self.held
self.held = True
return self
def __exit__(self, exc_type, exc, tb):
self.held = False
put, events = self._make_put_event()
lock = TrackingLock()
s = self._make_session_obj(title='Old Title')
def save(*args, **kwargs):
assert not lock.held, "Session.save() must run outside api.models.LOCK"
s.save = save
fake_sessions = {'sid': s}
with patch('api.streaming.get_session', return_value=s), \
patch('api.streaming._aux_title_configured', return_value=True), \
patch('api.streaming._generate_llm_session_title_via_aux',
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
patch('api.streaming.SESSIONS', fake_sessions), \
patch('api.streaming.LOCK', lock):
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
title_events = [(n, d) for n, d in events if n == 'title']
assert len(title_events) == 1
assert title_events[0][1]['title'] == 'New Refreshed Title'
def test_exceptions_are_silently_swallowed(self):
"""Any unexpected error inside must not propagate — it's a background daemon."""
put, events = self._make_put_event()

View File

@@ -327,6 +327,19 @@ class TestIssue495TitleStreaming(unittest.TestCase):
"Substantive answer text on a tool_call row must be preserved",
)
def test_fallback_title_preserves_unicode_letters(self):
"""Local fallback title generation must not strip German umlauts."""
from api.streaming import _fallback_title_from_exchange
title = _fallback_title_from_exchange(
"Bitte führe ein Selbst-Audit durch. Wo ist überall noch Gemini-2.5-flash als Modell im Einsatz? Sei gründlich",
"Ich prüfe live statt aus Bauchgefühl.",
)
self.assertIsNotNone(title)
self.assertIn("führe", title)
self.assertNotIn("hre", title.split())
def test_title_snippet_skips_tool_call_preamble_only_rows(self):
"""Tool-call rows whose content is empty or meta-reasoning preamble
('Let me check my memory first.') must still be skipped — those are