fix: avoid adaptive title refresh session lock deadlock
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user