transport.py
python · 114 lines · Exact bytes
Version & provenance · service-gpu-c205ec8f7f77
Recorded SHA-256 4c76feb2f38a0644b55cc762cc2d1fc96b237e79b4856c8376c4ed0c2fa7742aRendered SHA-256 4c76feb2f38a0644b55cc762cc2d1fc96b237e79b4856c8376c4ed0c2fa7742a
Apache-2.0 for original LittlePly code, declared by the project owner on 2026-10-28. Historical source bytes and hashes are unchanged. Third-party components retain their own licenses; Lichess data remain CC0-1.0. License notes. Line links are stable within this snapshot. Exact allowlisted source from immutable live GPU release 6133648b2926-c205ec8f7f77, verified against release manifest SHA-256 a1cdc3f5f55c96b3164bc6fd78cb37308d547b2d7f07865dd37ad1448f2f3a1e. Runtime owner confirmed GPU serving, event-stream/model warmup, BOT identity and zero restarts on 2026-10-28. Capacity is up to four casual standard games, with bullet games alone; UltraBullet, correspondence and variants are unsupported. Checkpoint remains 6133648b2926b38542ee3ee1251a1ba111d61da9a8ea69d1c5f0559b09933643. app_multi.py is the multi-game controller; app.py is its shared base, whose legacy standalone admission differs. This is a source subset; host configuration, launcher, state, secrets and private games are excluded. This is a serving change, not a new model or evaluation.
1"""Fixed-origin Lichess transport. Bounded asynchronous control requests."""
2import json,queue,re,threading,time
3from pathlib import Path
4import requests
5
6class Events:
7 """Game updates cannot be starved by a burst of public challenges."""
8 def __init__(self):self.game=queue.Queue(128);self.account=queue.Queue(32)
9 def put_nowait(self,item):
10 (self.account if item[0]=='account' else self.game).put_nowait(item)
11 def get_nowait(self):
12 try:return self.game.get_nowait()
13 except queue.Empty:return self.account.get_nowait()
14
15class Cooldown:
16 def __init__(self):self.until=0.;self.lock=threading.Lock()
17 def defer(self,seconds=60):
18 with self.lock:self.until=max(self.until,time.monotonic()+max(60,seconds))
19 def remaining(self):return max(0.,self.until-time.monotonic())
20
21class SendPermit:
22 """Main owner persists an attempted POST before releasing the network thread."""
23 def __init__(self):self.event=threading.Event();self.allowed=False;self.expires=time.monotonic()+3
24
25class Transport:
26 def __init__(self,token_file,request=requests.request):
27 token=Path(token_file).read_text(encoding='utf-8-sig').strip()
28 if not re.fullmatch(r'[A-Za-z0-9_]+',token):raise ValueError('invalid token format')
29 self.headers={'Authorization':'Bearer '+token,'User-Agent':'LittlePly/1.0','Accept':'application/json'}
30 self.request=request;self.cooldown=Cooldown();self.stop=threading.Event()
31 self.jobs=queue.PriorityQueue(32);self.critical=queue.PriorityQueue(8)
32 self.results=queue.Queue(64);self.counter=0;self.lock=threading.Lock()
33 self.thread=threading.Thread(target=self._run,daemon=True);self.thread.start()
34 def submit(self,key,method,path,data=None,priority=1,guard=None):
35 if not re.fullmatch(r'/api/[A-Za-z0-9_/?=&.-]+',path):raise ValueError('invalid API path')
36 with self.lock:self.counter+=1;n=self.counter
37 target=self.critical if priority==0 else self.jobs
38 target.put_nowait((priority,n,(key,method,path,data,guard)))
39 def _run(self):
40 while not self.stop.is_set():
41 if self.stop.wait(min(.2,self.cooldown.remaining())):return
42 if self.cooldown.remaining():continue
43 try:priority,n,job=self.critical.get_nowait()
44 except queue.Empty:
45 try:priority,n,job=self.jobs.get(timeout=.2)
46 except queue.Empty:continue
47 key,method,path,data,guard=job
48 if guard and not guard():self.results.put((key,'cancelled',None));continue
49 if key.startswith(('move:','accept:')):
50 permit=SendPermit();self.results.put((key,'send_ready',permit))
51 if not permit.event.wait(3) or not permit.allowed or (guard and not guard()):
52 self.results.put((key,'cancelled',None));continue
53 try:
54 with self.request(method,'https://lichess.org'+path,headers=self.headers,data=data,
55 timeout=(3,5),allow_redirects=False) as r:
56 code=r.status_code
57 if code==429:
58 try:retry=float(r.headers.get('Retry-After',60))
59 except (TypeError,ValueError):retry=60
60 self.cooldown.defer(retry)
61 # Known rejected: return for state-aware retry after shared cooldown.
62 result=('rate_limited',{'retry_after':max(60,retry)})
63 elif code in (401,403):result=('auth_error',code)
64 elif 200<=code<300:
65 try:body=r.json()
66 except ValueError:body={}
67 result=('ok',body)
68 elif 400<=code<500:result=('rejected',code)
69 else:result=('uncertain' if method=='POST' else 'retryable',code)
70 except requests.RequestException as exc:
71 result=('uncertain' if method=='POST' else 'retryable',type(exc).__name__)
72 except Exception as exc:result=('retryable',type(exc).__name__)
73 self.results.put((key,*result))
74 def close(self):self.stop.set();self.thread.join(timeout=9)
75
76class Stream:
77 """One stream thread, monotonically increasing epochs, bounded queue."""
78 def __init__(self,transport,path,tag,events,epochs):
79 self.transport=transport;self.path=path;self.tag=tag;self.events=events;self.epochs=epochs
80 self.stop=threading.Event();self.resync=threading.Event();self.last_event=0.;self.response=None
81 self.thread=threading.Thread(target=self.run,daemon=True);self.thread.start()
82 def emit(self,kind,data,epoch):
83 try:self.events.put_nowait((self.tag,kind,data,epoch,time.monotonic()))
84 except queue.Full:self.resync.set()
85 def run(self):
86 delay=2
87 while not self.stop.is_set():
88 if self.stop.wait(max(0,self.transport.cooldown.remaining())):return
89 epoch=next(self.epochs);self.resync.clear()
90 try:
91 with requests.get('https://lichess.org'+self.path,headers=self.transport.headers,
92 stream=True,timeout=(5,15),allow_redirects=False) as r:
93 self.response=r
94 if r.status_code==429:self.transport.cooldown.defer();raise RuntimeError('rate_limited')
95 if r.status_code in (401,403):self.emit('auth_error',r.status_code,epoch);return
96 if r.status_code!=200:raise RuntimeError('stream_http')
97 self.last_event=time.monotonic();self.emit('connected',{},epoch);delay=2
98 for line in r.iter_lines(chunk_size=1):
99 self.last_event=time.monotonic()
100 if self.stop.is_set() or self.resync.is_set():break
101 if len(line)>262144:raise ValueError('oversized stream record')
102 if line:
103 data=json.loads(line)
104 if data.get('type') in ('gameFull','gameState','gameStart','gameFinish','challenge','challengeCanceled','challengeDeclined'):
105 self.emit('data',data,epoch)
106 except Exception as exc:self.emit('disconnected',type(exc).__name__,epoch)
107 finally:self.response=None
108 if self.stop.wait(delay):return
109 delay=min(60,delay*2)
110 def close(self):
111 self.stop.set()
112 if self.response:self.response.close()
113 self.thread.join(timeout=16)
114