Skip to content

Commit 6753b2a

Browse files
Log (instead of raise) exceptions when running as a server extension (#295)
* Log (instead of raise) exceptions when running as a server extension * pre-commit errors * Allow transient rooms to log exceptions * Update projects/jupyter-server-ydoc/jupyter_server_ydoc/websocketserver.py Co-authored-by: David Brochart <[email protected]> --------- Co-authored-by: David Brochart <[email protected]>
1 parent 01ee5df commit 6753b2a

File tree

5 files changed

+82
-16
lines changed

5 files changed

+82
-16
lines changed

projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
encode_file_path,
2323
room_id_from_encoded_path,
2424
)
25-
from .websocketserver import JupyterWebsocketServer, RoomNotFound
25+
from .websocketserver import JupyterWebsocketServer, RoomNotFound, exception_logger
2626

2727

2828
class YDocExtension(ExtensionApp):
@@ -107,6 +107,9 @@ def initialize_handlers(self):
107107
rooms_ready=False,
108108
auto_clean_rooms=False,
109109
ystore_class=self.ystore_class,
110+
# Log exceptions, because we don't want the websocket server
111+
# to _ever_ crash permanently in a live jupyter_server.
112+
exception_handler=exception_logger,
110113
log=self.log,
111114
)
112115

projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import time
99
import uuid
10+
from logging import Logger
1011
from typing import Any
1112

1213
from jupyter_server.auth import authorized
@@ -83,6 +84,21 @@ async def prepare(self):
8384
if self._websocket_server.room_exists(self._room_id):
8485
self.room: YRoom = await self._websocket_server.get_room(self._room_id)
8586
else:
87+
# Logging exceptions, instead of raising them here to ensure
88+
# that the y-rooms stay alive even after an exception is seen.
89+
def exception_logger(exception: Exception, log: Logger) -> bool:
90+
"""A function that catches any exceptions raised in the websocket
91+
server and logs them.
92+
93+
The protects the y-room's task group from cancelling
94+
anytime an exception is raised.
95+
"""
96+
log.error(
97+
f"Document Room Exception, (room_id={self._room_id or 'unknown'}): ",
98+
exc_info=exception,
99+
)
100+
return True
101+
86102
if self._room_id.count(":") >= 2:
87103
# DocumentRoom
88104
file_format, file_type, file_id = decode_file_path(self._room_id)
@@ -104,13 +120,18 @@ async def prepare(self):
104120
self.event_logger,
105121
ystore,
106122
self.log,
107-
self._document_save_delay,
123+
exception_handler=exception_logger,
124+
save_delay=self._document_save_delay,
108125
)
109126

110127
else:
111128
# TransientRoom
112129
# it is a transient document (e.g. awareness)
113-
self.room = TransientRoom(self._room_id, self.log)
130+
self.room = TransientRoom(
131+
self._room_id,
132+
log=self.log,
133+
exception_handler=exception_logger,
134+
)
114135

115136
try:
116137
await self._websocket_server.start_room(self.room)
@@ -223,7 +244,8 @@ async def open(self, room_id):
223244
else:
224245
self.log.error(f"Error initializing: {file.path}\n{e!r}", exc_info=e)
225246
self.close(
226-
1003, f"Error initializing: {file.path}. You need to close the document."
247+
1003,
248+
f"Error initializing: {file.path}. You need to close the document.",
227249
)
228250

229251
# Clean up the room and delete the file loader
@@ -290,7 +312,11 @@ async def on_message(self, message):
290312

291313
user = self.current_user
292314
data = json.dumps(
293-
{"sender": user.username, "timestamp": time.time(), "content": json.loads(msg)}
315+
{
316+
"sender": user.username,
317+
"timestamp": time.time(),
318+
"content": json.loads(msg),
319+
}
294320
).encode("utf8")
295321

296322
for client in self.room.clients:
@@ -405,7 +431,12 @@ async def put(self, path):
405431
# index already exists
406432
self.log.info("Request for Y document '%s' with room ID: %s", path, idx)
407433
data = json.dumps(
408-
{"format": format, "type": content_type, "fileId": idx, "sessionId": SERVER_SESSION}
434+
{
435+
"format": format,
436+
"type": content_type,
437+
"fileId": idx,
438+
"sessionId": SERVER_SESSION,
439+
}
409440
)
410441
self.set_status(200)
411442
return self.finish(data)
@@ -419,7 +450,12 @@ async def put(self, path):
419450
# index successfully created
420451
self.log.info("Request for Y document '%s' with room ID: %s", path, idx)
421452
data = json.dumps(
422-
{"format": format, "type": content_type, "fileId": idx, "sessionId": SERVER_SESSION}
453+
{
454+
"format": format,
455+
"type": content_type,
456+
"fileId": idx,
457+
"sessionId": SERVER_SESSION,
458+
}
423459
)
424460
self.set_status(201)
425461
return self.finish(data)

projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import asyncio
77
from logging import Logger
8-
from typing import Any
8+
from typing import Any, Callable
99

1010
from jupyter_events import EventLogger
1111
from jupyter_ydoc import ydocs as YDOCS
@@ -31,8 +31,9 @@ def __init__(
3131
ystore: BaseYStore | None,
3232
log: Logger | None,
3333
save_delay: float | None = None,
34+
exception_handler: Callable[[Exception, Logger], bool] | None = None,
3435
):
35-
super().__init__(ready=False, ystore=ystore, log=log)
36+
super().__init__(ready=False, ystore=ystore, exception_handler=exception_handler, log=log)
3637

3738
self._room_id: str = room_id
3839
self._file_format: str = file_format
@@ -123,7 +124,9 @@ async def initialize(self) -> None:
123124
if self._document.source != model["content"]:
124125
# TODO: Delete document from the store.
125126
self._emit(
126-
LogLevel.INFO, "initialize", "The file is out-of-sync with the ystore."
127+
LogLevel.INFO,
128+
"initialize",
129+
"The file is out-of-sync with the ystore.",
127130
)
128131
self.log.info(
129132
"Content in file %s is out-of-sync with the ystore %s",
@@ -135,7 +138,9 @@ async def initialize(self) -> None:
135138
if read_from_source:
136139
self._emit(LogLevel.INFO, "load", "Content loaded from disk.")
137140
self.log.info(
138-
"Content in room %s loaded from file %s", self._room_id, self._file.path
141+
"Content in room %s loaded from file %s",
142+
self._room_id,
143+
self._file.path,
139144
)
140145
self._document.source = model["content"]
141146

@@ -284,8 +289,13 @@ async def _maybe_save_document(self, saving_document: asyncio.Task | None) -> No
284289
class TransientRoom(YRoom):
285290
"""A Y room for sharing state (e.g. awareness)."""
286291

287-
def __init__(self, room_id: str, log: Logger | None):
288-
super().__init__(log=log)
292+
def __init__(
293+
self,
294+
room_id: str,
295+
log: Logger | None = None,
296+
exception_handler: Callable[[Exception, Logger], bool] | None = None,
297+
):
298+
super().__init__(log=log, exception_handler=exception_handler)
289299

290300
self._room_id = room_id
291301

projects/jupyter-server-ydoc/jupyter_server_ydoc/websocketserver.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import asyncio
77
from logging import Logger
8-
from typing import Any
8+
from typing import Any, Callable
99

1010
from pycrdt_websocket.websocket_server import WebsocketServer, YRoom
1111
from pycrdt_websocket.ystore import BaseYStore
@@ -16,6 +16,17 @@ class RoomNotFound(LookupError):
1616
pass
1717

1818

19+
def exception_logger(exception: Exception, log: Logger) -> bool:
20+
"""A function that catches any exceptions raised in the websocket
21+
server and logs them.
22+
23+
This protects the websocket server's task group from cancelling
24+
anytime an exception is raised.
25+
"""
26+
log.error("Jupyter Websocket Server: ", exc_info=exception)
27+
return True
28+
29+
1930
class JupyterWebsocketServer(WebsocketServer):
2031
"""Ypy websocket server.
2132
@@ -30,9 +41,15 @@ def __init__(
3041
ystore_class: BaseYStore,
3142
rooms_ready: bool = True,
3243
auto_clean_rooms: bool = True,
44+
exception_handler: Callable[[Exception, Logger], bool] | None = None,
3345
log: Logger | None = None,
3446
):
35-
super().__init__(rooms_ready, auto_clean_rooms, log)
47+
super().__init__(
48+
rooms_ready=rooms_ready,
49+
auto_clean_rooms=auto_clean_rooms,
50+
exception_handler=exception_handler,
51+
log=log,
52+
)
3653
self.ystore_class = ystore_class
3754
self.ypatch_nb = 0
3855
self.connected_users: dict[Any, Any] = {}

projects/jupyter-server-ydoc/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ authors = [
3030
dependencies = [
3131
"jupyter_server>=2.4.0,<3.0.0",
3232
"jupyter_ydoc>=2.0.0,<3.0.0",
33-
"pycrdt-websocket>=0.13.0,<0.14.0",
33+
"pycrdt-websocket>=0.13.1,<0.14.0",
3434
"jupyter_events>=0.10.0",
3535
"jupyter_server_fileid>=0.7.0,<1",
3636
"jsonschema>=4.18.0"

0 commit comments

Comments
 (0)