Decisions
The Robot World Repo on GitHub
The Forth Repo on GitHub
Despite success coming up to speed on sockets, I’m not having fun. What to do? Resist fascism!
Of course, if I were in the active trade, someone would say to me “That’s why they call it ‘work’, not ‘fun’.” But since all code is sufficiently similar, I can do my “work”, which is something like write code and write about it, on anything. So there’s nothing stopping me from deciding to write PacMan or Oracle or something else that might be fun. Still, it wasn’t that bad.
However, advocatus diaboli, maybe I owe it to myself and my reader (or possibly readers) to at least make the game work across some kind of network interface, lest I be accused of giving up when the going gets tough, and therefore everything I stand for is false.
I’ll push a bit further on this. My motivation is low, very low. I would very much like to have something that I enjoy doing to write about. Maybe something will come to me.
Where Are We, Anyway?
In the WorldServer project, we have a WorldServer class, a MessageHandler class, and some tests in TestServer class. The most complex test is one that tests sending and receiving two messages:
def test_two_messages(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(("127.0.0.1", 34567))
msg = prefix(b"Hello, world")
s.sendall(msg[:6])
s.sendall(msg[6:])
data = s.recv(1024)
assert data == "I saw: Hello, world".encode('utf-8')
how_ya = prefix(b'How ya doing?')
s.sendall(how_ya[:1])
s.sendall(how_ya[1:4])
s.sendall(how_ya[4:])
data = s.recv(1024)
assert data == "I saw: How ya doing?".encode('utf-8')
That’s awfully specific, because we have no real app to talk with. Our WorldServer accepts connections, and when events occur on those connections, it passes them to the MessageHandler:
class WorldServer:
def service_connection(self, key, mask):
sock = key.fileobj
handler = key.data
if mask & selectors.EVENT_READ:
handler.process_read(self.sel, sock)
if mask & selectors.EVENT_WRITE:
handler.process_write(sock)
The events at that level may offer bytes to read from the socket, but those bytes may constitute a message, part of a message, or more than one message, so the MessageHandler’s real job is to assemble complete messages from the socket. As we’ll see in a moment, it then passes them to a trivial bit of “app” code.
class MessageHandler:
def __init__(self, addr):
self.addr = addr
self.inb = b""
self.outb = b""
self.size = None
def process_read(self, sel, sock):
bytes = sock.recv(1024) # Should be ready to read
if not bytes:
self.client_closed_connection(sel, sock)
return
self.inb += bytes
if self.message_is_new():
self.get_size_if_available()
if self.input_message_complete():
self.process_complete_message()
def message_is_new(self):
return self.size is None
def input_message_complete(self):
return self.size is not None and len(self.inb) >= self.size
def process_complete_message(self):
print(f'{self.size=} {self.inb=} {len(self.inb)=}')
self.outb = b"I saw: " + self.inb[:self.size]
self.inb = self.inb[self.size:]
self.size = None
There in process_complete_message
we’re pretending to be a simple “app” that prepends “I saw: “ to the input message and sends it back.
OK. What we’re trying to do, I think, is to rig things up so that our Robot World’s World object will be sent the input messages, and will return the output messages.
I’m not sure how we’ll really want to rig that up, but clearly our process_complete_message
should have an instance of the “app” and send the message to it to be dealt with, expecting a returned message to send back.
In the interest of making some kind of progress, let’s extract a little ISawApp and then see how to plug in something more wonderful. It would be great if we could keep our tests running.
We’ll TDD up a little ISawApp, I think, just to get started on the right foot.
I create a new test file and get this much running:
class ISawApp:
pass
class TestISawApp:
def test_create_app(self):
app = ISawApp()
Green. Commit: TDDing ISawApp
Next test:
def test_process(self):
app = ISawApp()
result = app.process(b'Hello')
assert result == b'I saw: Hello'
Curiously this fails for want of a process method on ISawApp. We can oblige:
def process(self, message):
return b'I saw: ' + message
Let’s move the class over to the source side. It’s really kind of a test class but I don’t like my source side looking back at the tests.
Commit: ISawApp complete.
Now I want to plug it into the MessageHandler, like this:
class MessageHandler:
def __init__(self, addr):
self.app = ISawApp()
self.addr = addr
self.inb = b""
self.outb = b""
self.size = None
Commit: MessageHandler has ISawApp instance.
Now we might use it:
def process_complete_message(self):
self.outb = self.app.process(self.inb[:self.size])
self.inb = self.inb[self.size:]
self.size = None
Green. Commit: MessageHandler sends input messages to process
on its app instance.
Reflection
In a very few small steps, we have isolated the application used to process messages received on our sockets. Now, we need to arrange that our whole scheme here can use a different app.
Here’s our trivial main:
if __name__ == "__main__":
ws = WorldServer()
ws.go()
I can imagine at least two ways things might work with our WorldServer, if it ever grows into anything useful. We might have a single app instance that all the handlers use, or each handler might have its own app instance. the latter approach might be useful for isolating the details of our service relating to the specific client on a specific socket.
So … it seems to me … we would like each new instance of MessageHandler to have something to call to create or fetch an app instance.
And for my purposes here, I want to keep my ISawApp for the tests that expect that, and to provide a different app for other tests, all without breaking the server or needing to start up a new server.
That should be interesting.
We’ll pass a function to MessageHandler that it uses to create its app.
class MessageHandler:
def __init__(self, addr, app_maker=None):
if not app_maker:
self.app = ISawApp()
else:
self.app = app_maker()
self.addr = addr
self.inb = b""
self.outb = b""
self.size = None
Restarting the server, we’re still green. Commit: working toward dynamic app instances.
class WorldServer:
def __init__(self):
self.sel = selectors.DefaultSelector()
self.app_maker = lambda: ISawApp()
def accept_wrapper(self, listener: socket.socket):
connecting_socket, addr = listener.accept() # Should be ready to read
connecting_socket.setblocking(False)
handler = MessageHandler(addr=addr, app_maker=self.app_maker)
events = selectors.EVENT_READ | selectors.EVENT_WRITE
self.sel.register(connecting_socket, events, data=handler)
print(f"Accepted connection from {addr}")
Now, it seems to me, we could set the app_maker
instance to anything and get a new app.
I’m going to extend ISawApp
a bit:
class ISawApp:
def __init__(self, prefix=b'I saw: '):
self.prefix = prefix
def process(self, message):
return self.prefix + message
Green. Commit: still working toward dynamic app instances.
Stuck
I am stuck. I was trying to write a test that injected a different app into the running server. But by the nature of things, my tests don’t have access to the instance of WorldServer that is running in a separate process that I start up for testing purposes.
It’s pretty clear that I can put any app into the MessageHandler, and I’ll do that ASAP, but I was hoping to keep al my tests running and now I don’t see how to do that. I suppose I could have some kind of secret message that the server intercepts but that’s horrid.
Oh. How about having more than one server running? I could have one for my new app work on a different port from the current one. Yes, that’ll work, I think. I’ll work in that direction.
I’ll have to think about how best to do this, starting up two servers and so on. It’s going to get confusing if I have to change things often.
Summary
For now, we’ve made a little progress and haven’t done much damage. We have a way to select the app that we start with, but it’s not a good one. It’s possible that I should just flush the basic tests … I just need to think about how I want things to work for testing and in actual use. Configuration. Meh.
Fight fascism, oligarchy, and idiocracy. We all need better government.