home · contact · privacy
Refactor socket code.
[plomrogue2-experiments] / server_ / io.py
1 import socketserver
2 import threading
3 import queue
4 import sys
5 sys.path.append('../')
6 import parser
7 from server_.game_error import GameError
8
9
10 # Avoid "Address already in use" errors.
11 socketserver.TCPServer.allow_reuse_address = True
12
13
14 class Server(socketserver.ThreadingTCPServer):
15     """Bind together threaded IO handling server and message queue."""
16
17     def __init__(self, queue, port, *args, **kwargs):
18         super().__init__(('localhost', port), IO_Handler, *args, **kwargs)
19         self.queue_out = queue
20         self.daemon_threads = True  # Else, server's threads have daemon=False.
21
22
23 class IO_Handler(socketserver.BaseRequestHandler):
24
25     def handle(self):
26         """Move messages between network socket and game IO loop via queues.
27
28         On start (a new connection from client to server), sets up a
29         new queue, sends it via self.server.queue_out to the game IO
30         loop thread, and from then on receives messages to send back
31         from the game IO loop via that new queue.
32
33         At the same time, loops over socket's recv to get messages
34         from the outside into the game IO loop by way of
35         self.server.queue_out into the game IO. Ends connection once a
36         'QUIT' message is received from socket, and then also calls
37         for a kill of its own queue.
38
39         All messages to the game IO loop are tuples, with the first
40         element a meta command ('ADD_QUEUE' for queue creation,
41         'KILL_QUEUE' for queue deletion, and 'COMMAND' for everything
42         else), the second element a UUID that uniquely identifies the
43         thread (so that the game IO loop knows whom to send replies
44         back to), and optionally a third element for further
45         instructions.
46
47         """
48
49         def send_queue_messages(plom_socket, queue_in, thread_alive):
50             """Send messages via socket from queue_in while thread_alive[0]."""
51             while thread_alive[0]:
52                 try:
53                     msg = queue_in.get(timeout=1)
54                 except queue.Empty:
55                     continue
56                 plom_socket.send(msg, True)
57
58         import uuid
59         import plom_socket
60         plom_socket = plom_socket.PlomSocket(self.request)
61         print('CONNECTION FROM:', str(self.client_address))
62         connection_id = uuid.uuid4()
63         queue_in = queue.Queue()
64         self.server.queue_out.put(('ADD_QUEUE', connection_id, queue_in))
65         thread_alive = [True]
66         t = threading.Thread(target=send_queue_messages,
67                              args=(plom_socket, queue_in, thread_alive))
68         t.start()
69         for message in plom_socket.recv():
70             if message is None:
71                 plom_socket.send('BAD MESSAGE', True)
72             elif 'QUIT' == message:
73                 plom_socket.send('BYE', True)
74                 break
75             else:
76                 self.server.queue_out.put(('COMMAND', connection_id, message))
77         self.server.queue_out.put(('KILL_QUEUE', connection_id))
78         thread_alive[0] = False
79         print('CONNECTION CLOSED FROM:', str(self.client_address))
80         plom_socket.socket.close()
81
82
83 class GameIO():
84
85     def __init__(self, game_file_name, game):
86         self.game_file_name = game_file_name
87         self.queues_out = {}
88         self.parser = parser.Parser(game)
89
90     def loop(self, q):
91         """Handle commands coming through queue q, send results back.
92
93         Commands from q are expected to be tuples, with the first element
94         either 'ADD_QUEUE', 'COMMAND', or 'KILL_QUEUE', the second element
95         a UUID, and an optional third element of arbitrary type. The UUID
96         identifies a receiver for replies.
97
98         An 'ADD_QUEUE' command should contain as third element a queue
99         through which to send messages back to the sender of the
100         command. A 'KILL_QUEUE' command removes the queue for that
101         receiver from the list of queues through which to send replies.
102
103         A 'COMMAND' command is specified in greater detail by a string
104         that is the tuple's third element. The game_command_handler takes
105         care of processing this and sending out replies.
106
107         """
108         while True:
109             x = q.get()
110             command_type = x[0]
111             connection_id = x[1]
112             content = None if len(x) == 2 else x[2]
113             if command_type == 'ADD_QUEUE':
114                 self.queues_out[connection_id] = content
115             elif command_type == 'KILL_QUEUE':
116                 del self.queues_out[connection_id]
117             elif command_type == 'COMMAND':
118                 self.handle_input(content, connection_id)
119
120     def run_loop_with_server(self):
121         """Run connection of server talking to clients and game IO loop.
122
123         We have the TCP server (an instance of Server) and we have the
124         game IO loop, a thread running self.loop. Both communicate with
125         each other via a queue.Queue. While the TCP server may spawn
126         parallel threads to many clients, the IO loop works sequentially
127         through game commands received from the TCP server's threads (=
128         client connections to the TCP server). A processed command may
129         trigger messages to the commanding client or to all clients,
130         delivered from the IO loop to the TCP server via the queue.
131
132         """
133         q = queue.Queue()
134         c = threading.Thread(target=self.loop, daemon=True, args=(q,))
135         c.start()
136         server = Server(q, 5000)
137         try:
138             server.serve_forever()
139         except KeyboardInterrupt:
140             pass
141         finally:
142             print('Killing server')
143             server.server_close()
144
145     def handle_input(self, input_, connection_id=None, store=True):
146         """Process input_ to command grammar, call command handler if found."""
147         from inspect import signature
148         import server_.game
149
150         def answer(connection_id, msg):
151             if connection_id:
152                 self.send(msg, connection_id)
153             else:
154                 print(msg)
155
156         try:
157             command, args = self.parser.parse(input_)
158             if command is None:
159                 answer(connection_id, 'UNHANDLED_INPUT')
160             else:
161                 if 'connection_id' in list(signature(command).parameters):
162                     command(*args, connection_id=connection_id)
163                 else:
164                     command(*args)
165                     if store and not hasattr(command, 'dont_save'):
166                         with open(self.game_file_name, 'a') as f:
167                             f.write(input_ + '\n')
168         except parser.ArgError as e:
169             answer(connection_id, 'ARGUMENT_ERROR ' + quote(str(e)))
170         except GameError as e:
171             answer(connection_id, 'GAME_ERROR ' + quote(str(e)))
172
173     def send(self, msg, connection_id=None):
174         """Send message msg to server's client(s) via self.queues_out.
175
176         If a specific client is identified by connection_id, only
177         sends msg to that one. Else, sends it to all clients
178         identified in self.queues_out.
179
180         """
181         if connection_id:
182             self.queues_out[connection_id].put(msg)
183         else:
184             for connection_id in self.queues_out:
185                 self.queues_out[connection_id].put(msg)
186
187
188 def quote(string):
189     """Quote & escape string so client interprets it as single token."""
190     quoted = []
191     quoted += ['"']
192     for c in string:
193         if c in {'"', '\\'}:
194             quoted += ['\\']
195         quoted += [c]
196     quoted += ['"']
197     return ''.join(quoted)
198
199
200 def stringify_yx(tuple_):
201     """Transform tuple (y,x) into string 'Y:'+str(y)+',X:'+str(x)."""
202     return 'Y:' + str(tuple_[0]) + ',X:' + str(tuple_[1])