+++ /dev/null
-# plomrogue2-experiments
-preliminary study in mechanisms useful for a new PlomRogue engine
--- /dev/null
+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.
--- /dev/null
+#!/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()
--- /dev/null
+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])
--- /dev/null
+pkg-resources==0.0.0
+urwid==1.3.1
--- /dev/null
+#!/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()