Skip to content

Commit 9b68f95

Browse files
committed
REPL: add remote Ctrl+C support
1 parent de96075 commit 9b68f95

File tree

2 files changed

+181
-81
lines changed

2 files changed

+181
-81
lines changed

unpythonic/net/client.py

Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ def _handle_alarm(signum, frame):
3030
# to configure the client's prompt detector before opening the primary channel.
3131
# - To keep us netcat compatible, handshake is optional. It is legal
3232
# to just immediately connect on the primary channel, in which case
33-
# there will be no control channel associated with the REPL session.
34-
# - 2: Open the primary channel, parse the session number from the first line of text.
35-
# - To keep us netcat compatible, we must transmit the session number as
33+
# there will be no control channel paired with the REPL session.
34+
# - 2: Open the primary channel, parse the session id from the first line of text.
35+
# - To keep us netcat compatible, we must transmit the session id as
3636
# part of the primary data stream; it cannot be packaged into a message
3737
# since only `unpythonic` knows about the message protocol.
3838
# - So, print "Session XX connected\n" as the first line on the server side
3939
# when a client connects. In the client, parse the first line (beside
4040
# printing it as usual, to have the same appearance for both unpythonic
4141
# and netcat connections).
42-
# - 3: Send command on the control channel to associate that control channel
43-
# to session number XX. Maybe print a message on the client side saying
42+
# - 3: Send command on the control channel to pair that control channel
43+
# to session id XX. Maybe print a message on the client side saying
4444
# that tab completion and Ctrl+C are available.
4545

4646
# Messages must be processed by just one central decoder, to prevent data
@@ -65,44 +65,56 @@ def __init__(self, sock):
6565
# ReceiveBuffer inside MessageDecoder.
6666
self.decoder = MessageDecoder(socketsource(sock))
6767

68-
def complete(self, text, state):
69-
"""Tab-complete in a remote REPL session.
68+
def _send_command(self, request):
69+
"""Send a command to the server, get the reply.
7070
71-
This is a completer for `readline.set_completer`.
71+
request: a dict-like, containing the "command" field and any required
72+
parameters (command-dependent).
73+
74+
On success, return the `reply` dict. On failure, return `None`.
7275
"""
7376
try:
74-
request = {"command": "TabComplete", "text": text, "state": state}
7577
self._send(request)
7678
reply = self._recv()
77-
# print("text '{}' state '{}' reply '{}'".format(text, state, reply))
7879
if not reply:
7980
print("Socket closed by other end.")
8081
return None
8182
if reply["status"] == "ok":
82-
return reply["result"]
83+
return reply
84+
elif reply["status"] == "failed":
85+
if "reason" in reply:
86+
print("Server command failed, reason: {}".format(reply["reason"]))
8387
except BaseException as err:
8488
print(type(err), err)
8589
return None
8690

91+
def describe_server(self):
92+
"""Return server metadata such as prompt settings and version."""
93+
return self._send_command({"command": "DescribeServer"})
94+
95+
def pair_with_session(self, session_id):
96+
"""Pair this control channel with a REPL session."""
97+
return self._send_command({"command": "PairWithSession", "id": session_id})
98+
99+
def complete(self, text, state):
100+
"""Tab-complete in a remote REPL session.
101+
102+
This is a completer for `readline.set_completer`.
103+
"""
104+
reply = self._send_command({"command": "TabComplete", "text": text, "state": state})
105+
if reply:
106+
return reply["result"]
107+
return None
108+
87109
def send_kbinterrupt(self):
88110
"""Request the server to perform a `KeyboardInterrupt` (Ctrl+C).
89111
90-
The `KeyboardInterrupt` occurs in the REPL session associated with
112+
The `KeyboardInterrupt` occurs in the REPL session paired with
91113
this control channel.
92114
93115
Returns truthy on success, falsey on failure.
94116
"""
95-
try:
96-
request = {"command": "KeyboardInterrupt"}
97-
self._send(request)
98-
reply = self._recv()
99-
if not reply:
100-
print("Socket closed by other end.")
101-
return None
102-
return reply["status"] == "ok"
103-
except BaseException as err:
104-
print(type(err), err)
105-
return False
117+
return self._send_command({"command": "KeyboardInterrupt"})
106118

107119

108120
def connect(addrspec):
@@ -122,19 +134,47 @@ def connect(addrspec):
122134
class SessionExit(Exception):
123135
pass
124136
try:
125-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # remote REPL session
126-
sock.connect(addrspec)
127-
128-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as csock: # control channel (remote tab completion, remote Ctrl+C)
129-
# TODO: configurable control port
130-
csock.connect((addrspec[0], 8128)) # TODO: IPv6 support
131-
132-
# Set a custom tab completer for readline.
133-
# https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
134-
controller = ControlClient(csock)
135-
readline.set_completer(controller.complete)
136-
readline.parse_and_bind("tab: complete")
137-
137+
# First handshake on control channel to get prompt information.
138+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as csock: # control channel (remote tab completion, remote Ctrl+C)
139+
# TODO: configurable control port
140+
csock.connect((addrspec[0], 8128)) # TODO: IPv6 support
141+
controller = ControlClient(csock)
142+
143+
# TODO: use the prompts information in calls to input()
144+
metadata = controller.describe_server()
145+
# print(metadata["prompts"])
146+
147+
# Set up remote tab completion, using a custom completer for readline.
148+
# https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
149+
readline.set_completer(controller.complete)
150+
readline.parse_and_bind("tab: complete")
151+
152+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # remote REPL session
153+
sock.connect(addrspec)
154+
155+
# TODO: refactor
156+
# The first line of text contains the session id.
157+
# We can't use the message protocol; this information must arrive on the primary channel.
158+
rs, ws, es = select.select([sock], [], [])
159+
for r in rs:
160+
data = sock.recv(4096)
161+
if len(data) == 0:
162+
print("replclient: disconnected by server.")
163+
raise SessionExit
164+
text = data.decode("utf-8")
165+
sys.stdout.write(text)
166+
if "\n" in text:
167+
first_line, *rest = text.split("\n")
168+
import re
169+
matches = re.findall(r"session (\d+) connected", first_line)
170+
assert len(matches) == 1, "Expected server to print session id on the first line"
171+
repl_session_id = int(matches[0])
172+
else:
173+
# TODO: make sure we always receive a whole line
174+
assert False
175+
controller.pair_with_session(repl_session_id)
176+
177+
# TODO: This can't be a separate thread, we must listen for input only when a prompt has appeared.
138178
def sock_to_stdout():
139179
try:
140180
while True:
@@ -144,7 +184,8 @@ def sock_to_stdout():
144184
if len(data) == 0:
145185
print("replclient: disconnected by server.")
146186
raise SessionExit
147-
sys.stdout.write(data.decode("utf-8"))
187+
text = data.decode("utf-8")
188+
sys.stdout.write(text)
148189
except SessionExit:
149190
# Exit also in main thread, which is waiting on input() when this happens.
150191
signal.alarm(1)

0 commit comments

Comments
 (0)