home · contact · privacy
Initial commit of actual stuff.
authorChristian Heller <c.heller@plomlompom.de>
Sun, 23 Apr 2017 11:07:17 +0000 (13:07 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 23 Apr 2017 11:07:17 +0000 (13:07 +0200)
README.md [deleted file]
README.rst [new file with mode: 0644]
client.py [new file with mode: 0755]
plom_socket_io.py [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
server.py [new file with mode: 0755]

diff --git a/README.md b/README.md
deleted file mode 100644 (file)
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 (file)
index 0000000..daeabbe
--- /dev/null
@@ -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 (executable)
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 (file)
index 0000000..07d41ce
--- /dev/null
@@ -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:
+    - <http://stackoverflow.com/q/19697218>
+    - <http://stackoverflow.com/q/2618736>
+    - <http://stackoverflow.com/q/8900474>
+
+    This also handles a socket.send() return value of 0, which might be
+    possible or not (?) for blocking sockets:
+    - <http://stackoverflow.com/q/34919846>
+    """
+    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 (file)
index 0000000..02cf780
--- /dev/null
@@ -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 (executable)
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()