Skip to content

Commit c09956b

Browse files
Zsailerdavidbrochart
authored andcommitted
Log (instead of raise) exceptions when running as a server extension (jupyterlab#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 3d89ea7 commit c09956b

File tree

5 files changed

+108
-21
lines changed

5 files changed

+108
-21
lines changed

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

Lines changed: 10 additions & 2 deletions
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):
@@ -103,7 +103,15 @@ def initialize_handlers(self):
103103
for k, v in self.config.get(self.ystore_class.__name__, {}).items():
104104
setattr(self.ystore_class, k, v)
105105

106-
self.store = self.ystore_class(log=self.log)
106+
self.ywebsocket_server = JupyterWebsocketServer(
107+
rooms_ready=False,
108+
auto_clean_rooms=False,
109+
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,
113+
log=self.log,
114+
)
107115

108116
# self.settings is local to the ExtensionApp but here we need
109117
# the global app settings in which the file id manager will register

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

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import asyncio
77
import json
88
import time
9+
import uuid
10+
from logging import Logger
911
from typing import Any
1012

1113
from jupyter_server.auth import authorized
@@ -84,6 +86,21 @@ async def prepare(self):
8486
if self._websocket_server.room_exists(self._room_id):
8587
self.room: YRoom = await self._websocket_server.get_room(self._room_id)
8688
else:
89+
# Logging exceptions, instead of raising them here to ensure
90+
# that the y-rooms stay alive even after an exception is seen.
91+
def exception_logger(exception: Exception, log: Logger) -> bool:
92+
"""A function that catches any exceptions raised in the websocket
93+
server and logs them.
94+
95+
The protects the y-room's task group from cancelling
96+
anytime an exception is raised.
97+
"""
98+
log.error(
99+
f"Document Room Exception, (room_id={self._room_id or 'unknown'}): ",
100+
exc_info=exception,
101+
)
102+
return True
103+
87104
if self._room_id.count(":") >= 2:
88105
# DocumentRoom
89106
file_format, file_type, file_id = decode_file_path(self._room_id)
@@ -105,13 +122,18 @@ async def prepare(self):
105122
self.event_logger,
106123
ystore,
107124
self.log,
108-
self._document_save_delay,
125+
exception_handler=exception_logger,
126+
save_delay=self._document_save_delay,
109127
)
110128

111129
else:
112130
# TransientRoom
113131
# it is a transient document (e.g. awareness)
114-
self.room = TransientRoom(self._room_id, self.log)
132+
self.room = TransientRoom(
133+
self._room_id,
134+
log=self.log,
135+
exception_handler=exception_logger,
136+
)
115137

116138
try:
117139
await self._websocket_server.start_room(self.room)
@@ -223,12 +245,16 @@ async def open(self, room_id):
223245
_, _, file_id = decode_file_path(self._room_id)
224246
file = self._file_loaders[file_id]
225247

226-
# Clean up the room and delete the file loader
227-
if self.room is not None and len(self.room.clients) == 0 or self.room.clients == [self]:
228-
self._message_queue.put_nowait(b"")
229-
if self._serve_task:
230-
self._serve_task.cancel()
231-
await self._room_manager.remove_room(self._room_id)
248+
# Close websocket and propagate error.
249+
if isinstance(e, web.HTTPError):
250+
self.log.error(f"File {file.path} not found.\n{e!r}", exc_info=e)
251+
self.close(1004, f"File {file.path} not found.")
252+
else:
253+
self.log.error(f"Error initializing: {file.path}\n{e!r}", exc_info=e)
254+
self.close(
255+
1003,
256+
f"Error initializing: {file.path}. You need to close the document.",
257+
)
232258

233259
return
234260

@@ -290,7 +316,11 @@ async def on_message(self, message):
290316

291317
user = self.current_user
292318
data = json.dumps(
293-
{"sender": user.username, "timestamp": time.time(), "content": json.loads(msg)}
319+
{
320+
"sender": user.username,
321+
"timestamp": time.time(),
322+
"content": json.loads(msg),
323+
}
294324
).encode("utf8")
295325

296326
self.room.broadcast_msg(bytes([MessageType.CHAT]) + write_var_uint(len(data)) + data)
@@ -414,6 +444,22 @@ async def put(self, path):
414444

415445
status = 200
416446
idx = file_id_manager.get_id(path)
447+
if idx is not None:
448+
# index already exists
449+
self.log.info("Request for Y document '%s' with room ID: %s", path, idx)
450+
data = json.dumps(
451+
{
452+
"format": format,
453+
"type": content_type,
454+
"fileId": idx,
455+
"sessionId": SERVER_SESSION,
456+
}
457+
)
458+
self.set_status(200)
459+
return self.finish(data)
460+
461+
# try indexing
462+
idx = file_id_manager.index(path)
417463
if idx is None:
418464
# try indexing
419465
status = 201
@@ -426,7 +472,12 @@ async def put(self, path):
426472

427473
self.log.info("Request for Y document '%s' with room ID: %s", path, idx)
428474
data = json.dumps(
429-
{"format": format, "type": content_type, "fileId": idx, "sessionId": session_id}
475+
{
476+
"format": format,
477+
"type": content_type,
478+
"fileId": idx,
479+
"sessionId": SERVER_SESSION,
480+
}
430481
)
431482
self.set_status(status)
432483
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
store: 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__(room_id=room_id, store=store, log=log)
36+
super().__init__(ready=False, ystore=ystore, exception_handler=exception_handler, log=log)
3637

3738
self._file_format: str = file_format
3839
self._file_type: str = file_type
@@ -101,7 +102,9 @@ async def initialize(self) -> None:
101102
if self._document.source != model["content"]:
102103
# TODO: Delete document from the store.
103104
self._emit(
104-
LogLevel.INFO, "initialize", "The file is out-of-sync with the ystore."
105+
LogLevel.INFO,
106+
"initialize",
107+
"The file is out-of-sync with the ystore.",
105108
)
106109
self.log.info(
107110
"Content in file %s is out-of-sync with the ystore %s",
@@ -113,7 +116,9 @@ async def initialize(self) -> None:
113116
if read_from_source:
114117
self._emit(LogLevel.INFO, "load", "Content loaded from disk.")
115118
self.log.info(
116-
"Content in room %s loaded from file %s", self._room_id, self._file.path
119+
"Content in room %s loaded from file %s",
120+
self._room_id,
121+
self._file.path,
117122
)
118123
self._document.source = model["content"]
119124

@@ -266,8 +271,13 @@ async def _maybe_save_document(self, saving_document: asyncio.Task | None) -> No
266271
class TransientRoom(YRoom):
267272
"""A Y room for sharing state (e.g. awareness)."""
268273

269-
def __init__(self, room_id: str, log: Logger | None):
270-
super().__init__(log=log)
274+
def __init__(
275+
self,
276+
room_id: str,
277+
log: Logger | None = None,
278+
exception_handler: Callable[[Exception, Logger], bool] | None = None,
279+
):
280+
super().__init__(log=log, exception_handler=exception_handler)
271281

272282
self._room_id = room_id
273283

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

Lines changed: 20 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
@@ -29,9 +40,16 @@ def __init__(
2940
self,
3041
rooms_ready: bool = True,
3142
auto_clean_rooms: bool = True,
43+
exception_handler: Callable[[Exception, Logger], bool] | None = None,
3244
log: Logger | None = None,
3345
):
34-
super().__init__(rooms_ready, auto_clean_rooms, log)
46+
super().__init__(
47+
rooms_ready=rooms_ready,
48+
auto_clean_rooms=auto_clean_rooms,
49+
exception_handler=exception_handler,
50+
log=log,
51+
)
52+
self.ystore_class = ystore_class
3553
self.ypatch_nb = 0
3654
self.connected_users: dict[Any, Any] = {}
3755
# Async loop is not yet ready at the object instantiation

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)