Skip to content

Commit dd1fba1

Browse files
committed
Refactor transport processors
1 parent 6ab3fca commit dd1fba1

File tree

4 files changed

+108
-102
lines changed

4 files changed

+108
-102
lines changed

plugin/core/transports.py

Lines changed: 84 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import closing
66
from functools import partial
77
from queue import Queue
8-
import http
8+
import http.client
99
import json
1010
import multiprocessing.connection
1111
import os
@@ -49,70 +49,99 @@ def on_stderr_message(self, message: str) -> None:
4949

5050
class AbstractProcessor(Generic[T]):
5151

52-
def write_data(self, writer: IO[bytes], data: T, is_node_ipc: bool) -> None:
52+
def write_data(self, data: T) -> None:
5353
raise NotImplementedError()
5454

55-
def read_data(self, reader: IO[bytes], is_node_ipc: bool) -> Optional[T]:
55+
def read_data(self) -> Optional[T]:
5656
raise NotImplementedError()
5757

5858

59-
class JsonRpcProcessor(AbstractProcessor[Dict[str, Any]]):
59+
def encode_payload(data: Dict[str, Any]) -> bytes:
60+
return json.dumps(
61+
data,
62+
ensure_ascii=False,
63+
check_circular=False,
64+
separators=(',', ':')
65+
).encode('utf-8')
6066

61-
def write_data(self, writer: IO[bytes], data: Dict[str, Any], is_node_ipc: bool) -> None:
62-
body = self._encode(data)
63-
if not is_node_ipc:
64-
writer.writelines(("Content-Length: {}\r\n\r\n".format(len(body)).encode('ascii'), body))
65-
else:
66-
writer.write(body + b"\n")
6767

68-
def read_data(self, reader: IO[bytes], is_node_ipc: bool) -> Optional[Dict[str, Any]]:
69-
if not is_node_ipc:
70-
headers = http.client.parse_headers(reader) # type: ignore
71-
try:
72-
body = reader.read(int(headers.get("Content-Length")))
73-
except TypeError:
74-
# Expected error on process stopping. Stop the read loop.
75-
raise StopLoopError()
76-
else:
77-
body = reader.readline()
68+
def decode_payload(message: bytes) -> Optional[Dict[str, Any]]:
69+
try:
70+
return json.loads(message.decode('utf-8'))
71+
except Exception as ex:
72+
exception_log("JSON decode error", ex)
73+
return None
74+
75+
76+
class StandardProcessor(AbstractProcessor[Dict[str, Any]]):
7877

78+
def __init__(self, reader: Optional[IO[bytes]], writer: IO[bytes]):
79+
if not reader or not writer:
80+
raise RuntimeError('Failed initializing transport: reader: {}, writer: {}'.format(reader, writer))
81+
self._reader = reader
82+
self._writer = writer
83+
84+
def write_data(self, data: Dict[str, Any]) -> None:
85+
body = encode_payload(data)
86+
self._writer.writelines(("Content-Length: {}\r\n\r\n".format(len(body)).encode('ascii'), body))
87+
self._writer.flush()
88+
89+
def read_data(self) -> Optional[Dict[str, Any]]:
90+
headers = http.client.parse_headers(self._reader) # type: ignore
7991
try:
80-
return self._decode(body)
81-
except Exception as ex:
82-
exception_log("JSON decode error", ex)
83-
return None
84-
85-
@staticmethod
86-
def _encode(data: Dict[str, Any]) -> bytes:
87-
return json.dumps(
88-
data,
89-
ensure_ascii=False,
90-
check_circular=False,
91-
separators=(',', ':')
92-
).encode('utf-8')
93-
94-
@staticmethod
95-
def _decode(message: bytes) -> Dict[str, Any]:
96-
return json.loads(message.decode('utf-8'))
92+
body = self._reader.read(int(headers.get("Content-Length")))
93+
except TypeError:
94+
# Expected error on process stopping. Stop the read loop.
95+
raise StopLoopError()
96+
return decode_payload(body)
97+
98+
99+
class NodeIpcProcessor(AbstractProcessor[Dict[str, Any]]):
100+
_buf = bytearray()
101+
_lines = 0
102+
103+
def __init__(self, conn: multiprocessing.connection._ConnectionBase):
104+
self._conn = conn
105+
106+
def write_data(self, data: Dict[str, Any]) -> None:
107+
body = encode_payload(data) + b"\n"
108+
while len(body):
109+
n = self._conn._write(self._conn.fileno(), body) # type: ignore
110+
body = body[n:]
111+
112+
def read_data(self) -> Optional[Dict[str, Any]]:
113+
while self._lines == 0:
114+
chunk = self._conn._read(self._conn.fileno(), 65536) # type: ignore
115+
if len(chunk) == 0:
116+
# EOF reached: https://docs.python.org/3/library/os.html#os.read
117+
raise StopLoopError()
118+
119+
self._buf += chunk
120+
self._lines += chunk.count(b'\n')
121+
122+
self._lines -= 1
123+
message, _, self._buf = self._buf.partition(b'\n')
124+
return decode_payload(message)
97125

98126

99127
class ProcessTransport(Transport[T]):
100128

101-
def __init__(self, name: str, process: subprocess.Popen, socket: Optional[socket.socket], reader: IO[bytes],
102-
writer: IO[bytes], stderr: Optional[IO[bytes]], processor: AbstractProcessor[T],
103-
callback_object: TransportCallbacks[T], is_node_ipc: bool) -> None:
129+
def __init__(self,
130+
name: str,
131+
process: subprocess.Popen,
132+
socket: Optional[socket.socket],
133+
stderr: Optional[IO[bytes]],
134+
processor: AbstractProcessor[T],
135+
callback_object: TransportCallbacks[T]) -> None:
104136
self._closed = False
105137
self._process = process
106138
self._socket = socket
107-
self._reader = reader
108-
self._writer = writer
109139
self._stderr = stderr
110140
self._processor = processor
111141
self._reader_thread = threading.Thread(target=self._read_loop, name='{}-reader'.format(name))
112142
self._writer_thread = threading.Thread(target=self._write_loop, name='{}-writer'.format(name))
113143
self._stderr_thread = threading.Thread(target=self._stderr_loop, name='{}-stderr'.format(name))
114144
self._callback_object = weakref.ref(callback_object)
115-
self._is_node_ipc = is_node_ipc
116145
self._send_queue = Queue(0) # type: Queue[Union[T, None]]
117146
self._reader_thread.start()
118147
self._writer_thread.start()
@@ -144,8 +173,8 @@ def __del__(self) -> None:
144173

145174
def _read_loop(self) -> None:
146175
try:
147-
while self._reader:
148-
payload = self._processor.read_data(self._reader, self._is_node_ipc)
176+
while True:
177+
payload = self._processor.read_data()
149178
if payload is None:
150179
continue
151180

@@ -194,13 +223,11 @@ def invoke() -> None:
194223
def _write_loop(self) -> None:
195224
exception = None # type: Optional[Exception]
196225
try:
197-
while self._writer:
226+
while True:
198227
d = self._send_queue.get()
199228
if d is None:
200229
break
201-
self._processor.write_data(self._writer, d, self._is_node_ipc)
202-
if not self._is_node_ipc:
203-
self._writer.flush()
230+
self._processor.write_data(d)
204231
except (BrokenPipeError, AttributeError):
205232
pass
206233
except Exception as ex:
@@ -228,35 +255,6 @@ def _stderr_loop(self) -> None:
228255
self._send_queue.put_nowait(None)
229256

230257

231-
# Can be a singleton since it doesn't hold any state.
232-
json_rpc_processor = JsonRpcProcessor()
233-
234-
235-
class NodeIpcIO():
236-
_buf = bytearray()
237-
_lines = 0
238-
239-
def __init__(self, conn: multiprocessing.connection._ConnectionBase):
240-
self._conn = conn
241-
242-
# https://github.com/python/cpython/blob/330f1d58282517bdf1f19577ab9317fa9810bf95/Lib/multiprocessing/connection.py#L378-L392
243-
def readline(self) -> bytearray:
244-
while self._lines == 0:
245-
chunk = self._conn._read(self._conn.fileno(), 65536) # type: ignore
246-
self._buf += chunk
247-
self._lines += chunk.count(b'\n')
248-
249-
self._lines -= 1
250-
line, _, self._buf = self._buf.partition(b'\n')
251-
return line
252-
253-
# https://github.com/python/cpython/blob/330f1d58282517bdf1f19577ab9317fa9810bf95/Lib/multiprocessing/connection.py#L369-L376
254-
def write(self, data: bytes) -> None:
255-
while len(data):
256-
n = self._conn._write(self._conn.fileno(), data) # type: ignore
257-
data = data[n:]
258-
259-
260258
def create_transport(config: TransportConfig, cwd: Optional[str],
261259
callback_object: TransportCallbacks) -> Transport[Dict[str, Any]]:
262260
stderr = subprocess.PIPE
@@ -292,24 +290,22 @@ def start_subprocess() -> subprocess.Popen:
292290
config.listener_socket,
293291
start_subprocess
294292
)
293+
processor = StandardProcessor(reader, writer) # type: AbstractProcessor
295294
else:
296295
process = start_subprocess()
297296
if config.tcp_port:
298297
sock = _connect_tcp(config.tcp_port)
299298
if sock is None:
300299
raise RuntimeError("Failed to connect on port {}".format(config.tcp_port))
301-
reader = sock.makefile('rwb') # type: ignore
302-
writer = reader
300+
reader = writer = sock.makefile('rwb')
301+
processor = StandardProcessor(reader, writer)
303302
elif not config.node_ipc:
304-
reader = process.stdout # type: ignore
305-
writer = process.stdin # type: ignore
303+
processor = StandardProcessor(process.stdout, process.stdin) # type: ignore
306304
else:
307-
reader = writer = NodeIpcIO(config.node_ipc.parent_conn) # type: ignore
308-
if not reader or not writer:
309-
raise RuntimeError('Failed initializing transport: reader: {}, writer: {}'.format(reader, writer))
305+
processor = NodeIpcProcessor(config.node_ipc.parent_conn)
306+
310307
stderr_reader = process.stdout if config.node_ipc else process.stderr
311-
return ProcessTransport(config.name, process, sock, reader, writer, stderr_reader, json_rpc_processor,
312-
callback_object, bool(config.node_ipc))
308+
return ProcessTransport(config.name, process, sock, stderr_reader, processor, callback_object)
313309

314310

315311
_subprocesses = weakref.WeakSet() # type: weakref.WeakSet[subprocess.Popen]
@@ -403,8 +399,7 @@ def start_in_background(d: _SubprocessData) -> None:
403399
# Await one client connection (blocking!)
404400
sock, _ = listener_socket.accept()
405401
thread.join()
406-
reader = sock.makefile('rwb') # type: IO[bytes]
407-
writer = reader
402+
reader = writer = sock.makefile('rwb')
408403
assert data.process
409404
return data.process, sock, reader, writer
410405

plugin/core/types.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ def map_from_remote_to_local(self, uri: str) -> Tuple[str, bool]:
607607
return _translate_path(uri, self._remote, self._local)
608608

609609

610-
NodeIpc = collections.namedtuple('NodeIpc', 'parent_conn,child_conn')
610+
NodeIpcPipe = collections.namedtuple('NodeIpcPipe', 'parent_conn,child_conn')
611611

612612

613613
class TransportConfig:
@@ -620,15 +620,10 @@ def __init__(
620620
tcp_port: Optional[int],
621621
env: Dict[str, str],
622622
listener_socket: Optional[socket.socket],
623-
node_ipc: Optional[NodeIpc]
623+
node_ipc: Optional[NodeIpcPipe]
624624
) -> None:
625625
if not command and not tcp_port:
626626
raise ValueError('neither "command" nor "tcp_port" is provided; cannot start a language server')
627-
if node_ipc and (tcp_port or listener_socket):
628-
raise ValueError(
629-
'"tcp_port" and "listener_socket" can\'t be provided in "--node-ipc" mode; ' +
630-
'cannot start a language server'
631-
)
632627
self.name = name
633628
self.command = command
634629
self.tcp_port = tcp_port
@@ -644,6 +639,7 @@ def __init__(self,
644639
priority_selector: Optional[str] = None,
645640
schemes: Optional[List[str]] = None,
646641
command: Optional[List[str]] = None,
642+
use_node_ipc: bool = False,
647643
binary_args: Optional[List[str]] = None, # DEPRECATED
648644
tcp_port: Optional[int] = None,
649645
auto_complete_selector: Optional[str] = None,
@@ -668,6 +664,7 @@ def __init__(self,
668664
else:
669665
assert isinstance(binary_args, list)
670666
self.command = binary_args
667+
self.use_node_ipc = use_node_ipc
671668
self.tcp_port = tcp_port
672669
self.auto_complete_selector = auto_complete_selector
673670
self.enabled = enabled
@@ -701,9 +698,10 @@ def from_sublime_settings(cls, name: str, s: sublime.Settings, file: str) -> "Cl
701698
priority_selector=_read_priority_selector(s),
702699
schemes=s.get("schemes"),
703700
command=read_list_setting(s, "command", []),
701+
use_node_ipc=bool(s.get("use_node_ipc", False)),
704702
tcp_port=s.get("tcp_port"),
705703
auto_complete_selector=s.get("auto_complete_selector"),
706-
# Default to True, because an LSP plugin is enabled iff it is enabled as a Sublime package.
704+
# Default to True, because an LSP plugin is enabled if it is enabled as a Sublime package.
707705
enabled=bool(s.get("enabled", True)),
708706
init_options=init_options,
709707
settings=settings,
@@ -731,6 +729,7 @@ def from_dict(cls, name: str, d: Dict[str, Any]) -> "ClientConfig":
731729
priority_selector=_read_priority_selector(d),
732730
schemes=schemes,
733731
command=d.get("command", []),
732+
use_node_ipc=d.get("use_node_ipc", False),
734733
tcp_port=d.get("tcp_port"),
735734
auto_complete_selector=d.get("auto_complete_selector"),
736735
enabled=d.get("enabled", False),
@@ -758,6 +757,7 @@ def from_config(cls, src_config: "ClientConfig", override: Dict[str, Any]) -> "C
758757
priority_selector=_read_priority_selector(override) or src_config.priority_selector,
759758
schemes=override.get("schemes", src_config.schemes),
760759
command=override.get("command", src_config.command),
760+
use_node_ipc=override.get("use_node_ipc", src_config.use_node_ipc),
761761
tcp_port=override.get("tcp_port", src_config.tcp_port),
762762
auto_complete_selector=override.get("auto_complete_selector", src_config.auto_complete_selector),
763763
enabled=override.get("enabled", src_config.enabled),
@@ -803,8 +803,8 @@ def resolve_transport_config(self, variables: Dict[str, str]) -> TransportConfig
803803
else:
804804
env[key] = sublime.expand_variables(value, variables)
805805
node_ipc = None
806-
if '--node-ipc' in command:
807-
node_ipc = NodeIpc(*multiprocessing.Pipe())
806+
if self.use_node_ipc:
807+
node_ipc = NodeIpcPipe(*multiprocessing.Pipe())
808808
env["NODE_CHANNEL_FD"] = str(node_ipc.child_conn.fileno())
809809
return TransportConfig(self.name, command, tcp_port, env, listener_socket, node_ipc)
810810

sublime-package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@
7575
},
7676
"markdownDescription": "The command to start the language server."
7777
},
78+
"ClientUseNodeIpc": {
79+
"type": "boolean",
80+
"default": false,
81+
"markdownDescription": "Communicate with the language server over Node.js IPC. This lets the server print to stdout without disrupting the LSP communication. It's non-standard, but is used by VSCode. The command must be adjusted accordingly, e.g. `--stdio` must be replaced with `--node-ipc` in case of vscode-eslint. `tcp_port` is ignored if this is enabled."
82+
},
7883
"ClientEnabled": {
7984
"type": "boolean",
8085
"default": false,
@@ -156,6 +161,9 @@
156161
"command": {
157162
"$ref": "sublime://settings/LSP#/definitions/ClientCommand"
158163
},
164+
"use_node_ipc": {
165+
"$ref": "sublime://settings/LSP#/definitions/ClientUseNodeIpc"
166+
},
159167
"enabled": {
160168
"$ref": "sublime://settings/LSP#/definitions/ClientEnabled"
161169
},
@@ -555,6 +563,9 @@
555563
"command": {
556564
"$ref": "sublime://settings/LSP#/definitions/ClientCommand"
557565
},
566+
"use_node_ipc": {
567+
"$ref": "sublime://settings/LSP#/definitions/ClientUseNodeIpc"
568+
},
558569
"enabled": {
559570
"$ref": "sublime://settings/LSP#/definitions/ClientEnabled"
560571
},

tests/test_protocol.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from LSP.plugin.core.protocol import Point, Position, Range, RangeLsp, Request, Notification
2-
from LSP.plugin.core.transports import JsonRpcProcessor
2+
from LSP.plugin.core.transports import encode_payload, decode_payload
33
import unittest
44

55

@@ -129,9 +129,9 @@ def test_extend(self) -> None:
129129

130130
class EncodingTests(unittest.TestCase):
131131
def test_encode(self) -> None:
132-
encoded = JsonRpcProcessor._encode({"text": "😃"})
132+
encoded = encode_payload({"text": "😃"})
133133
self.assertEqual(encoded, b'{"text":"\xF0\x9F\x98\x83"}')
134-
decoded = JsonRpcProcessor._decode(encoded)
134+
decoded = decode_payload(encoded)
135135
self.assertEqual(decoded, {"text": "😃"})
136136

137137

0 commit comments

Comments
 (0)