Skip to content

Commit 29b63be

Browse files
JiahuiJiangJorge Senín
authored andcommitted
Make python language server exit when parent process dies (palantir#410)
1 parent 3b48404 commit 29b63be

File tree

6 files changed

+85
-23
lines changed

6 files changed

+85
-23
lines changed

pyls/_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,21 @@ def clip_column(column, lines, line_number):
113113
# https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md#position
114114
max_column = len(lines[line_number].rstrip('\r\n')) if len(lines) > line_number else 0
115115
return min(column, max_column)
116+
117+
118+
def is_process_alive(pid):
119+
""" Check whether the process with the given pid is still alive.
120+
121+
Args:
122+
pid (int): process ID
123+
124+
Returns:
125+
bool: False if the process is not alive or don't have permission to check, True otherwise.
126+
"""
127+
try:
128+
os.kill(pid, 0)
129+
except OSError:
130+
# no such process or process is already dead
131+
return False
132+
else:
133+
return True

pyls/config/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414

1515
class Config(object):
1616

17-
def __init__(self, root_uri, init_opts):
17+
def __init__(self, root_uri, init_opts, process_id):
1818
self._root_path = uris.to_fs_path(root_uri)
1919
self._root_uri = root_uri
2020
self._init_opts = init_opts
21+
self._process_id = process_id
2122

2223
self._settings = {}
2324
self._plugin_settings = {}
@@ -77,6 +78,10 @@ def init_opts(self):
7778
def root_uri(self):
7879
return self._root_uri
7980

81+
@property
82+
def process_id(self):
83+
return self._process_id
84+
8085
def settings(self, document_path=None):
8186
"""Settings are constructed from a few sources:
8287

pyls/python_ls.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright 2017 Palantir Technologies, Inc.
22
import logging
33
import socketserver
4+
import threading
45

56
from jsonrpc.dispatchers import MethodDispatcher
67
from jsonrpc.endpoint import Endpoint
@@ -14,6 +15,7 @@
1415

1516

1617
LINT_DEBOUNCE_S = 0.5 # 500 ms
18+
PARENT_PROCESS_WATCH_INTERVAL = 10 # 10 s
1719

1820

1921
class _StreamHandlerWrapper(socketserver.StreamRequestHandler, object):
@@ -149,10 +151,23 @@ def m_initialize(self, processId=None, rootUri=None, rootPath=None, initializati
149151
rootUri = uris.from_fs_path(rootPath) if rootPath is not None else ''
150152

151153
self.workspace = Workspace(rootUri, self._endpoint)
152-
self.config = config.Config(rootUri, initializationOptions or {})
154+
self.config = config.Config(rootUri, initializationOptions or {}, processId)
153155
self._dispatchers = self._hook('pyls_dispatchers')
154156
self._hook('pyls_initialize')
155157

158+
if processId is not None:
159+
def watch_parent_process(pid):
160+
# exist when the given pid is not alive
161+
if not _utils.is_process_alive(pid):
162+
log.info("parent process %s is not alive", pid)
163+
self.m_exit()
164+
log.debug("parent process %s is still alive", pid)
165+
threading.Timer(PARENT_PROCESS_WATCH_INTERVAL, watch_parent_process(pid)).start()
166+
167+
watching_thread = threading.Thread(target=watch_parent_process, args=[processId])
168+
watching_thread.daemon = True
169+
watching_thread.start()
170+
156171
# Get our capabilities
157172
return {'capabilities': self.capabilities()}
158173

test/fixtures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def workspace(tmpdir):
4444
@pytest.fixture
4545
def config(workspace): # pylint: disable=redefined-outer-name
4646
"""Return a config object."""
47-
return Config(workspace.root_uri, {})
47+
return Config(workspace.root_uri, {}, 0)
4848

4949

5050
@pytest.fixture

test/plugins/test_pycodestyle_lint.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def test_pycodestyle_config(workspace):
6767
doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, 'test.py'))
6868
workspace.put_document(doc_uri, DOC)
6969
doc = workspace.get_document(doc_uri)
70-
config = Config(workspace.root_uri, {})
70+
config = Config(workspace.root_uri, {}, 1234)
7171

7272
# Make sure we get a warning for 'indentation contains tabs'
7373
diags = pycodestyle_lint.pyls_lint(config, doc)

test/test_language_server.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,42 +14,66 @@ def start_client(client):
1414
client.start()
1515

1616

17+
class _ClientServer(object):
18+
""" A class to setup a client/server pair """
19+
def __init__(self):
20+
# Client to Server pipe
21+
csr, csw = os.pipe()
22+
# Server to client pipe
23+
scr, scw = os.pipe()
24+
25+
self.server_thread = Thread(target=start_io_lang_server, args=(
26+
os.fdopen(csr, 'rb'), os.fdopen(scw, 'wb'), PythonLanguageServer
27+
))
28+
self.server_thread.daemon = True
29+
self.server_thread.start()
30+
31+
self.client = PythonLanguageServer(os.fdopen(scr, 'rb'), os.fdopen(csw, 'wb'))
32+
self.client_thread = Thread(target=start_client, args=[self.client])
33+
self.client_thread.daemon = True
34+
self.client_thread.start()
35+
36+
1737
@pytest.fixture
1838
def client_server():
19-
""" A fixture to setup a client/server """
39+
""" A fixture that sets up a client/server pair and shuts down the server """
40+
client_server_pair = _ClientServer()
2041

21-
# Client to Server pipe
22-
csr, csw = os.pipe()
23-
# Server to client pipe
24-
scr, scw = os.pipe()
42+
yield client_server_pair.client
43+
44+
shutdown_response = client_server_pair.client._endpoint.request('shutdown').result(timeout=CALL_TIMEOUT)
45+
assert shutdown_response is None
46+
client_server_pair.client._endpoint.notify('exit')
2547

26-
server_thread = Thread(target=start_io_lang_server, args=(
27-
os.fdopen(csr, 'rb'), os.fdopen(scw, 'wb'), PythonLanguageServer
28-
))
29-
server_thread.daemon = True
30-
server_thread.start()
3148

32-
client = PythonLanguageServer(os.fdopen(scr, 'rb'), os.fdopen(csw, 'wb'))
33-
client_thread = Thread(target=start_client, args=[client])
34-
client_thread.daemon = True
35-
client_thread.start()
49+
@pytest.fixture
50+
def client_exited_server():
51+
""" A fixture that sets up a client/server pair and assert the server has already exited """
52+
client_server_pair = _ClientServer()
3653

37-
yield client
54+
yield client_server_pair.client
3855

39-
shutdown_response = client._endpoint.request('shutdown').result(timeout=CALL_TIMEOUT)
40-
assert shutdown_response is None
41-
client._endpoint.notify('exit')
56+
assert client_server_pair.server_thread.is_alive() is False
4257

4358

4459
def test_initialize(client_server): # pylint: disable=redefined-outer-name
4560
response = client_server._endpoint.request('initialize', {
46-
'processId': 1234,
4761
'rootPath': os.path.dirname(__file__),
4862
'initializationOptions': {}
4963
}).result(timeout=CALL_TIMEOUT)
5064
assert 'capabilities' in response
5165

5266

67+
def test_exit_with_parent_process_died(client_exited_server): # pylint: disable=redefined-outer-name
68+
# language server should have already exited before responding
69+
with pytest.raises(Exception):
70+
client_exited_server._endpoint.request('initialize', {
71+
'processId': 1234,
72+
'rootPath': os.path.dirname(__file__),
73+
'initializationOptions': {}
74+
}).result(timeout=CALL_TIMEOUT)
75+
76+
5377
def test_missing_message(client_server): # pylint: disable=redefined-outer-name
5478
with pytest.raises(JsonRpcMethodNotFound):
5579
client_server._endpoint.request('unknown_method').result(timeout=CALL_TIMEOUT)

0 commit comments

Comments
 (0)