Skip to content

Commit 2658ede

Browse files
committed
REPL client: implement prompt detection
1 parent 00af701 commit 2658ede

File tree

1 file changed

+76
-69
lines changed

1 file changed

+76
-69
lines changed

unpythonic/net/client.py

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,7 @@
3434

3535
import readline # noqa: F401, input() uses the readline module if it has been loaded.
3636
import socket
37-
import select
3837
import sys
39-
import signal
40-
import threading
4138
import re
4239

4340
from .msg import MessageDecoder
@@ -47,15 +44,6 @@
4744
__all__ = ["connect"]
4845

4946

50-
# Install system signal handler for forcibly terminating the stdin input() when
51-
# an EOF is received on the socket in the other thread.
52-
# https://en.wikiversity.org/wiki/Python_Concepts/Console_Input#Detecting_timeout_on_stdin
53-
# https://docs.python.org/3/library/signal.html
54-
def _handle_alarm(signum, frame):
55-
sys.exit(255) # we will actually catch this.
56-
signal.signal(signal.SIGALRM, _handle_alarm)
57-
58-
5947
# Protocol for establishing a paired control/REPL connection:
6048
# - 1: Handshake: open the control channel, ask for metadata (prompts: sys.ps1, sys.ps2)
6149
# to configure the client's prompt detector before opening the primary channel.
@@ -175,14 +163,17 @@ class SessionExit(Exception):
175163
csock.connect((addrspec[0], 8128)) # TODO: IPv6 support
176164
controller = ControlClient(csock)
177165

178-
# TODO: use the prompts information in calls to input()
166+
# Get prompts for use with input()
179167
metadata = controller.describe_server()
180-
# print(metadata["prompts"])
168+
ps1 = metadata["prompts"]["ps1"]
169+
ps2 = metadata["prompts"]["ps2"]
170+
bps1 = ps1.encode("utf-8")
171+
bps2 = ps2.encode("utf-8")
181172

182173
# Set up remote tab completion, using a custom completer for readline.
183174
# https://stackoverflow.com/questions/35115208/is-there-any-way-to-combine-readline-rlcompleter-and-interactiveconsole-in-pytho
184175
readline.set_completer(controller.complete)
185-
readline.parse_and_bind("tab: complete") # TODO: do we need this, PyPy doesn't support it?
176+
readline.parse_and_bind("tab: complete") # TODO: do we need to call this, PyPy doesn't support it?
186177

187178
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: # remote REPL session
188179
sock.connect(addrspec)
@@ -200,6 +191,7 @@ def read_more_input():
200191

201192
# The first line of text contains the session id.
202193
# We can't use the message protocol; this information must arrive on the primary channel.
194+
# So we read input until the first newline, storing it all to be printed later.
203195
try:
204196
val = buf.getvalue()
205197
while True:
@@ -215,66 +207,81 @@ def read_more_input():
215207
print("unpythonic.net.client: disconnected by server.")
216208
raise SessionExit
217209
controller.pair_with_session(repl_session_id)
218-
sys.stdout.write(text)
219210

220-
# TODO: This can't be a separate thread, we must listen for input only when a prompt has appeared.
221-
def sock_to_stdout():
222-
try:
223-
while True:
224-
rs, ws, es = select.select([sock], [], [])
225-
for r in rs:
226-
data = sock.recv(4096)
227-
if len(data) == 0:
228-
print("unpythonic.net.client: disconnected by server.")
229-
raise SessionExit
230-
text = data.decode("utf-8")
231-
sys.stdout.write(text)
232-
except SessionExit:
233-
# Exit also in main thread, which is waiting on input() when this happens.
234-
signal.alarm(1)
235-
t = threading.Thread(target=sock_to_stdout, daemon=True)
236-
t.start()
237-
238-
# TODO: fix prompts in multiline inputs (see repl_tool.py in socketserverREPL for reference)
211+
# We run readline at the client side. Only the tab completion
212+
# results come from the server, via the custom remote completer.
239213
#
240-
# This needs prompt detection so we'll know how to set up
241-
# `input`. The first time on a new line, the prompt is sent
242-
# by the server, but then during line editing, it needs to be
243-
# re-printed by `readline`, so `input` needs to know what the
244-
# prompt text should be.
214+
# The first time for each "R" in "REPL", the input prompt is
215+
# sent by the server, but then during line editing, it needs to
216+
# be re-printed by `readline`, so `input` needs to know what
217+
# the prompt text should be.
245218
#
246-
# For this, we need to read the socket until we see a new prompt.
247-
248-
# Run readline at the client side. Only the tab completion
249-
# results come from the server.
219+
# So at our end, `input` should be the one to print the prompt.
250220
#
251-
# Important: this must be outside the forwarder loop (since
252-
# there will be multiple events on stdin for each line sent),
253-
# so we use a different thread (here specifically, the main
254-
# thread).
255-
try:
256-
while True:
257-
try:
258-
inp = input()
259-
sock.sendall((inp + "\n").encode("utf-8"))
260-
except KeyboardInterrupt:
261-
controller.send_kbinterrupt()
262-
except EOFError:
263-
print("unpythonic.net.client: Ctrl+D pressed, asking server to disconnect.")
264-
print("unpythonic.net.client: if the server does not respond, press Ctrl+C to force.")
221+
# For this, we read the socket until we see a new prompt,
222+
# and then switch from the "P" in "REPL" to the "R" and "E".
223+
#
224+
val = buf.getvalue()
225+
while True: # The "L" in the "REPL"
265226
try:
266-
print("quit()") # local echo
267-
sock.sendall("quit()\n".encode("utf-8"))
268-
t.join() # wait for the EOF response
227+
# Wait for the prompt. It's the last thing the console
228+
# sends before listening for more input.
229+
if val.endswith(bps1) or val.endswith(bps2):
230+
# "P"
231+
text = val.decode("utf-8")
232+
prompt = ps1 if text.endswith(ps1) else ps2
233+
text = text[:-len(prompt)]
234+
sys.stdout.write(text)
235+
buf.set(b"")
236+
237+
# "R", "E" (but evaluate remotely)
238+
try:
239+
inp = input(prompt)
240+
sock.sendall((inp + "\n").encode("utf-8"))
241+
except EOFError:
242+
print("unpythonic.net.client: Ctrl+D pressed, asking server to disconnect.")
243+
print("unpythonic.net.client: if the server does not respond, press Ctrl+C to force.")
244+
try:
245+
print("quit()") # local echo
246+
sock.sendall("quit()\n".encode("utf-8"))
247+
except KeyboardInterrupt:
248+
print("unpythonic.net.client: Ctrl+C pressed, forcing disconnect.")
249+
finally:
250+
raise SessionExit
251+
except BrokenPipeError:
252+
print("unpythonic.net.client: socket closed unexpectedly, exiting.")
253+
raise SessionExit
254+
255+
else: # no prompt yet, just print whatever came in, and clear the buffer
256+
text = val.decode("utf-8")
257+
sys.stdout.write(text)
258+
buf.set(b"")
259+
260+
val = read_more_input()
261+
262+
# TODO: It's very difficult to get this 100% right, and right now we don't even try.
263+
# The problem is that KeyboardInterrupt may occur on any line of code here, so we may
264+
# lose some text, or print some twice, depending on the exact moment Ctrl+C is pressed.
269265
except KeyboardInterrupt:
270-
print("unpythonic.net.client: Ctrl+C pressed, forcing disconnect.")
271-
finally:
272-
raise SessionExit
273-
except SystemExit: # catch the alarm signaled by the socket-listening thread.
274-
raise SessionExit
275-
except BrokenPipeError:
276-
print("unpythonic.net.client: socket closed unexpectedly, exiting.")
277-
raise SessionExit
266+
controller.send_kbinterrupt()
267+
# When KeyboardInterrupt occurs, the server will send
268+
# the string "KeyboardInterrupt" and a new prompt when
269+
# we send a blank line to it (but sending the blank line
270+
# seems to be mandatory for that to happen).
271+
sock.sendall(("\n").encode("utf-8"))
272+
273+
# If the interrupt happened inside read_more_input, it has closed the socketsource
274+
# by terminating the generator that was blocking on its internal select() call.
275+
# So let's re-instantiate the socketsource, just to be safe.
276+
#
277+
# (This cannot lose data, since the source object itself has no buffer. There is
278+
# an app-level buffer in ReceiveBuffer, and the underlying socket has a buffer,
279+
# but at the level of the "source" abstraction, there is no buffer.)
280+
src.close() # PyPy recommends closing generators explicitly.
281+
src = socketsource(sock)
282+
283+
# Process the server's response to the blank line.
284+
val = read_more_input()
278285

279286
except SessionExit:
280287
print("Session closed.")

0 commit comments

Comments
 (0)