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())