diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index 9c6d83aef0..675a7136a4 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -4,6 +4,7 @@ import time from asyncio import CancelledError, Event, Queue from contextlib import suppress +from traceback import format_exception from typing import TYPE_CHECKING, TypedDict import marshmallow.fields @@ -23,6 +24,7 @@ Notification.tunnel_removed, Notification.watch_folder_corrupt_file, Notification.tribler_new_version, + Notification.tribler_exception, Notification.torrent_finished, Notification.torrent_health_updated, Notification.tribler_shutdown_state, @@ -112,7 +114,10 @@ def error_message(self, reported_error: Exception) -> MessageDict: """ return { "topic": Notification.tribler_exception.value.name, - "kwargs": {"error": str(reported_error)}, + "kwargs": { + "error": str(reported_error), + "traceback": "".join(format_exception(type(reported_error), reported_error, + reported_error.__traceback__))}, } def encode_message(self, message: MessageDict) -> bytes: diff --git a/src/tribler/core/session.py b/src/tribler/core/session.py index a447038e6c..4a0865abb7 100644 --- a/src/tribler/core/session.py +++ b/src/tribler/core/session.py @@ -2,9 +2,10 @@ import asyncio import logging -from asyncio import Event +import sys +from asyncio import AbstractEventLoop, Event from contextlib import contextmanager -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING, Any, Generator, Type, cast import aiohttp from ipv8.loader import IPv8CommunityLoader @@ -38,6 +39,8 @@ from tribler.core.socks5.server import Socks5Server if TYPE_CHECKING: + from types import TracebackType + from tribler.core.database.store import MetadataStore from tribler.core.database.tribler_database import TriblerDatabase from tribler.core.torrent_checker.torrent_checker import TorrentChecker @@ -148,6 +151,35 @@ def register_rest_endpoints(self) -> None: self.rest_manager.add_endpoint(StatisticsEndpoint()) self.rest_manager.add_endpoint(TorrentInfoEndpoint(self.download_manager)) + def _except_hook(self, typ: Type[BaseException], value: BaseException, traceback: TracebackType | None) -> None: + """ + Handle an uncaught exception. + + Note: at this point the REST interface is available. + Note2: ignored BaseExceptions are BaseExceptionGroup, GeneratorExit, KeyboardInterrupt and SystemExit + """ + if isinstance(value, Exception): + cast(EventsEndpoint, self.rest_manager.get_endpoint("/api/events")).on_tribler_exception(value) + + def _asyncio_except_hook(self, loop: AbstractEventLoop, context: dict[str, Any]) -> None: + """ + Handle an uncaught asyncio exception. + + Note: at this point the REST interface is available. + Note2: ignored BaseExceptions are BaseExceptionGroup, GeneratorExit, KeyboardInterrupt and SystemExit + """ + exc = context.get("exception") + if isinstance(exc, Exception): + cast(EventsEndpoint, self.rest_manager.get_endpoint("/api/events")).on_tribler_exception(exc) + raise exc + + def attach_exception_handler(self) -> None: + """ + Hook ourselves in as the general exception handler. + """ + sys.excepthook = self._except_hook + asyncio.get_running_loop().set_exception_handler(self._asyncio_except_hook) + async def start(self) -> None: """ Initialize and launch all components and REST endpoints. @@ -157,6 +189,7 @@ async def start(self) -> None: # REST (1/2) await self.rest_manager.start() + self.attach_exception_handler() # Libtorrent for server in self.socks_servers: diff --git a/src/tribler/test_unit/core/restapi/test_events_endpoint.py b/src/tribler/test_unit/core/restapi/test_events_endpoint.py index 6c244d6747..2ed08dea99 100644 --- a/src/tribler/test_unit/core/restapi/test_events_endpoint.py +++ b/src/tribler/test_unit/core/restapi/test_events_endpoint.py @@ -116,7 +116,7 @@ async def test_establish_connection_with_error(self) -> None: self.assertEqual(200, response.status) self.assertEqual((b'event: tribler_exception\n' - b'data: {"error": "test message"}' + b'data: {"error": "test message", "traceback": "ValueError: test message\\n"}' b'\n\n'), request.payload_writer.captured[1]) async def test_forward_error(self) -> None: @@ -133,7 +133,7 @@ async def test_forward_error(self) -> None: self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) self.assertEqual((b'event: tribler_exception\n' - b'data: {"error": "test message"}' + b'data: {"error": "test message", "traceback": "ValueError: test message\\n"}' b'\n\n'), request.payload_writer.captured[1]) async def test_error_before_connection(self) -> None: @@ -175,9 +175,10 @@ async def test_send_event_illegal_chars(self) -> None: self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'event: tribler_exception\n' - b'data: {"error": "Object of type bytes is not JSON serializable"}' - b'\n\n'), request.payload_writer.captured[1]) + self.assertTrue(request.payload_writer.captured[1].startswith( + b'event: tribler_exception\n' + b'data: {"error": "Object of type bytes is not JSON serializable", "traceback": "' + )) async def test_forward_notification(self) -> None: """ diff --git a/src/tribler/ui/src/services/reporting.ts b/src/tribler/ui/src/services/reporting.ts index 9f23ae3062..3bd58b6fea 100644 --- a/src/tribler/ui/src/services/reporting.ts +++ b/src/tribler/ui/src/services/reporting.ts @@ -8,7 +8,7 @@ export function handleHTTPError(error: Error | AxiosError) { if (axios.isAxiosError(error) && error.response?.data?.error?.message){ error_popup_text.textContent = error.response.data.error.message.replace(/(?:\n)/g, '\r\n'); } else { - error_popup_text.textContent = `${error}`; + error_popup_text.textContent = error.message; } const error_popup = document.querySelector("#error_popup"); if (error_popup && error_popup.classList.contains("hidden")) { diff --git a/src/tribler/ui/src/services/tribler.service.ts b/src/tribler/ui/src/services/tribler.service.ts index 13a082a57d..d5d6ce36e8 100644 --- a/src/tribler/ui/src/services/tribler.service.ts +++ b/src/tribler/ui/src/services/tribler.service.ts @@ -4,10 +4,16 @@ import { File as BTFile } from "@/models/file.model"; import { Path } from "@/models/path.model"; import { GuiSettings, Settings } from "@/models/settings.model"; import { Torrent } from "@/models/torrent.model"; -import axios, { AxiosInstance } from "axios"; +import axios, { AxiosError, AxiosInstance } from "axios"; import { handleHTTPError } from "./reporting"; +const OnError = (event: MessageEvent) => { + const data = JSON.parse(event.data); + handleHTTPError(new Error(data.traceback)); +}; + + export class TriblerService { private http: AxiosInstance; private baseURL = "/api"; @@ -22,6 +28,7 @@ export class TriblerService { }); this.http.interceptors.response.use(function (response) { return response; }, handleHTTPError); this.events = new EventSource(this.baseURL + '/events', { withCredentials: true }); + this.addEventListener("tribler_exception", OnError); // Gets the GuiSettings this.getSettings(); }