Skip to content

Commit 2f9f0f4

Browse files
committed
REPL: generalize control channel
Now all we need for remote Ctrl+C is actually implementing the logic on the server side. We must first associate the REPL and control sessions, so that we'll know which REPL session thread t the async_raise(t, KeyboardInterrupt) should target.
1 parent 9a4bf9b commit 2f9f0f4

File tree

2 files changed

+91
-40
lines changed

2 files changed

+91
-40
lines changed

unpythonic/net/client.py

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,46 +23,73 @@ def _handle_alarm(signum, frame):
2323
signal.signal(signal.SIGALRM, _handle_alarm)
2424

2525

26-
def _make_remote_completion_client(sock):
27-
"""Make a tab completion function for a remote REPL session.
28-
29-
`buf` is a receive buffer for the message protocol (see
30-
`unpythonic.net.msg.ReceiveBuffer`).
31-
32-
`sock` must be a socket already connected to a `ControlSession`.
33-
The caller is responsible for managing the socket.
34-
35-
The return value can be used as a completer in `readline.set_completer`.
36-
"""
37-
# Messages must be processed by just one central decoder, to prevent data
38-
# races, but also to handle buffering of incoming data correctly, because
39-
# the underlying transport (TCP) has no notion of message boundaries. Thus
40-
# typically, at the seam between two messages, some of the data belonging
41-
# to the beginning of the second message has already arrived and been read
42-
# from the socket, when trying to read the last batch of data belonging to
43-
# the end of the first message.
44-
#
45-
# The source is just an abstraction over the details of how to actually
46-
# read data from a specific type of data source; buffering occurs in
47-
# ReceiveBuffer inside MessageDecoder.
48-
decoder = MessageDecoder(socketsource(sock))
49-
def complete(text, state):
26+
# Messages must be processed by just one central decoder, to prevent data
27+
# races, but also to handle buffering of incoming data correctly, because
28+
# the underlying transport (TCP) has no notion of message boundaries. Thus
29+
# typically, at the seam between two messages, some of the data belonging
30+
# to the beginning of the second message has already arrived and been read
31+
# from the socket, when trying to read the last batch of data belonging to
32+
# the end of the first message.
33+
class ControlClient:
34+
# TODO: manage the socket internally. We need to make this into a context manager,
35+
# so that __enter__ can set up the socket and __exit__ can tear it down.
36+
def __init__(self, sock):
37+
"""Initialize session for control channel (client side).
38+
39+
`sock` must be a socket already connected to a `ControlSession` (on the
40+
server side). The caller is responsible for managing the socket.
41+
"""
42+
self.sock = sock
43+
# The source is just an abstraction over the details of how to actually
44+
# read data from a specific type of data source; buffering occurs in
45+
# ReceiveBuffer inside MessageDecoder.
46+
self.decoder = MessageDecoder(socketsource(sock))
47+
48+
# TODO: Refactor low-level _send, _recv functions.
49+
# TODO: Always JSON encode. Don't send just strings.
50+
51+
def complete(self, text, state):
52+
"""Tab-complete in a remote REPL session.
53+
54+
This is a completer for `readline.set_completer`.
55+
"""
5056
try:
51-
request = {"text": text, "state": state}
57+
request = {"command": "TabComplete", "text": text, "state": state}
5258
data_out = json.dumps(request).encode("utf-8")
53-
sock.sendall(encodemsg(data_out))
54-
data_in = decoder.decode()
59+
self.sock.sendall(encodemsg(data_out))
60+
data_in = self.decoder.decode()
5561
response = data_in.decode("utf-8")
5662
# print("text '{}' state '{}' reply '{}'".format(text, state, response))
5763
if not response:
58-
print("Control server exited, socket closed!")
64+
print("Socket closed by other end.")
5965
return None
6066
reply = json.loads(response)
6167
return reply
6268
except BaseException as err:
6369
print(type(err), err)
6470
return None
65-
return complete
71+
72+
def send_kbinterrupt(self):
73+
"""Request the server to perform a `KeyboardInterrupt` (Ctrl+C).
74+
75+
The `KeyboardInterrupt` occurs in the REPL session associated with
76+
this control channel.
77+
78+
Returns `True` on success, `False` on failure.
79+
"""
80+
try:
81+
request = {"command": "KeyboardInterrupt"}
82+
data_out = json.dumps(request).encode("utf-8")
83+
self.sock.sendall(encodemsg(data_out))
84+
data_in = self.decoder.decode()
85+
response = data_in.decode("utf-8")
86+
if not response:
87+
print("Socket closed by other end.")
88+
return False
89+
return response == "ok"
90+
except BaseException as err:
91+
print(type(err), err)
92+
return False
6693

6794

6895
def connect(addrspec):
@@ -91,8 +118,8 @@ class SessionExit(Exception):
91118

92119
# Set a custom tab completer for readline.
93120
# https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
94-
completer = _make_remote_completion_client(csock)
95-
readline.set_completer(completer)
121+
controller = ControlClient(csock)
122+
readline.set_completer(controller.complete)
96123
readline.parse_and_bind("tab: complete")
97124

98125
def sock_to_stdout():
@@ -134,8 +161,7 @@ def sock_to_stdout():
134161
inp = input()
135162
sock.sendall((inp + "\n").encode("utf-8"))
136163
except KeyboardInterrupt:
137-
# TODO: refactor control channel logic; send command on control channel
138-
sock.sendall("\x03\n".encode("utf-8"))
164+
controller.send_kbinterrupt()
139165
except EOFError:
140166
print("replclient: Ctrl+D pressed, asking server to disconnect.")
141167
print("replclient: if the server does not respond, press Ctrl+C to force.")

unpythonic/net/server.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,20 +153,45 @@ class ClientExit(Exception):
153153
try:
154154
server_print("Control channel for {} opened.".format(client_address_str))
155155
# TODO: fancier backend? See examples in https://pymotw.com/3/readline/
156-
backend = rlcompleter.Completer(_console_locals_namespace)
156+
completer_backend = rlcompleter.Completer(_console_locals_namespace)
157157
sock = self.request
158158
decoder = MessageDecoder(socketsource(sock))
159159
while True:
160-
# TODO: Add support for requests to inject Ctrl+C. Needs a command protocol layer.
161-
# TODO: Can use JSON dictionaries; we're guaranteed to get whole messages only.
160+
# A message sent by the client contains exactly one request.
161+
# A request is a UTF-8 encoded JSON dictionary with one
162+
# compulsory field: "command". It must contain one of the
163+
# recognized command names as `str`.
164+
#
165+
# Existence and type of any other fields depends on each
166+
# individual command. This server source code is the official
167+
# documentation of this small app-level protocol.
162168
data_in = decoder.decode()
163169
if not data_in:
164170
raise ClientExit
165171
request = json.loads(data_in.decode("utf-8"))
166-
reply = backend.complete(request["text"], request["state"])
167-
# server_print(request, reply)
168-
data_out = json.dumps(reply).encode("utf-8")
169-
sock.sendall(encodemsg(data_out))
172+
173+
if request["command"] == "TabComplete":
174+
reply = completer_backend.complete(request["text"], request["state"])
175+
# server_print(request, reply)
176+
data_out = json.dumps(reply).encode("utf-8")
177+
sock.sendall(encodemsg(data_out))
178+
179+
elif request["command"] == "KeyboardInterrupt":
180+
errmsg = "TODO: remote Ctrl+C request received; implement the server side!"
181+
server_print(errmsg)
182+
183+
# TODO: Once we pair the REPL and control sessions, this will be as easy as:
184+
# async_raise(t, KeyboardInterrupt)
185+
# Here t is the threading.Thread instance in which *our* REPL session is running.
186+
187+
# Acknowledge the request, the client wants to know whether it worked.
188+
# When this is implemented, upon success, we should send "ok".
189+
data_out = "not_implemented".encode("utf-8")
190+
sock.sendall(encodemsg(data_out))
191+
192+
# TODO: always send a JSON encoded reply, so that even if the
193+
# command was not understood, we can inform the client about that.
194+
170195
except ClientExit:
171196
server_print("Control channel for {} closed.".format(client_address_str))
172197
except BaseException as err:

0 commit comments

Comments
 (0)