serve.py (12.3 KB)
1 #!/usr/bin/env python3 2 """serve animus over the local network with a shared canvas. 3 4 usage: ./serve.py [port] 5 """ 6 7 import base64 8 import hashlib 9 import json 10 import os 11 import queue 12 import socket 13 import struct 14 import sys 15 import threading 16 import time 17 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer 18 19 ROOT = os.path.dirname(os.path.abspath(__file__)) 20 DEFAULT_PORT = 8000 21 # per-painter outbound backlog. a stroke burst is a few hundred small messages, 22 # so this is generous; past it the connection is too far behind to be correct 23 SEND_QUEUE_MAX = 4096 24 # rfc 6455 fixes this string 25 WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 26 MAX_PAYLOAD = 64 * 1024 * 1024 27 28 # once the stored stroke log for the room passes this, ask a painter for flat 29 # images of the frames so joiners don't have to replay an entire session 30 SNAPSHOT_AFTER_OPS = 4000 31 SNAPSHOT_COOLDOWN = 15.0 32 33 # the ways a connection ends when the other side simply walks off 34 DEAD_SOCKET = (ConnectionError, BrokenPipeError, TimeoutError, socket.timeout) 35 36 37 def read_exact(rfile, n): 38 buf = b'' 39 while len(buf) < n: 40 chunk = rfile.read(n - len(buf)) 41 if not chunk: 42 return None 43 buf += chunk 44 return buf 45 46 47 def unmask(payload, mask): 48 if not payload: 49 return payload 50 n = len(payload) 51 # xor the whole payload as one big int: the repeated key is trimmed back 52 # down to n bytes so byte i lines up with mask[i % 4] 53 rep = mask * (n // 4 + 1) 54 key = int.from_bytes(rep, 'big') >> (8 * (len(rep) - n)) 55 return (int.from_bytes(payload, 'big') ^ key).to_bytes(n, 'big') 56 57 58 def ws_recv(rfile, on_control=None): 59 """next complete data message as (opcode, payload), or None when the peer 60 closes. control frames are answered inline so they can't interrupt a 61 message that arrives in fragments.""" 62 data = b'' 63 msg_op = None 64 while True: 65 hdr = read_exact(rfile, 2) 66 if hdr is None: 67 return None 68 fin = hdr[0] & 0x80 69 op = hdr[0] & 0x0f 70 masked = hdr[1] & 0x80 71 length = hdr[1] & 0x7f 72 if length == 126: 73 ext = read_exact(rfile, 2) 74 if ext is None: 75 return None 76 length = struct.unpack('>H', ext)[0] 77 elif length == 127: 78 ext = read_exact(rfile, 8) 79 if ext is None: 80 return None 81 length = struct.unpack('>Q', ext)[0] 82 if length > MAX_PAYLOAD: 83 return None 84 mask = read_exact(rfile, 4) if masked else b'' 85 if masked and mask is None: 86 return None 87 payload = read_exact(rfile, length) if length else b'' 88 if payload is None: 89 return None 90 if masked: 91 payload = unmask(payload, mask) 92 if op & 0x8: 93 if op == 0x8: 94 return None 95 if op == 0x9 and on_control: 96 on_control(0xa, payload) 97 continue 98 if op != 0: 99 msg_op = op 100 data = payload 101 else: 102 data += payload 103 if fin: 104 return msg_op or 1, data 105 106 107 def ws_frame(payload, opcode=1): 108 head = bytearray([0x80 | opcode]) 109 n = len(payload) 110 if n < 126: 111 head.append(n) 112 elif n < 65536: 113 head.append(126) 114 head += struct.pack('>H', n) 115 else: 116 head.append(127) 117 head += struct.pack('>Q', n) 118 return bytes(head) + payload 119 120 121 class Conn: 122 """one painter. writes go through a queue and a dedicated thread so a 123 painter on bad wifi can never stall the room: their backlog is theirs.""" 124 125 _seq = 0 126 _seq_lock = threading.Lock() 127 128 def __init__(self, sock): 129 self.sock = sock 130 self.alive = True 131 self.joined = time.time() 132 self.q = queue.Queue(maxsize=SEND_QUEUE_MAX) 133 with Conn._seq_lock: 134 Conn._seq += 1 135 self.id = 'c%d' % Conn._seq 136 self.writer = threading.Thread(target=self._pump, daemon=True) 137 self.writer.start() 138 139 def _pump(self): 140 while True: 141 item = self.q.get() 142 if item is None: 143 return 144 payload, opcode = item 145 try: 146 self.sock.sendall(ws_frame(payload, opcode)) 147 except OSError: 148 self.kill() 149 return 150 151 def kill(self): 152 """drop the connection; the page reconnects and re-syncs from scratch""" 153 if not self.alive: 154 return 155 self.alive = False 156 try: 157 self.sock.shutdown(socket.SHUT_RDWR) 158 except OSError: 159 pass 160 try: 161 self.q.put_nowait(None) 162 except queue.Full: 163 pass 164 165 def raw(self, payload, opcode=1, droppable=False): 166 if not self.alive: 167 return 168 try: 169 self.q.put_nowait((payload, opcode)) 170 except queue.Full: 171 # a stale brush position is worth nothing, so let it go. a lost 172 # stroke would desync this painter for good, so cut them loose 173 # instead and let the reconnect hand them the room again 174 if not droppable: 175 self.kill() 176 177 def send(self, obj, droppable=False): 178 self.raw(json.dumps(obj, separators=(',', ':')).encode('utf-8'), droppable=droppable) 179 180 181 class Room: 182 def __init__(self): 183 self.lock = threading.RLock() 184 self.clients = [] 185 self.frame_seq = 0 186 self.op_seq = 0 187 self.op_count = 0 # stored strokes, tracked rather than recounted 188 self.frames = [self.blank_frame()] 189 self.index = {f['id']: f for f in self.frames} 190 self.interval = 250 191 self.snap_pending = False 192 self.snap_at = 0.0 193 194 def blank_frame(self): 195 self.frame_seq += 1 196 return {'id': 'f%d' % self.frame_seq, 'img': None, 'ox': 0, 'oy': 0, 'w': 0, 'h': 0, 'ops': []} 197 198 def base_frame(self, src): 199 f = self.blank_frame() 200 if isinstance(src, dict) and src.get('img'): 201 f['img'] = src['img'] 202 f['ox'] = int(src.get('ox', 0)) 203 f['oy'] = int(src.get('oy', 0)) 204 f['w'] = int(src.get('w', 0)) 205 f['h'] = int(src.get('h', 0)) 206 return f 207 208 def frame(self, fid): 209 return self.index.get(fid) 210 211 def wire_frames(self, with_ops=True): 212 out = [] 213 for f in self.frames: 214 e = {'id': f['id'], 'img': f['img'], 'ox': f['ox'], 'oy': f['oy'], 'w': f['w'], 'h': f['h']} 215 if with_ops: 216 e['ops'] = f['ops'] 217 out.append(e) 218 return out 219 220 def broadcast(self, msg, skip=None, droppable=False): 221 payload = json.dumps(msg, separators=(',', ':')).encode('utf-8') 222 with self.lock: 223 targets = [c for c in self.clients if c is not skip] 224 for c in targets: 225 c.raw(payload, droppable=droppable) 226 227 def join(self, conn): 228 with self.lock: 229 self.clients.append(conn) 230 conn.send({ 231 't': 'init', 232 'id': conn.id, 233 'frames': self.wire_frames(), 234 'interval': self.interval, 235 }) 236 print(' + %s joined (%d painting)' % (conn.id, len(self.clients))) 237 238 def leave(self, conn): 239 with self.lock: 240 if conn in self.clients: 241 self.clients.remove(conn) 242 # the painter we asked for a flat copy may be the one leaving; let 243 # the next stroke ask someone else 244 self.snap_pending = False 245 print(' - %s left (%d painting)' % (conn.id, len(self.clients))) 246 self.broadcast({'t': 'bye', 'id': conn.id}) 247 248 def maybe_snapshot(self): 249 """ask one painter to flatten the room so the stroke log stays bounded""" 250 now = time.time() 251 if self.snap_pending or now - self.snap_at < SNAPSHOT_COOLDOWN: 252 return 253 if self.op_count < SNAPSHOT_AFTER_OPS or not self.clients: 254 return 255 self.snap_pending = True 256 self.snap_at = now 257 self.clients[0].send({'t': 'snap', 'upto': self.op_seq}) 258 259 def set_frames(self, frames): 260 self.frames = frames 261 self.index = {f['id']: f for f in frames} 262 self.op_count = sum(len(f['ops']) for f in frames) 263 264 def handle(self, conn, msg): 265 t = msg.get('t') 266 if t == 'p': 267 with self.lock: 268 f = self.frame(msg.get('f')) 269 if f is None: 270 return 271 self.op_seq += 1 272 op = { 273 'n': self.op_seq, 274 'o': msg.get('o'), 275 'c': msg.get('c'), 276 'b': msg.get('b'), 277 's': msg.get('s'), 278 } 279 f['ops'].append(op) 280 self.op_count += 1 281 self.maybe_snapshot() 282 out = dict(op) 283 out['t'] = 'p' 284 out['f'] = f['id'] 285 out['by'] = conn.id 286 # the painter's own tag for this batch, so they can recognise it 287 # coming back; not stored, it means nothing to anyone else 288 if msg.get('k') is not None: 289 out['k'] = msg['k'] 290 # broadcast under the lock so wire order matches sequence order; 291 # sends only enqueue, so nothing slow happens in here. echoed to 292 # the painter too: the ack is what commits their stroke 293 self.broadcast(out) 294 elif t == 'c': 295 msg['id'] = conn.id 296 self.broadcast(msg, skip=conn, droppable=True) 297 elif t == 'af': 298 with self.lock: 299 at = max(0, min(int(msg.get('at', len(self.frames))), len(self.frames))) 300 f = self.blank_frame() 301 self.frames.insert(at, f) 302 self.index[f['id']] = f 303 self.broadcast({'t': 'af', 'at': at, 'id': f['id'], 'by': conn.id}) 304 elif t == 'df': 305 with self.lock: 306 if len(self.frames) <= 1: 307 return 308 f = self.frame(msg.get('id')) 309 if f is None: 310 return 311 self.frames.remove(f) 312 del self.index[f['id']] 313 self.op_count -= len(f['ops']) 314 self.broadcast({'t': 'df', 'id': f['id'], 'by': conn.id}) 315 elif t == 'reset': 316 srcs = msg.get('frames') or [] 317 if not srcs: 318 return 319 with self.lock: 320 self.set_frames([self.base_frame(s) for s in srcs]) 321 self.interval = int(msg.get('interval') or self.interval) 322 self.snap_pending = False 323 self.broadcast({ 324 't': 'reset', 325 'frames': self.wire_frames(with_ops=False), 326 'interval': self.interval, 327 'by': conn.id, 328 }) 329 elif t == 'snapshot': 330 upto = int(msg.get('upto') or 0) 331 with self.lock: 332 self.snap_pending = False 333 for src in msg.get('frames') or []: 334 f = self.frame(src.get('id')) 335 if f is None: 336 continue 337 f['img'] = src.get('img') 338 f['ox'] = int(src.get('ox', 0)) 339 f['oy'] = int(src.get('oy', 0)) 340 f['w'] = int(src.get('w', 0)) 341 f['h'] = int(src.get('h', 0)) 342 # strokes newer than the snapshot are kept; a stroke that made it 343 # into the image and also survives here just paints itself twice 344 kept = 0 345 for f in self.frames: 346 f['ops'] = [o for o in f['ops'] if o['n'] > upto] 347 kept += len(f['ops']) 348 self.op_count = kept 349 elif t == 'interval': 350 with self.lock: 351 self.interval = int(msg.get('v') or self.interval) 352 353 354 room = Room() 355 356 357 class Server(ThreadingHTTPServer): 358 daemon_threads = True 359 # a whole request thread can still unwind on a dropped socket, outside any 360 # handler we control. same story, same silence 361 def handle_error(self, request, client_address): 362 if isinstance(sys.exc_info()[1], DEAD_SOCKET): 363 return 364 super().handle_error(request, client_address) 365 366 367 class Handler(SimpleHTTPRequestHandler): 368 protocol_version = 'HTTP/1.1' 369 server_version = 'animus' 370 # tiny realtime frames must go out now, not sit in nagle's buffer waiting 371 # on a watcher's delayed acks 372 disable_nagle_algorithm = True 373 374 def __init__(self, *a, **kw): 375 super().__init__(*a, directory=ROOT, **kw) 376 377 def log_message(self, fmt, *args): 378 pass 379 380 def handle(self): 381 try: 382 super().handle() 383 except DEAD_SOCKET: 384 self.close_connection = True 385 386 def finish(self): 387 try: 388 super().finish() 389 except DEAD_SOCKET: 390 pass 391 392 def end_headers(self): 393 self.send_header('Cache-Control', 'no-store') 394 super().end_headers() 395 396 def do_GET(self): 397 if self.path.split('?')[0] == '/ws': 398 self.do_ws() 399 return 400 super().do_GET() 401 402 def do_ws(self): 403 key = self.headers.get('Sec-WebSocket-Key') 404 if not key or 'websocket' not in (self.headers.get('Upgrade') or '').lower(): 405 self.send_error(400, 'expected a websocket upgrade') 406 return 407 accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() 408 self.close_connection = True 409 self.send_response(101, 'Switching Protocols') 410 self.send_header('Upgrade', 'websocket') 411 self.send_header('Connection', 'Upgrade') 412 self.send_header('Sec-WebSocket-Accept', accept) 413 self.end_headers() 414 self.wfile.flush() 415 416 conn = Conn(self.connection) 417 room.join(conn) 418 try: 419 pong = lambda code, data: conn.raw(data, code) 420 while conn.alive: 421 msg = ws_recv(self.rfile, pong) 422 if msg is None: 423 break 424 op, payload = msg 425 if op != 0x1: 426 continue 427 try: 428 data = json.loads(payload.decode('utf-8')) 429 except (ValueError, UnicodeDecodeError): 430 continue 431 if isinstance(data, dict): 432 room.handle(conn, data) 433 except OSError: 434 pass 435 finally: 436 conn.kill() 437 room.leave(conn) 438 439 440 def lan_ip(): 441 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 442 try: 443 s.connect(('10.255.255.255', 1)) 444 return s.getsockname()[0] 445 except OSError: 446 return '127.0.0.1' 447 finally: 448 s.close() 449 450 451 def main(): 452 port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT 453 for attempt in range(20): 454 try: 455 httpd = Server(('0.0.0.0', port + attempt), Handler) 456 break 457 except OSError: 458 continue 459 else: 460 print('no free port near %d' % port) 461 return 1 462 port = httpd.server_address[1] 463 464 print('\nPress Ctrl+C to stop the server.\n') 465 print('http://%s:%d\n' % (lan_ip(), port)) 466 467 468 try: 469 httpd.serve_forever() 470 except KeyboardInterrupt: 471 print('\n\nShutting down server...') 472 return 0 473 474 475 if __name__ == '__main__': 476 sys.stdout.reconfigure(line_buffering=True) 477 sys.exit(main())