Fix Qt worker lifecycle crash

This commit is contained in:
2026-07-30 21:10:56 +02:00
parent cba80dfeb1
commit 7f4fc23e1d
9 changed files with 272 additions and 20 deletions
+13
View File
@@ -0,0 +1,13 @@
Fatal Python error: Aborted
Current thread 0x000066dc (most recent call first):
File "D:\Javis-Projekt\src\javis\ui\main_window.py", line 301 in _generation_complete
File "D:\Javis-Projekt\src\javis\ui\app.py", line 44 in run_gui
File "D:\Javis-Projekt\src\javis\interface\cli.py", line 429 in main
File "D:\Javis-Projekt\.venv\Scripts\javis.exe\__main__.py", line 10 in <module>
File "<frozen runpy>", line 88 in _run_code
File "<frozen runpy>", line 198 in _run_module_as_main
Extension modules: charset_normalizer.md, charset_normalizer.cd, requests.packages.charset_normalizer.md, requests.packages.chardet.md, requests.packages.charset_normalizer.cd, requests.packages.chardet.cd, _cffi_backend, websockets.speedups, shiboken6.Shiboken, PySide6.QtCore, PySide6.QtGui, PySide6.QtWidgets (total: 12)
QThreadStorage: entry 1 destroyed before end of thread 0x28a793bea70
QThreadStorage: entry 0 destroyed before end of thread 0x28a793bea70
+22
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import threading
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import StrEnum
@@ -84,6 +85,8 @@ class HybridProvider:
self._max_cloud_input_chars = max_cloud_input_chars
self._max_cloud_context_messages = max_cloud_context_messages
self._cloud_session_disabled = False
self._approval_lock = threading.Lock()
self._next_approval: ApprovalChoice | None = None
self.last_route: RouteStatus | None = None
def supports_session(self, provider: str, model: str) -> bool:
@@ -97,7 +100,26 @@ class HybridProvider:
raise ValueError("Provider-Modus muss auto, local oder gemini sein.")
self.mode = mode
def privacy_decision(self, text: str) -> PrivacyDecision:
"""Classify a prospective request without contacting a provider."""
return self._privacy_router.classify(
text,
cloud_requested=self.mode == "gemini",
)
def queue_approval(self, choice: ApprovalChoice) -> None:
"""Provide the GUI decision for exactly the next ASK request."""
if choice not in {ApprovalChoice.ALLOW, ApprovalChoice.LOCAL}:
raise ValueError("Nur Freigabe oder lokale Antwort kann vorgemerkt werden.")
with self._approval_lock:
self._next_approval = choice
def _approval_choice(self, decision: PrivacyDecision) -> ApprovalChoice:
with self._approval_lock:
queued = self._next_approval
self._next_approval = None
if queued is not None:
return queued
result = self._approval_callback(decision)
if isinstance(result, bool):
return ApprovalChoice.ALLOW if result else ApprovalChoice.LOCAL
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox
from javis.config.settings import ConfigurationError, Settings
from javis.core.provider_router import ApprovalChoice
from javis.core.runtime import build_javis_runtime
from javis.memory.sqlite_store import SessionStoreError
from javis.memory.usage_store import UsageStoreError
@@ -26,7 +27,7 @@ def run_gui(argv: Sequence[str] | None = None) -> int:
settings = Settings.from_env()
runtime = build_javis_runtime(
settings,
approval_callback=approval_bridge.request,
approval_callback=lambda _decision: ApprovalChoice.CANCEL,
notice_callback=approval_bridge.notice_received.emit,
)
except (ConfigurationError, SessionStoreError, UsageStoreError) as exc:
+12
View File
@@ -6,9 +6,11 @@ import re
import threading
from collections.abc import Iterator
from javis.core.provider_router import ApprovalChoice
from javis.core.runtime import JavisRuntime
from javis.memory.sqlite_store import ChatSession
from javis.providers.base import ProviderError, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.models import (
DesktopStatus,
GenerationState,
@@ -65,6 +67,16 @@ class DesktopController:
def set_mode(self, mode: str) -> None:
self.runtime.hybrid_provider.set_mode(mode)
def approval_decision(self, text: str) -> PrivacyDecision | None:
provider = self.runtime.hybrid_provider
decision = provider.privacy_decision(text)
if provider.mode == "local" or decision.policy is not CloudPolicy.ASK:
return None
return decision
def queue_approval(self, choice: ApprovalChoice) -> None:
self.runtime.hybrid_provider.queue_approval(choice)
def stream_message(
self,
text: str,
+32 -8
View File
@@ -57,6 +57,8 @@ class MainWindow(QMainWindow):
self.approval_bridge = approval_bridge
self._thread: QThread | None = None
self._worker: StreamWorker | None = None
self._generation_finalized = True
self._close_when_finished = False
self._messages: list[ChatMessage] = []
self._draft_user = ""
self._draft_assistant = ""
@@ -158,7 +160,6 @@ class MainWindow(QMainWindow):
self.stop_button.clicked.connect(self._stop_generation)
self.message_edit.send_requested.connect(self._send)
self.mode_combo.currentIndexChanged.connect(self._mode_changed)
self.approval_bridge.approval_requested.connect(self._show_approval)
self.approval_bridge.notice_received.connect(self._show_notice)
def _apply_style(self) -> None:
@@ -266,25 +267,40 @@ class MainWindow(QMainWindow):
text = self.message_edit.toPlainText().strip()
if not text or self._worker is not None:
return
try:
decision = self.controller.approval_decision(text)
if decision is not None:
choice = self._show_approval(decision)
if choice is ApprovalChoice.CANCEL:
self.statusBar().showMessage("Anfrage abgebrochen.", 3000)
return
self.controller.queue_approval(choice)
except Exception as exc:
self._show_error(str(exc))
return
self.message_edit.clear()
self._draft_user = text
self._draft_assistant = ""
self._render_chat()
self._set_generating(True)
self._generation_finalized = False
thread = QThread(self)
worker = StreamWorker(self.controller, text)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.event_received.connect(self._handle_stream_event)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
worker.completed.connect(self._generation_complete)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(self._thread_finished)
thread.finished.connect(thread.deleteLater)
self._thread = thread
self._worker = worker
thread.start()
def _handle_stream_event(self, event: StreamEvent) -> None:
if self._generation_finalized:
return
if event.kind is StreamEventKind.CHUNK:
self._draft_assistant += event.text
self._render_chat()
@@ -294,12 +310,13 @@ class MainWindow(QMainWindow):
self._show_error(event.text)
def _generation_complete(self) -> None:
if self._generation_finalized:
return
self._generation_finalized = True
failed = self.controller.state in {
GenerationState.ABORTED,
GenerationState.ERROR,
}
self._worker = None
self._thread = None
self._set_generating(False)
try:
loaded = self.controller.load_session(self.controller.active_session_id or "")
@@ -315,11 +332,17 @@ class MainWindow(QMainWindow):
else:
self.statusBar().showMessage("Antwort vollständig gespeichert.", 3000)
def _thread_finished(self) -> None:
self._worker = None
self._thread = None
if self._close_when_finished:
self._close_when_finished = False
QTimer.singleShot(0, self.close)
def _stop_generation(self) -> None:
if self._worker is None:
return
self._worker.request_cancel()
self.approval_bridge.cancel_pending()
self.statusBar().showMessage("Abbruch angefordert …")
def _set_generating(self, active: bool) -> None:
@@ -336,7 +359,7 @@ class MainWindow(QMainWindow):
except Exception as exc:
self._show_error(str(exc))
def _show_approval(self, decision: PrivacyDecision) -> None:
def _show_approval(self, decision: PrivacyDecision) -> ApprovalChoice:
box = QMessageBox(self)
box.setWindowTitle("Cloudfreigabe")
box.setIcon(QMessageBox.Question)
@@ -357,9 +380,9 @@ class MainWindow(QMainWindow):
choice = ApprovalChoice.LOCAL
elif clicked is cancel:
choice = ApprovalChoice.CANCEL
self.approval_bridge.resolve(choice)
if box.clickedButton() is local:
self.statusBar().showMessage("Die Anfrage wird lokal beantwortet.", 3000)
return choice
def _show_notice(self, text: str) -> None:
self.statusBar().showMessage(text, 5000)
@@ -438,8 +461,9 @@ class MainWindow(QMainWindow):
self.search_edit.selectAll()
def closeEvent(self, event: QCloseEvent) -> None:
if self._thread is not None:
self._close_when_finished = True
self._stop_generation()
if self._thread is not None and not self._thread.wait(2500):
event.ignore()
self.statusBar().showMessage(
"Javis beendet die laufende Antwort noch kontrolliert.",
+7
View File
@@ -53,9 +53,14 @@ class StreamWorker(QObject):
self._controller = controller
self._text = text
self._cancel_event = threading.Event()
self._started = False
self._completed = False
@Slot()
def run(self) -> None:
if self._started:
return
self._started = True
try:
for event in self._controller.stream_message(
self._text,
@@ -63,6 +68,8 @@ class StreamWorker(QObject):
):
self.event_received.emit(event)
finally:
if not self._completed:
self._completed = True
self.completed.emit()
def request_cancel(self) -> None:
+20
View File
@@ -7,8 +7,10 @@ from pathlib import Path
from types import SimpleNamespace
from javis.core.chat_service import ChatService
from javis.core.provider_router import ApprovalChoice
from javis.memory.sqlite_store import SQLiteSessionStore
from javis.providers.base import ChatMessage, ResponseAbortedError
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.chat_controller import DesktopController, sanitized_error
from javis.ui.models import GenerationState, StreamEventKind
@@ -45,10 +47,17 @@ class _Service:
class _Hybrid:
mode = "auto"
last_route = None
queued_approval = None
def set_mode(self, mode: str) -> None:
self.mode = mode
def privacy_decision(self, _text: str) -> PrivacyDecision:
return PrivacyDecision(CloudPolicy.ASK, "Test")
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
class _StreamingProvider:
name = "ollama"
@@ -91,6 +100,17 @@ class DesktopControllerTests(unittest.TestCase):
self.assertEqual(loaded.messages[0].content, "Hallo")
self.assertEqual(renamed.title, "Neu")
def test_controller_preflights_and_queues_gui_approval(self) -> None:
decision = self.controller.approval_decision("Private Frage")
self.controller.queue_approval(ApprovalChoice.ALLOW)
self.assertIsNotNone(decision)
self.assertIs(decision.policy, CloudPolicy.ASK)
self.assertIs(
self.runtime.hybrid_provider.queued_approval,
ApprovalChoice.ALLOW,
)
def test_error_sanitizer_masks_key_patterns(self) -> None:
secret = "AIza" + "x" * 25
result = sanitized_error(RuntimeError(f"Fehler {secret}"))
+150 -9
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import os
import threading
import time
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -13,8 +15,8 @@ from javis.core.provider_router import ApprovalChoice
from javis.providers.base import ChatMessage
from javis.security.privacy import CloudPolicy, PrivacyDecision
from javis.ui.main_window import MainWindow
from javis.ui.models import DesktopStatus, GenerationState
from javis.ui.workers import ApprovalBridge
from javis.ui.models import DesktopStatus, GenerationState, StreamEvent, StreamEventKind
from javis.ui.workers import ApprovalBridge, StreamWorker
class _Controller:
@@ -28,6 +30,15 @@ class _Controller:
message_count=2,
updated_at="2026-07-30T20:00:00+00:00",
)
self.messages = [
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
]
self.approval: PrivacyDecision | None = None
self.queued_approval: ApprovalChoice | None = None
self.started = threading.Event()
self.release = threading.Event()
self.release.set()
def list_sessions(self, search: str = ""):
return [self.session] if not search or search in self.session.title else []
@@ -36,10 +47,7 @@ class _Controller:
self.active_session_id = session_id
return SimpleNamespace(
session=self.session,
messages=[
ChatMessage("user", "Hallo"),
ChatMessage("assistant", "Guten Tag"),
],
messages=list(self.messages),
)
def new_session(self):
@@ -53,6 +61,38 @@ class _Controller:
def set_mode(self, _mode: str) -> None:
return
def approval_decision(self, _text: str) -> PrivacyDecision | None:
return self.approval
def queue_approval(self, choice: ApprovalChoice) -> None:
self.queued_approval = choice
def stream_message(self, text: str, cancel_event: threading.Event):
self.started.set()
self.state = GenerationState.THINKING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
while not self.release.wait(0.005):
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
if cancel_event.is_set():
self.state = GenerationState.ABORTED
yield StreamEvent(StreamEventKind.STATE, state=self.state)
return
self.state = GenerationState.ANSWERING
yield StreamEvent(StreamEventKind.STATE, state=self.state)
yield StreamEvent(StreamEventKind.CHUNK, text="Testantwort")
self.messages.extend(
[
ChatMessage("user", text),
ChatMessage("assistant", "Testantwort"),
]
)
self.session.message_count = len(self.messages)
self.state = GenerationState.READY
yield StreamEvent(StreamEventKind.FINISHED, state=self.state)
def status(self) -> DesktopStatus:
return DesktopStatus(
state=self.state,
@@ -108,14 +148,26 @@ class DesktopGuiTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = ApprovalBridge()
self.window = MainWindow(_Controller(), self.bridge)
self.controller = _Controller()
self.window = MainWindow(self.controller, self.bridge)
self.window.show()
self.application.processEvents()
def tearDown(self) -> None:
self.controller.release.set()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.window.close()
self.application.processEvents()
def _process_until(self, condition, timeout: float = 2.0) -> None:
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
self.application.processEvents()
time.sleep(0.005)
self.application.processEvents()
self.assertTrue(condition())
def test_offscreen_window_loads_session_and_core_widgets(self) -> None:
self.assertEqual(self.window.windowTitle(), "Javis Desktop")
self.assertEqual(self.window.session_list.count(), 1)
@@ -137,5 +189,94 @@ class DesktopGuiTests(unittest.TestCase):
for label, choice in expected.items():
with self.subTest(label=label):
_MessageBox.selected_label = label
self.window._show_approval(decision)
self.bridge.resolve.assert_called_with(choice)
self.assertIs(self.window._show_approval(decision), choice)
def test_local_completion_and_second_question_keep_gui_reusable(self) -> None:
for text in ("Erste Frage", "Zweite Frage"):
self.window.message_edit.setPlainText(text)
self.window._send()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 6)
self.assertIn("Zweite Frage", self.window.chat_view.toPlainText())
self.assertTrue(self.window.send_button.isEnabled())
def test_approval_is_decided_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.ALLOW)
self.window.message_edit.setPlainText("Private Testfrage")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.ALLOW)
self.window._show_approval.assert_called_once()
self._process_until(lambda: self.window._thread is None)
def test_local_approval_choice_is_queued_before_worker_starts(self) -> None:
self.controller.approval = PrivacyDecision(CloudPolicy.ASK, "privat")
self.window._show_approval = Mock(return_value=ApprovalChoice.LOCAL)
self.window.message_edit.setPlainText("Lokal beantworten")
self.window._send()
self.assertIs(self.controller.queued_approval, ApprovalChoice.LOCAL)
self._process_until(lambda: self.window._thread is None)
def test_stop_during_streaming_discards_partial_exchange(self) -> None:
self.controller.release.clear()
before = list(self.controller.messages)
self.window.message_edit.setPlainText("Nicht speichern")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.messages, before)
self.assertEqual(self.controller.state, GenerationState.ABORTED)
def test_stop_immediately_after_start_is_safe(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Sofort stoppen")
self.window._send()
self.window._stop_generation()
self._process_until(lambda: self.window._thread is None)
self.assertEqual(self.controller.session.message_count, 2)
def test_completion_is_idempotent_and_retains_qt_references(self) -> None:
worker = object()
thread = object()
self.window._worker = worker
self.window._thread = thread
self.window._generation_finalized = False
self.window._generation_complete()
self.window._generation_complete()
self.assertIs(self.window._worker, worker)
self.assertIs(self.window._thread, thread)
self.window._thread_finished()
self.assertIsNone(self.window._worker)
self.assertIsNone(self.window._thread)
def test_worker_emits_completion_only_once(self) -> None:
worker = StreamWorker(self.controller, "Nur einmal")
completions: list[bool] = []
worker.completed.connect(lambda: completions.append(True))
worker.run()
worker.run()
self.assertEqual(completions, [True])
def test_close_during_generation_cancels_then_closes(self) -> None:
self.controller.release.clear()
self.window.message_edit.setPlainText("Beim Schließen stoppen")
self.window._send()
self._process_until(self.controller.started.is_set)
self.window.close()
self.assertTrue(self.window._close_when_finished)
self._process_until(lambda: self.window._thread is None)
self._process_until(lambda: not self.window.isVisible())
+12
View File
@@ -182,6 +182,18 @@ class HybridProviderTests(unittest.TestCase):
self.assertEqual(answer, "Cloud")
self.assertEqual(len(self.cloud.calls), 1)
def test_queued_gui_approval_is_one_shot_and_uses_fake_cloud(self) -> None:
router = self._router()
question = [ChatMessage("user", "Meine Familie plant Urlaub")]
router.queue_approval(ApprovalChoice.ALLOW)
chunks = list(router.stream_chat(question))
second_answer = router.chat(question)
self.assertEqual(chunks, ["Cloud"])
self.assertEqual(second_answer, "Lokal")
self.assertEqual(len(self.approvals), 1)
def test_ask_supports_allow_local_and_cancel_choices(self) -> None:
question = [ChatMessage("user", "Meine Familie plant Urlaub")]