Skip to content

Commit b67617c

Browse files
committed
add a server status endpoint
1 parent 21ff0cc commit b67617c

File tree

8 files changed

+198
-31
lines changed

8 files changed

+198
-31
lines changed

py_src/jupyter_lsp/handlers.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
""" tornado handler for managing and communicating with language servers
22
"""
3+
from typing import Optional, Text
4+
35
from notebook.base.handlers import IPythonHandler
46
from notebook.base.zmqhandlers import WebSocketHandler, WebSocketMixin
57
from tornado.ioloop import IOLoop
68

9+
from .manager import LanguageServerManager
710

8-
class LanguageServerWebSocketHandler(WebSocketMixin, WebSocketHandler, IPythonHandler):
9-
""" Setup tornado websocket to route to language server sessions
10-
"""
1111

12-
language = None
13-
manager = None
12+
class BaseHandler(IPythonHandler):
13+
manager: LanguageServerManager = None
1414

15-
def initialize(self, manager):
15+
def initialize(self, manager: LanguageServerManager):
1616
self.manager = manager
1717

18+
19+
class LanguageServerWebSocketHandler(WebSocketMixin, WebSocketHandler, BaseHandler):
20+
""" Setup tornado websocket to route to language server sessions
21+
"""
22+
23+
language: Optional[Text] = None
24+
1825
def open(self, language):
1926
self.language = language
2027
self.manager.subscribe(self)
@@ -31,3 +38,15 @@ def send(message):
3138
def on_close(self):
3239
self.manager.unsubscribe(self)
3340
self.log.debug("[{0: >16}] Closed a handler".format(self.language))
41+
42+
43+
class LanguageServersHandler(BaseHandler):
44+
def get(self):
45+
self.finish(
46+
{
47+
"sessions": sorted(
48+
[session.to_json() for session in self.manager.sessions.values()],
49+
key=lambda session: session["languages"],
50+
)
51+
}
52+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import json
2+
import pathlib
3+
4+
import jsonschema
5+
6+
HERE = pathlib.Path(__file__).parent
7+
8+
9+
def servers_schema():
10+
return jsonschema.validators.Draft7Validator(
11+
json.loads((HERE / "servers.schema.json").read_text())
12+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$ref": "#/definitions/servers-response",
4+
"definitions": {
5+
"servers-response": {
6+
"type": "object",
7+
"properties": {
8+
"sessions": {
9+
"type": "array",
10+
"items": { "$ref": "#/definitions/session" }
11+
}
12+
},
13+
"required": ["sessions"]
14+
},
15+
"nullable-date-time": {
16+
"oneOf": [{ "type": "string", "format": "date-time" }, { "type": "null" }]
17+
},
18+
"session": {
19+
"additionalProperties": false,
20+
"required": [
21+
"languages",
22+
"handler_count",
23+
"status",
24+
"last_server_message_at",
25+
"last_handler_message_at"
26+
],
27+
"properties": {
28+
"languages": {
29+
"type": "array",
30+
"items": {"type": "string"},
31+
"uniqueItems": true,
32+
"minItems": 1
33+
},
34+
"handler_count": { "type": "integer", "minValue": 0 },
35+
"status": {
36+
"type": "string",
37+
"enum": ["not_started", "starting", "started", "stopping", "stopped"]
38+
},
39+
"last_server_message_at": {
40+
"$ref": "#/definitions/nullable-date-time"
41+
},
42+
"last_handler_message_at": {
43+
"$ref": "#/definitions/nullable-date-time"
44+
}
45+
}
46+
}
47+
}
48+
}

py_src/jupyter_lsp/serverextension.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import traitlets
66
from notebook.utils import url_path_join as ujoin
77

8-
from .handlers import LanguageServerWebSocketHandler
8+
from .handlers import LanguageServersHandler, LanguageServerWebSocketHandler
99
from .manager import LanguageServerManager
1010

1111

@@ -20,13 +20,16 @@ def load_jupyter_server_extension(nbapp):
2020
json.dumps(manager.language_servers, indent=2, sort_keys=True)
2121
)
2222
)
23+
24+
lsp_url = ujoin(nbapp.base_url, "lsp")
25+
re_langs = "(?P<language>.*)"
26+
27+
opts = {"manager": nbapp.language_server_manager}
28+
2329
nbapp.web_app.add_handlers(
2430
".*",
2531
[
26-
(
27-
ujoin(nbapp.base_url, "lsp", "(?P<language>.*)"),
28-
LanguageServerWebSocketHandler,
29-
{"manager": nbapp.language_server_manager},
30-
)
32+
(lsp_url, LanguageServersHandler, opts),
33+
(ujoin(lsp_url, re_langs), LanguageServerWebSocketHandler, opts),
3134
],
3235
)

py_src/jupyter_lsp/session.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import asyncio
44
import atexit
55
import subprocess
6+
from datetime import datetime, timezone
67

78
from tornado.queues import Queue
89
from tornado.websocket import WebSocketHandler
9-
from traitlets import Bunch, Instance, List, Set, Unicode, observe
10+
from traitlets import Bunch, Instance, List, Set, Unicode, UseEnum, observe
1011
from traitlets.config import LoggingConfigurable
1112

1213
from . import stdio
14+
from .types import SessionStatus
1315

1416

1517
class LanguageServerSession(LoggingConfigurable):
@@ -42,6 +44,9 @@ class LanguageServerSession(LoggingConfigurable):
4244
default_value=[],
4345
help="the currently subscribed websockets",
4446
)
47+
status = UseEnum(SessionStatus, default_value=SessionStatus.NOT_STARTED)
48+
last_handler_message_at = Instance(datetime, allow_none=True)
49+
last_server_message_at = Instance(datetime, allow_none=True)
4550

4651
_tasks = None
4752

@@ -56,10 +61,24 @@ def __repr__(self): # pragma: no cover
5661
self.languages, self.argv
5762
)
5863

64+
def to_json(self):
65+
return dict(
66+
languages=self.languages,
67+
handler_count=len(self.handlers),
68+
status=self.status.value,
69+
last_server_message_at=self.last_server_message_at.isoformat()
70+
if self.last_server_message_at
71+
else None,
72+
last_handler_message_at=self.last_handler_message_at.isoformat()
73+
if self.last_handler_message_at
74+
else None,
75+
)
76+
5977
def initialize(self):
6078
""" (re)initialize a language server session
6179
"""
6280
self.stop()
81+
self.status = SessionStatus.STARTING
6382
self.init_queues()
6483
self.init_process()
6584
self.init_writer()
@@ -71,9 +90,14 @@ def initialize(self):
7190
for coro in [self._read_lsp, self._write_lsp, self._broadcast_from_lsp]
7291
]
7392

93+
self.status = SessionStatus.STARTED
94+
7495
def stop(self):
7596
""" clean up all of the state of the session
7697
"""
98+
99+
self.status = SessionStatus.STOPPING
100+
77101
if self.process:
78102
self.process.terminate()
79103
self.process = None
@@ -87,6 +111,8 @@ def stop(self):
87111
if self._tasks:
88112
[task.cancel() for task in self._tasks]
89113

114+
self.status = SessionStatus.STOPPED
115+
90116
@observe("handlers")
91117
def _on_handlers(self, change: Bunch):
92118
""" re-initialize if someone starts listening, or stop if nobody is
@@ -99,8 +125,12 @@ def _on_handlers(self, change: Bunch):
99125
def write(self, message):
100126
""" wrapper around the write queue to keep it mostly internal
101127
"""
128+
self.last_handler_message_at = self.now()
102129
self.to_lsp.put_nowait(message)
103130

131+
def now(self):
132+
return datetime.now(timezone.utc)
133+
104134
def init_process(self):
105135
""" start the language server subprocess
106136
"""
@@ -139,6 +169,7 @@ async def _broadcast_from_lsp(self):
139169
server
140170
"""
141171
async for msg in self.from_lsp:
172+
self.last_server_message_at = self.now()
142173
for handler in self.handlers:
143174
handler.write_message(msg)
144175
self.from_lsp.task_done()

py_src/jupyter_lsp/tests/conftest.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
# local imports
1111
from jupyter_lsp import LanguageServerManager
12-
from jupyter_lsp.handlers import LanguageServerWebSocketHandler
12+
from jupyter_lsp.handlers import LanguageServersHandler, LanguageServerWebSocketHandler
13+
from jupyter_lsp.schema import servers_schema
1314

1415
# these should always be available in a test environment ()
1516
KNOWN_LANGUAGES = [
@@ -38,6 +39,8 @@
3839

3940
KNOWN_UNKNOWN_LANGUAGES = ["cobol"]
4041

42+
SERVERS_SCHEMA = servers_schema()
43+
4144

4245
@fixture
4346
def manager() -> LanguageServerManager:
@@ -55,10 +58,12 @@ def known_unknown_language(request):
5558

5659

5760
@fixture
58-
def handler(manager):
59-
handler = MockWebsocketHandler()
61+
def handlers(manager):
62+
ws_handler = MockWebsocketHandler()
63+
ws_handler.initialize(manager)
64+
handler = MockHandler()
6065
handler.initialize(manager)
61-
return handler
66+
return handler, ws_handler
6267

6368

6469
@fixture
@@ -100,5 +105,15 @@ def write_message(self, message: Text) -> None:
100105
self._messages_wrote.put_nowait(message)
101106

102107

108+
class MockHandler(LanguageServersHandler):
109+
_payload = None
110+
111+
def __init__(self):
112+
pass
113+
114+
def finish(self, payload):
115+
self._payload = payload
116+
117+
103118
class MockNotebookApp(NotebookApp):
104119
pass

py_src/jupyter_lsp/tests/test_session.py

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,69 @@
22

33
import pytest
44

5+
from .conftest import SERVERS_SCHEMA
6+
7+
8+
def assert_status_set(handler, expected_statuses, language=None):
9+
handler.get()
10+
payload = handler._payload
11+
12+
SERVERS_SCHEMA.validate(payload)
13+
14+
statuses = {
15+
s["status"]
16+
for s in payload["sessions"]
17+
if language is None or language in s["languages"]
18+
}
19+
assert statuses == expected_statuses
20+
521

622
@pytest.mark.asyncio
7-
async def test_start_known(known_language, handler, jsonrpc_init_msg):
23+
async def test_start_known(known_language, handlers, jsonrpc_init_msg):
824
""" will a process start for a known language if a handler starts listening?
925
"""
26+
handler, ws_handler = handlers
1027
manager = handler.manager
28+
1129
manager.initialize()
12-
handler.open(known_language)
13-
sessions = list(manager.sessions_for_handler(handler))
30+
31+
assert_status_set(handler, {"not_started"})
32+
33+
ws_handler.open(known_language)
34+
sessions = list(manager.sessions_for_handler(ws_handler))
1435
session = sessions[0]
1536
assert session.process is not None
1637

17-
handler.on_message(jsonrpc_init_msg)
38+
assert_status_set(handler, {"started"}, known_language)
39+
40+
ws_handler.on_message(jsonrpc_init_msg)
1841

1942
try:
20-
await asyncio.wait_for(handler._messages_wrote.get(), 20)
21-
handler._messages_wrote.task_done()
43+
await asyncio.wait_for(ws_handler._messages_wrote.get(), 20)
44+
ws_handler._messages_wrote.task_done()
2245
finally:
23-
handler.on_close()
46+
ws_handler.on_close()
2447

25-
assert not list(manager.sessions_for_handler(handler))
48+
assert not list(manager.sessions_for_handler(ws_handler))
2649
assert not session.handlers
2750
assert not session.process
2851

52+
assert_status_set(handler, {"stopped"}, known_language)
53+
assert_status_set(handler, {"stopped", "not_started"})
54+
2955

3056
@pytest.mark.asyncio
31-
async def test_start_unknown(known_unknown_language, handler, jsonrpc_init_msg):
57+
async def test_start_unknown(known_unknown_language, handlers, jsonrpc_init_msg):
3258
""" will a process not start for an unknown if a handler starts listening?
3359
"""
60+
handler, ws_handler = handlers
3461
manager = handler.manager
3562
manager.initialize()
36-
handler.open(known_unknown_language)
37-
assert not list(manager.sessions_for_handler(handler))
63+
ws_handler.open(known_unknown_language)
64+
assert not list(manager.sessions_for_handler(ws_handler))
3865

39-
handler.on_message(jsonrpc_init_msg)
66+
ws_handler.on_message(jsonrpc_init_msg)
4067

41-
handler.on_close()
68+
ws_handler.on_close()
4269

43-
assert not list(manager.sessions_for_handler(handler))
70+
assert not list(manager.sessions_for_handler(ws_handler))

py_src/jupyter_lsp/types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
""" API used by spec finders and manager
22
"""
3+
import enum
34
import pathlib
45
import shutil
56
import sys
@@ -14,6 +15,17 @@
1415
KeyedLanguageServerSpecs = Dict[Text, LanguageServerSpec]
1516

1617

18+
class SessionStatus(enum.Enum):
19+
""" States in which a language server session can be
20+
"""
21+
22+
NOT_STARTED = "not_started"
23+
STARTING = "starting"
24+
STARTED = "started"
25+
STOPPING = "stopping"
26+
STOPPED = "stopped"
27+
28+
1729
class LanguageServerManagerAPI(LoggingConfigurable):
1830
""" Public API that can be used for python-based spec finders
1931
"""

0 commit comments

Comments
 (0)