From f5287b7235704555925ed2a24113258fe61b40c1 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Sun, 23 Apr 2017 13:07:17 +0200 Subject: [PATCH] Initial commit of actual stuff. --- README.md | 2 - README.rst | 28 +++++++++++++ client.py | 100 ++++++++++++++++++++++++++++++++++++++++++++++ plom_socket_io.py | 75 ++++++++++++++++++++++++++++++++++ requirements.txt | 2 + server.py | 95 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 300 insertions(+), 2 deletions(-) delete mode 100644 README.md create mode 100644 README.rst create mode 100755 client.py create mode 100644 plom_socket_io.py create mode 100644 requirements.txt create mode 100755 server.py diff --git a/README.md b/README.md deleted file mode 100644 index 1a97be6..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# plomrogue2-experiments -preliminary study in mechanisms useful for a new PlomRogue engine diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..daeabbe --- /dev/null +++ b/README.rst @@ -0,0 +1,28 @@ +Preliminary study on mechanisms useful for a new PlomRogue engine +================================================================= + +The old PlomRogue engine in its mechanisms feels quite questionable to me now. +I have some ideas for a new variant, but I must get acquainted with some +relevant mechanics and their Python3 implementations first. So this code is just +some playing around with these. + +A new PlomRogue engine should have: + +* server-client communication via sockets, on top of some internet protocol +* the server should be capable of parallel computation +* maybe use a different library for console interfaces than ncurses – how about + *urwid*? + +To play around with these mechanics, I create two executables to be run in +dialogue: + +* `./client.py` +* `./server.py` + +The following commands can be sent from client to server: + +* `QUIT` – closes the connection +* `FIB` followed by positive integers; for each such integer n, calculates the + n-th Fibonacci number (this allows for testing parallel CPU-heavy computation) + +See `./requirements.txt` for the dependencies. diff --git a/client.py b/client.py new file mode 100755 index 0000000..92d555e --- /dev/null +++ b/client.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +import urwid +import plom_socket_io +import socket +import threading + + +class RecvThread(threading.Thread): + """Background thread that delivers messages from the socket to urwid. + + The message transfer to urwid is a bit weird. The urwid developers warn + against sharing urwid resources among threads, and recommend using urwid's + watch_pipe mechanism: using a pipe from non-urwid threads into a single + urwid thread. We could pipe the recv output directly, but then we get + complicated buffering situations here as well as in the urwid code that + receives the pipe content. It's much easier to update a third resource + (server_output, which references an object that's also known to the urwid + code) to contain the new message, and then just use the urwid pipe + (urwid_pipe_write_fd) to trigger the urwid code to pull the message in from + that third resource. We send a single b' ' through the pipe to trigger it. + """ + + def __init__(self, socket, urwid_pipe_write_fd, server_output): + super().__init__() + self.socket = socket + self.urwid_pipe = urwid_pipe_write_fd + self.server_output = server_output + + def run(self): + """On message receive, write to self.server_output, ping urwid pipe.""" + import os + for msg in plom_socket_io.recv(self.socket): + self.server_output[0] = msg + os.write(self.urwid_pipe, b' ') + + +class InputHandler: + """Helps delivering data from other thread to widget via message_container. + + The whole class only exists to provide handle_input as a bound method, with + widget and message_container pre-set, as (bound) handle_input is used as a + callback in urwid's watch_pipe – which merely provides its callback target + with one parameter for a pipe to read data from an urwid-external thread. + """ + + def __init__(self, widget, message_container): + self.widget = widget + self.message_container = message_container + + def handle_input(self, trigger): + """On input from other thread, either quit, or write to widget text. + + Serves as a receiver to urwid's watch_pipe mechanism, with trigger the + data that a pipe defined by watch_pipe delivers. To avoid buffering + trouble, we don't care for that data beyond the fact that its receival + triggers this function: The sender is to write the data it wants to + deliver into the container referenced by self.message_container, and + just pipe the trigger to inform us about this. + + If the message delivered is 'BYE', quits Urbit. + """ + if self.message_container[0] == 'BYE': + raise urwid.ExitMainLoop() + return + self.widget.set_text('REPLY: ' + self.message_container[0]) + + +class SocketInputWidget(urwid.Filler): + + def __init__(self, socket, *args, **kwargs): + super().__init__(*args, **kwargs) + self.socket = socket + + def keypress(self, size, key): + """Act like super(), except on Enter: send .edit_text, and empty it.""" + if key != 'enter': + return super().keypress(size, key) + plom_socket_io.send(self.socket, edit.edit_text) + edit.edit_text = '' + + +s = socket.create_connection(('127.0.0.1', 5000)) + +edit = urwid.Edit('SEND: ') +txt = urwid.Text('') +pile = urwid.Pile([edit, txt]) +fill = SocketInputWidget(s, pile) +loop = urwid.MainLoop(fill) + +server_output = [''] +write_fd = loop.watch_pipe(getattr(InputHandler(txt, server_output), + 'handle_input')) +thread = RecvThread(s, write_fd, server_output) +thread.start() + +loop.run() + +thread.join() +s.close() diff --git a/plom_socket_io.py b/plom_socket_io.py new file mode 100644 index 0000000..07d41ce --- /dev/null +++ b/plom_socket_io.py @@ -0,0 +1,75 @@ +def send(socket, message): + """Send message via socket, encoded and delimited the way recv() expects. + + In detail, all \ and $ in message are escaped with prefixed \, and an + unescaped $ is appended as a message delimiter. Then, socket.send() is + called as often as necessary to ensure message is sent fully, as + socket.send() due to buffering may not send all of it right away. + + Assuming socket is blocking, it's rather improbable that socket.send() will + be partial / return a positive value less than the (byte) length of msg – + but not entirely out of the question. See: + - + - + - + + This also handles a socket.send() return value of 0, which might be + possible or not (?) for blocking sockets: + - + """ + escaped_message = '' + for char in message: + if char in ('\\', '$'): + escaped_message += '\\' + escaped_message += char + escaped_message += '$' + data = escaped_message.encode() + totalsent = 0 + while totalsent < len(data): + sent = socket.send(data[totalsent:]) + if sent == 0: + raise RuntimeError('socket connection broken') + totalsent = totalsent + sent + + +def recv(socket): + """Get full send()-prepared message from socket. + + In detail, socket.recv() is looped over for sequences of bytes that can be + decoded as a Unicode string delimited by an unescaped $, with \ and $ + escapable by \. If a sequence of characters that ends in an unescaped $ + cannot be decoded as Unicode, None is returned as its representation. Stop + once socket.recv() returns nothing. + + Under the hood, the TCP stack receives packets that construct the input + payload in an internal buffer; socket.recv(BUFSIZE) pops up to BUFSIZE + bytes from that buffer, without knowledge either about the input's + segmentation into packets, or whether the input is segmented in any other + meaningful way; that's why we do our own message segmentation with $ as a + delimiter. + """ + quit = False + esc = False + data = b'' + msg = b'' + while True: + data += socket.recv(1024) + if 0 == len(data): + return + cut_off = 0 + for c in data: + cut_off += 1 + if esc: + msg += bytes([c]) + esc = False + elif chr(c) == '\\': + esc = True + elif chr(c) == '$': + try: + yield msg.decode() + except UnicodeDecodeError: + yield None + data = data[cut_off:] + msg = b'' + else: + msg += bytes([c]) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..02cf780 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pkg-resources==0.0.0 +urwid==1.3.1 diff --git a/server.py b/server.py new file mode 100755 index 0000000..b326768 --- /dev/null +++ b/server.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 + +import socketserver +import plom_socket_io + +# Avoid "Address already in use" errors. +socketserver.TCPServer.allow_reuse_address = True + + +def fib(n): + """Calculate n-th Fibonacci number.""" + if n in (1, 2): + return 1 + else: + return fib(n-1) + fib(n-2) + + +def handle_message(message): + """Evaluate message for computing-heavy tasks to perform, yield result. + + Accepts one command: FIB, followed by positive integers, all tokens + separated by whitespace. Will calculate and return for each such integer n + the n-th Fibonacci number. Uses multiprocessing to perform multiple such + calculations in parallel. Yields a 'CALCULATING …' message before the + calculation starts, and finally yields a message containing the results. + (The 'CALCULATING …' message coming before the results message is currently + the main reason this works as a generator function using yield.) + + When no command can be read into the message, just yields a 'NO COMMAND + UNDERSTOOD:', followed by the message. + """ + tokens = message.split(' ') + if tokens[0] == 'FIB': + msg_fail_fib = 'MALFORMED FIB REQUEST' + if len(tokens) < 2: + yield msg_fail_fib + return + numbers = [] + fail = False + for token in tokens[1:]: + if token != '0' and token.isdigit(): + numbers += [int(token)] + elif token == '': + continue + else: + yield msg_fail_fib + return + yield 'CALCULATING …' + reply = '' + from multiprocessing import Pool + with Pool(len(numbers)) as p: + results = p.map(fib, numbers) + reply = ' '.join([str(r) for r in results]) + yield reply + return + yield 'NO COMMAND UNDERSTOOD: %s' % message + + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + """Enables threading on TCP server for asynchronous IO handling.""" + pass + + +class MyTCPHandler(socketserver.BaseRequestHandler): + + def handle(self): + """Loop recv for input, act on it, send reply. + + If input is 'QUIT', send reply 'BYE' and end loop / connection. + Otherwise, use handle_message. + """ + + print('CONNECTION FROM:', str(self.client_address)) + for message in plom_socket_io.recv(self.request): + if message is None: + print('RECEIVED MALFORMED MESSAGE') + plom_socket_io.send(self.request, 'bad message') + elif 'QUIT' == message: + plom_socket_io.send(self.request, 'BYE') + break + else: + print('RECEIVED MESSAGE:', message) + for reply in handle_message(message): + plom_socket_io.send(self.request, reply) + print('CONNECTION CLOSED:', str(self.client_address)) + self.request.close() + + +server = ThreadedTCPServer(('localhost', 5000), MyTCPHandler) +try: + server.serve_forever() +except KeyboardInterrupt: + pass +finally: + server.server_close() -- 2.30.2