3434
3535import readline # noqa: F401, input() uses the readline module if it has been loaded.
3636import socket
37- import select
3837import sys
39- import signal
40- import threading
4138import re
4239
4340from .msg import MessageDecoder
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