Make the acceptance page answer the operator, not the timer

Presses on Start and Continue were being swallowed. Every poll rebuilt
the whole test table and the prompt buttons with innerHTML, and a button
destroyed between mousedown and mouseup raises no click event at all:
the press simply vanished. Measured on the page as it stood, a Start
button node was replaced 13 times in 40 seconds, and with a poll landing
mid-press 8 presses out of 8 were lost. Rows, prompt buttons and tool
entries are now built once and afterwards only updated in place through
setters that skip the write when the value has not changed; under the
same test 8 presses out of 8 land.

The page also felt slow because each poll re-read and re-parsed the whole
result log and recomputed all 42 domain fingerprints. A result record
carries its run log, so the file reaches megabytes over a campaign and
the poll cost grew with it. The log now parses each line once and reads
only the bytes appended since, and a fingerprint is memoized against the
manifest's content hash. On the same manifest, catalog and log, one
/state goes from 8.89 ms to 0.10 ms at 0.89 MB and from 41.79 ms to
0.23 ms at 10.62 MB, and no longer grows with the log.

Actions now report on the press instead of on the next poll: Start greys
every Start and marks the row, Continue and Abort grey themselves, and
each pulls the next poll forward. /state carries an ETag so an idle page
polls for a 304, the poll ticks faster during a run, and the connection
is kept alive rather than handshaking per request.

Keeping the connection alive exposed a hazard worth naming: a POST
refused before its body was read left that body in the socket, where the
next read took it for a request line. A refusal now ends the connection.

forgetest is a dev-only component and can never appear in a coverage map,
so this has no acceptance catalog consequence; the host tests carry the
proof, including one that fails if a per-poll innerHTML rebuild ever
comes back.
This commit is contained in:
ScottW514
2026-08-21 09:49:35 -04:00
parent eaa0b0741b
commit 474e14e0e8
7 changed files with 552 additions and 87 deletions
+12 -1
View File
@@ -39,6 +39,7 @@ class Test:
self.description = description or (fn.__doc__ or "").strip()
self.fn = fn
self._source_sha = None
self._fp = (None, None) # (manifest content sha, fingerprint)
@property
def source_sha(self):
@@ -51,7 +52,17 @@ class Test:
return self._source_sha
def fingerprint(self, manifest):
return _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
"""The domain fingerprint on this manifest, memoized by the
manifest's content hash: the page recomputes every test's
fingerprint on every poll, and a manifest never changes under a
running tool."""
key = manifest.content_sha
if key and self._fp[0] == key:
return self._fp[1]
fp = _manifest.fingerprint(manifest, self.covers, extra=[self.source_sha])
if key:
self._fp = (key, fp)
return fp
def definition(self):
"""The gate-visible definition (no implementation, no prose)."""
+46 -12
View File
@@ -13,6 +13,11 @@ One JSON object per line under the data directory (default
Every record carries "t" (type) and "ts" (UTC, ISO 8601, seconds).
The file is only ever appended; a corrupt line is skipped, counted, and
reported, never repaired in place.
Because the file only grows, `read` parses each line once and keeps what
it parsed; a later call reads only the bytes appended since. A result
record carries the whole run log, so the file reaches megabytes over a
campaign, and the page asks for the state every second or two.
"""
import json
import os
@@ -35,6 +40,9 @@ class Log:
self.path = path or os.path.join(data_dir(), "results.jsonl")
self._lock = threading.Lock()
self.corrupt = 0
self._recs = [] # every record parsed so far, in file order
self._offset = 0 # bytes of the file already consumed
self._tail = b"" # bytes past the last newline: not a record yet
def append(self, rec):
rec = dict(rec)
@@ -48,28 +56,54 @@ class Log:
os.fsync(f.fileno())
return rec
def read(self):
recs = []
def _forget(self):
"""Drop what was parsed: the next read starts from the top."""
self._recs = []
self._offset = 0
self._tail = b""
self.corrupt = 0
if not os.path.exists(self.path):
return recs
with self._lock:
with open(self.path, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
def _consume(self, chunk):
"""Parse the whole lines in `chunk`, holding back a partial tail."""
data = self._tail + chunk
cut = data.rfind(b"\n")
if cut < 0:
self._tail = data
return
self._tail = data[cut + 1:]
for line in data[:cut].split(b"\n"):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
rec = json.loads(line.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
self.corrupt += 1
continue
if isinstance(rec, dict) and "t" in rec:
recs.append(rec)
self._recs.append(rec)
else:
self.corrupt += 1
return recs
def read(self):
"""Every record, in file order. Only the bytes appended since the
last call are parsed; a file that shrank or was replaced is read
again from the top."""
with self._lock:
try:
size = os.path.getsize(self.path)
except OSError:
self._forget()
return []
if size < self._offset:
self._forget()
if size != self._offset:
with open(self.path, "rb") as f:
f.seek(self._offset)
chunk = f.read()
self._offset += len(chunk)
self._consume(chunk)
return list(self._recs)
def raw(self):
if not os.path.exists(self.path):
+13 -6
View File
@@ -10,6 +10,7 @@ plus the test's own implementation. A recorded PASS applies to a build
exactly when the fingerprint recomputed from that build's manifest is the
same - the same code runs on the board and in the release gate.
"""
import functools
import hashlib
import json
import os
@@ -32,9 +33,11 @@ def sha256_text(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@functools.lru_cache(maxsize=512)
def glob_to_regex(pattern):
"""Coverage glob -> anchored regex. '**' spans directories, '*' and '?'
stay inside one path segment. Paths use '/' (git paths)."""
stay inside one path segment. Paths use '/' (git paths). Cached: the
catalog matches the same few hundred globs over and over."""
out = []
i, n = 0, len(pattern)
while i < n:
@@ -71,6 +74,7 @@ class Manifest:
self.platform = data.get("platform", {}) or {}
self.image = data.get("image", {}) or {}
self.content_sha = data.get("content_sha256")
self._files = {}
@classmethod
def load(cls, path=None):
@@ -91,12 +95,15 @@ class Manifest:
return self.image.get("name") or "unknown"
def files(self, component):
"""The [path, blob] list of a component, or None if the component
is not in this manifest."""
"""The (path, blob) pairs of a component, or None if the component
is not in this manifest. Built once per component: the manifest is
immutable and every coverage glob asks for the same lists."""
if component in self._files:
return self._files[component]
c = self.components.get(component)
if c is None:
return None
return [tuple(x) for x in c.get("files", [])]
out = None if c is None else [tuple(x) for x in c.get("files", [])]
self._files[component] = out
return out
def component_names(self):
return sorted(self.components)
+170 -61
View File
@@ -1,8 +1,16 @@
"""The single page: acceptance tab + bench tab. Self-contained (inline
CSS/JS, ES5, no external assets); the visual identity follows the
forgectrl control panel. State comes from GET /state on a 2 s poll; the
catalog and the bench listing are fetched once per tab and refreshed
after a run finishes."""
forgectrl control panel. State comes from GET /state, polled every 2 s
when the machine is idle and every second during a run; the catalog and
the bench listing are fetched once per tab and refreshed after a run
finishes.
Rows, prompt buttons and tool entries are built once and thereafter only
updated in place. A poll that rebuilt them would swallow the click it
landed on: the button that took the mousedown would be gone before the
mouseup, so no click event would ever be raised. Every action also greys
its control out on the press and pulls the next poll forward, so the page
answers the operator rather than the timer."""
_HTML = r"""<!DOCTYPE html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
@@ -81,7 +89,7 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<div class='kv' id='banner'></div></div>
<div id='invnote'></div><div id='msgs'></div>
<div class='actions' style='margin-top:10px'>
<button class='pri' onclick='doExport()'>Export release artifact</button>
<button class='pri' id='exportbtn' onclick='doExport()'>Export release artifact</button>
<a id='dljson' href='/export/acceptance.json' style='display:none'><button>acceptance.json</button></a>
<a id='dlmd' href='/export/acceptance.md' style='display:none'><button>acceptance.md</button></a>
<a href='/log'><button>Raw log</button></a>
@@ -120,107 +128,208 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
<script>
var TOKEN='__TOKEN__';
var state=null, catalog=null, catalogHash=null, bench=null, tab='acceptance', openDetails={};
var lastRunKey=null;
var lastRunKey=null, stateEtag=null, curPrompt=null, promptKey=null, abortSent=false;
var pollTimer=null, polling=false, pending=0, pendingId=null, rowMsg={};
var rowEls=null, groupsKey=null, benchEls=null, benchKey=null, benchNeedsRefresh=false;
var PENDING_MS=6000;
/* A poll that never answers must not wedge the loop: it times out
and the next one is scheduled as usual. Actions carry no timeout,
because a POST the server did act on must not read as a failure. */
var POLL_TIMEOUT_MS=20000;
var ignoreReq=false;try{ignoreReq=window.localStorage.getItem('forgetest.ignoreReq')==='1'}catch(e){}
function $(id){return document.getElementById(id)}
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
$('ignreq').checked=ignoreReq;$('ignreqon').innerHTML=ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'';if(state&&catalog)renderGroups()}
function nowMs(){return (new Date()).getTime()}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
function api(method,path,body,cb){var x=new XMLHttpRequest();x.open(method,path,true);x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
/* Touch the DOM only where the value really changed. Replacing a node
under the operator's finger loses the click: the button that took the
mousedown is gone before the mouseup, so no click event is ever
raised, and the press does nothing. Nothing on a poll may rebuild a
control that is only sitting there unchanged. */
function setHtml(e,h){if(e&&e.__h!==h){e.__h=h;e.innerHTML=h}}
function setText(e,t){if(e&&e.__t!==t){e.__t=t;e.textContent=t}}
function setDis(e,d){if(e&&e.disabled!==d)e.disabled=d}
function setProp(e,k,v){if(e&&e[k]!==v)e[k]=v}
function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.open(method,path,true);
if(timeoutMs)x.timeout=timeoutMs;
x.setRequestHeader('X-ForgeFIRM-Token',TOKEN);
if(hdrs){for(var k in hdrs){if(hdrs.hasOwnProperty(k))x.setRequestHeader(k,hdrs[k])}}
if(body!==undefined&&body!==null){x.setRequestHeader('Content-Type','application/json')}
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d)};
x.onreadystatechange=function(){if(x.readyState!==4)return;var d=null;try{d=JSON.parse(x.responseText)}catch(e){d={error:x.responseText}}cb(x.status,d,x)};
x.send(body===undefined||body===null?null:JSON.stringify(body))}
/* The state poll. It carries the last ETag, so an unchanged state costs
a 304 and no re-render; a run gets a faster tick, and every action
pulls the next poll forward instead of waiting out the interval. */
function pollDelay(){return (state&&state.running)?1000:2000}
function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)}
function kick(){schedule(120)}
function poll(){if(polling){schedule(150);return}polling=true;
/* While an action is still in flight the state is asked for in full:
a 304 would skip the render that releases the greyed-out buttons. */
var h=(stateEtag&&!pending)?{'If-None-Match':stateEtag}:null;
api('GET','/state',null,function(s,d,x){polling=false;
if(s===200){stateEtag=x.getResponseHeader('ETag');state=d;
if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
schedule(pollDelay())},h,POLL_TIMEOUT_MS)}
function showTab(t){tab=t;$('tab-acceptance').className=t==='acceptance'?'on':'';$('tab-bench').className=t==='bench'?'on':'';
$('pane-acceptance').style.display=t==='acceptance'?'':'none';$('pane-bench').style.display=t==='bench'?'':'none';
if(t==='bench'&&!bench)loadBench()}
function setMsg(id,txt,err){var e=$(id);e.innerHTML=txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":''}
function setMsg(id,txt,err){setHtml($(id),txt?"<div class='"+(err?'err':'msg')+"'>"+esc(txt)+"</div>":'')}
function loadCatalog(cb){api('GET','/catalog',null,function(s,d){if(s===200){catalog=d.tests;catalogHash=d.catalog_hash;if(cb)cb()}})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;renderBench()}})}
function poll(){api('GET','/state',null,function(s,d){if(s===200){state=d;if(!catalog||catalogHash!==d.catalog_hash){loadCatalog(render)}else{render()}}
setTimeout(poll,2000)})}
function loadBench(){api('GET','/bench',null,function(s,d){if(s===200){bench=d;benchKey=null;renderBench()}})}
function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forgetest.ignoreReq',ignoreReq?'1':'0')}catch(e){}
$('ignreq').checked=ignoreReq;setHtml($('ignreqon'),ignoreReq?"<span class='on'>ON - prerequisites are not enforced</span>":'');
if(state&&catalog)renderGroups()}
/* A start already sent but not yet seen in the state counts as busy, so
the buttons grey out on the click rather than on the next poll. The
window is capped in case the answer never arrives. */
function isBusy(){if(state&&state.running)return true;
return !!(pending&&(nowMs()-pending)<PENDING_MS)}
function fmtTs(t){return t?t.replace('T',' ').replace('Z',' UTC'):'-'}
function render(){if(!state||!catalog)return;
var m=state.manifest||{};$('hdrver').textContent=(m.version||'?');$('hdrsub').textContent=m.image||'';
var a=$('auth');a.textContent='Release authorized: '+(state.authorized?'YES':'NO');a.className='auth'+(state.authorized?' yes':'');
if(state.running){pending=0;pendingId=null}
var m=state.manifest||{};setText($('hdrver'),(m.version||'?'));setText($('hdrsub'),m.image||'');
var a=$('auth');setText(a,'Release authorized: '+(state.authorized?'YES':'NO'));setProp(a,'className','auth'+(state.authorized?' yes':''));
var c=state.campaign,cn=state.counts||{};var b='';
b+='<b>'+cn.satisfied+'</b> of <b>'+cn.total+'</b> satisfied ('+cn.inherited+' inherited), <b>'+cn.required+'</b> required<br>';
b+=c?('campaign <span class="mono">'+esc(c.id)+'</span> opened '+esc(fmtTs(c.ts))):('no open campaign'+(state.closed_by?(' (last one closed by '+esc(state.closed_by)+')'):'')+' - the first Start opens one');
b+='<br>manifest identity <span class="mono">'+esc((m.sha||'').slice(0,16))+'</span> &middot; catalog <span class="mono">'+esc((state.catalog_hash||'').slice(0,12))+'</span>';
if(state.log_corrupt)b+='<br><span style="color:var(--red)">'+state.log_corrupt+' corrupt log line(s) skipped</span>';
$('banner').innerHTML=b;
var inv=state.invalidate;$('invnote').innerHTML=inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'';
var ms='';(state.messages||[]).forEach(function(x){ms+="<div class='note'>"+esc(x)+"</div>"});$('msgs').innerHTML=ms;
renderGroups();renderRun()}
function renderGroups(){var groups={},order=[];catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var busy=!!state.running;var h='';
setHtml($('banner'),b);
var inv=state.invalidate;setHtml($('invnote'),inv?"<div class='note'>Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"</div>":'');
var ms='';(state.messages||[]).forEach(function(x){ms+="<div class='note'>"+esc(x)+"</div>"});setHtml($('msgs'),ms);
renderGroups();renderRun();if(bench)renderBench()}
/* The rows are built once for a given catalog and then only updated in
place: a poll never rewrites the table, so a Start button survives the
press that is landing on it. */
function renderGroups(){var ids=[];catalog.forEach(function(t){ids.push(t.id)});
var sig=catalogHash+'|'+ids.join(',');
if(sig!==groupsKey){groupsKey=sig;buildGroups()}
updateGroups()}
function buildGroups(){var groups={},order=[];
catalog.forEach(function(t){if(!groups[t.subsystem]){groups[t.subsystem]=[];order.push(t.subsystem)}groups[t.subsystem].push(t)});
var h='';
order.forEach(function(g){h+="<div class='card grp'><h2>"+esc(g)+"</h2><table><tr><th>Test</th><th>Kind</th><th>Status</th><th>Last result</th><th></th></tr>";
groups[g].forEach(function(t){var s=state.tests[t.id]||{};var badges='';
groups[g].forEach(function(t){var d=esc(t.id);var badges='';
if(t.always)badges+="<span class='badge core'>core</span>";badges+="<span class='badge "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')badges+="<span class='badge takeover'>takeover</span>";
var st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>";
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
var unmet=s.requires_met===false;var canStart=!busy&&(!unmet||ignoreReq);
var why=busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):'');
var startBtn="<button class='pri' "+(canStart?'':'disabled')+" title='"+esc(why)+"' onclick='startTest(\""+esc(t.id)+"\")'>Start</button>";
if(unmet)startBtn+="<div class='req"+(ignoreReq?' over':'')+"'>"+(ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))+"</div>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+esc(t.id)+"'>"+esc(t.description||'')+
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+esc(t.description||'')+
(t.steps&&t.steps.length?"<br><b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
"<b>Requires:</b> "+esc((t.requires||[]).join(', ')||'-')+"<br><b>Covers:</b> "+esc((t.covers||[]).map(function(c){return c[0]+':'+c[1]}).join(', ')||'-')+
"<br><b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":'')+"</div>";
h+="<tr><td><div>"+esc(t.title)+"</div><div class='tid'>"+esc(t.id)+" <a href='#' onclick='toggleDet(\""+esc(t.id)+"\");return false'>details</a></div>"+det+"</td><td>"+badges+"</td><td>"+st+"</td><td>"+last+"</td><td>"+startBtn+"</td></tr>"});
"<br><span id='detdyn-"+d+"'></span></div>";
h+="<tr><td><div>"+esc(t.title)+"</div><div class='tid'>"+d+" <a href='#' onclick='toggleDet(\""+d+"\");return false'>details</a></div>"+det+
"</td><td>"+badges+"</td><td id='st-"+d+"'></td><td id='last-"+d+"'></td>"+
"<td><button class='pri' id='btn-"+d+"' onclick='startTest(\""+d+"\")'>Start</button>"+
"<div id='unmet-"+d+"'></div><div id='note-"+d+"'></div></td></tr>"});
h+="</table></div>"});
$('groups').innerHTML=h}
$('groups').innerHTML=h;rowEls={};
catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id),
unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})}
function updateGroups(){if(!rowEls||!state)return;var busy=isBusy();
catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{};
var st;
if(pendingId===t.id&&isBusy()&&!state.running){st="<span class='st running'>starting&hellip;</span>"}
else{st="<span class='st "+esc(s.status)+"'>"+esc(s.status||'none')+"</span>";
if(s.required&&s.status!=='running')st+="<br><span class='req'>required: "+esc(s.reason)+"</span>"}
setHtml(e.st,st);
var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-';
if(s.status==='inherited'&&s.origin)last+="<br><span class='tid'>from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+"</span>";
setHtml(e.last,last);
var unmet=s.requires_met===false;var can=!busy&&(!unmet||ignoreReq);
setDis(e.btn,!can);
setProp(e.btn,'title',busy?'a run is in progress':(unmet?((ignoreReq?'prerequisites overridden - needs: ':'needs: ')+(s.missing_requires||[]).join(', ')):''));
setProp(e.unmet,'className','req'+(ignoreReq?' over':''));
setHtml(e.unmet,unmet?((ignoreReq?'unmet: ':'needs: ')+esc((s.missing_requires||[]).join(', '))):'');
setHtml(e.note,rowMsg[t.id]?"<div class='err'>"+esc(rowMsg[t.id])+"</div>":'');
setHtml(e.detdyn,"<b>Fingerprint:</b> <span class='mono'>"+esc((s.fingerprint||'').slice(0,16))+"</span> &middot; est. "+esc(t.est_min)+" min"+
(s.last?"<br><a href='/result?test="+encodeURIComponent(t.id)+"&ts="+encodeURIComponent(s.last.ts)+"'>last result record</a>":''))})}
function toggleDet(id){openDetails[id]=!openDetails[id];var e=$('det-'+id);if(e)e.className='details'+(openDetails[id]?' on':'')}
function renderRun(){var r=state.running||state.last_run;var key=r?(r.kind+':'+r.id+':'+r.started):null;
if(!r){$('runhead').textContent='idle';$('log').textContent='';$('prompt').style.display='none';$('abortbtn').disabled=true;$('runtitle').textContent='Run';return}
$('runtitle').textContent=(r.kind==='bench'?'Bench: ':'Test: ')+r.title;
var hd=r.id+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(key!==lastRunKey)abortSent=false;
if(!r){setText($('runhead'),'idle');setText($('log'),'');$('prompt').style.display='none';
curPrompt=null;promptKey=null;setDis($('abortbtn'),true);setText($('runtitle'),'Run');lastRunKey=null;return}
setText($('runtitle'),(r.kind==='bench'?'Bench: ':'Test: ')+r.title);
var hd=esc(r.id)+' &middot; started '+esc(fmtTs(r.started))+' &middot; '+r.elapsed_s+' s';
if(r.finished){hd+=' &middot; <b class="st '+(r.finished.result==='PASS'||r.finished.result==='OK'?'pass':'fail')+'">'+esc(r.finished.result)+'</b>'+(r.finished.message?' - '+esc(r.finished.message):'')}
else if(r.aborting){hd+=' &middot; <b class="st stale">aborting</b>'}
$('runhead').innerHTML=hd;
var lg=$('log');var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;lg.textContent=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight;lastRunKey=key;
if(r.prompt&&!r.finished){$('prompt').style.display='';$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o){pb+="<button class='pri' onclick='answer(\""+esc(r.prompt.id)+"\",\""+esc(o)+"\")'>"+esc(o)+"</button>"});$('promptb').innerHTML=pb}
else{$('prompt').style.display='none'}
$('abortbtn').disabled=!!r.finished;
setHtml($('runhead'),hd);
var lg=$('log');var txt=(r.dropped?('... '+r.dropped+' earlier lines dropped\n'):'')+r.log.join('\n');
if(lg.__t!==txt){var atBottom=lg.scrollTop+lg.clientHeight>=lg.scrollHeight-20;
lg.__t=txt;lg.textContent=txt;if(atBottom||key!==lastRunKey)lg.scrollTop=lg.scrollHeight}
lastRunKey=key;
/* The prompt buttons are rebuilt only when the prompt itself changes -
they are the ones the operator stares at before pressing. */
if(r.prompt&&!r.finished){var pk=r.prompt.id+'|'+r.prompt.question+'|'+r.prompt.options.length+':'+r.prompt.options.join(',');
if(pk!==promptKey){promptKey=pk;curPrompt=r.prompt;
$('promptq').textContent=r.prompt.question;var pb='';
r.prompt.options.forEach(function(o,i){pb+="<button class='pri' onclick='answerIdx("+i+")'>"+esc(o)+"</button>"});
$('promptb').innerHTML=pb;$('promptb').__h=pb;$('prompt').style.display=''}}
else if(promptKey!==null){promptKey=null;curPrompt=null;$('prompt').style.display='none'}
setDis($('abortbtn'),!!r.finished||abortSent||!!r.aborting);
if(r.finished&&tab==='bench'&&r.kind==='bench'&&benchNeedsRefresh){benchNeedsRefresh=false;loadBench()}}
var benchNeedsRefresh=false;
function findTest(id){for(var i=0;i<catalog.length;i++)if(catalog[i].id===id)return catalog[i];return null}
function startTest(id){var t=findTest(id);var body={test:id};
function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id};
if(ignoreReq)body.ignore_requires=true;
if(t&&t.kind==='live'){if(!confirmLive())return;body.ack_live=true}
api('POST','/start',body,function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
pending=nowMs();pendingId=id;rowMsg={};updateGroups();
setMsg('actmsg','starting '+id+'...');
api('POST','/start',body,function(s,d){
if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()}
setMsg('actmsg',d.message||d.error,s!==200);kick()})}
function confirmLive(){return window.confirm('LIVE LASER TEST.\n\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\nStart the test?')}
function answer(pid,v){api('POST','/answer',{prompt_id:pid,value:v},function(s,d){if(s!==200)setMsg('actmsg',d.message||d.error,true)})}
function doAbort(){api('POST','/abort',{},function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
function doExport(){api('POST','/export',{},function(s,d){if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}else setMsg('actmsg',d.message||d.error,true)})}
function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i<b.length;i++)setDis(b[i],on)}
function answerIdx(i){if(!curPrompt)return;var p=curPrompt,v=p.options[i];
promptBusy(true);setMsg('actmsg','answer sent: '+v);
api('POST','/answer',{prompt_id:p.id,value:v},function(s,d){
if(s!==200){promptBusy(false);setMsg('actmsg',d.message||d.error,true)}kick()})}
function doAbort(){var b=$('abortbtn');if(b.disabled)return;abortSent=true;setDis(b,true);
setMsg('actmsg','aborting...');
api('POST','/abort',{},function(s,d){if(s!==200)abortSent=false;setMsg('actmsg',d.message||d.error,s!==200);kick()})}
function doExport(){var b=$('exportbtn');if(b.disabled)return;setDis(b,true);setMsg('actmsg','exporting...');
api('POST','/export',{},function(s,d){setDis(b,false);
if(s===200){setMsg('actmsg','exported: authorized='+d.authorized+', sha256 '+d.sha256.slice(0,16)+' - download below');$('dljson').style.display='';$('dlmd').style.display=''}
else setMsg('actmsg',d.message||d.error,true)})}
function toggleInv(){var f=$('invform');f.style.display=f.style.display==='none'?'':'none'}
function doInvalidate(){var r=$('invreason').value;if(!r){setMsg('actmsg','a reason is required',true);return}
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);if(s===200){$('invform').style.display='none';$('invreason').value=''}})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200)})}
function renderBench(){if(!bench)return;var busy=!!(state&&state.running);var groups={dry:[],takeover:[],live:[],scope:[]};
api('POST','/invalidate',{reason:r},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);
if(s===200){$('invform').style.display='none';$('invreason').value=''}kick()})}
function doReset(){api('POST','/reset',{reason:'operator reset from the page'},function(s,d){setMsg('actmsg',d.message||d.error,s!==200);kick()})}
/* Same rule as the acceptance rows: the tool list is built once and then
only updated, so a run starting never rewrites the arg fields the
operator has typed into or the button being pressed. */
function renderBench(){if(!bench)return;var ids=[];
bench.tools.forEach(function(t){ids.push(t.id+':'+(t.ported?1:0)+(t.installed?1:0))});
var sig=ids.join(',');
if(sig!==benchKey){benchKey=sig;buildBench()}
updateBench()}
function buildBench(){var groups={dry:[],takeover:[],live:[],scope:[]};
bench.tools.forEach(function(t){(groups[t.safety]||(groups[t.safety]=[])).push(t)});var h='';
['dry','takeover','scope','live'].forEach(function(g){if(!groups[g]||!groups[g].length)return;
h+="<div class='card grp'><h2>"+esc(g)+"</h2>";
groups[g].forEach(function(t){var can=t.ported&&t.installed&&!busy;var why=!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':''));
groups[g].forEach(function(t){var d=esc(t.id);
h+="<div class='tool' style='border-bottom:1px solid var(--line);padding:8px 0'><div><b>"+esc(t.title)+"</b> <span class='tid'>"+esc(t.script)+"</span> <span class='badge'>"+esc(t.where)+"</span>"+(t.ported?'':"<span class='badge'>unported</span>")+"</div>";
h+="<div class='hint' style='margin:2px 0 4px'>"+esc(t.desc)+"</div>";
if(t.args&&t.args.length){h+="<div class='argrow'>";t.args.forEach(function(a){var iid='arg-'+t.id+'-'+a.name;
if(a.type==='choice'){h+="<label>"+esc(a.name)+" <select id='"+iid+"'>"+a.choices.map(function(c){return "<option"+(c===a.default?' selected':'')+">"+esc(c)+"</option>"}).join('')+"</select></label>"}
else{h+="<label>"+esc(a.name)+" <input type='"+(a.type==='str'?'text':'number')+"' step='any' id='"+iid+"' value='"+esc(a.default==null?'':a.default)+"' title='"+esc(a.help)+"' style='width:90px'></label>"}});h+="</div>"}
h+="<div class='actions'><button class='pri' "+(can?'':'disabled')+" title='"+esc(why)+"' onclick='startTool(\""+esc(t.id)+"\")'>Start</button>";
if(t.last)h+="<span class='hint'>last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))+"</span>";
h+="</div></div>"});
h+="<div class='actions'><button class='pri' id='tbtn-"+d+"' onclick='startTool(\""+d+"\")'>Start</button>";
h+="<span class='hint' id='tlast-"+d+"'></span></div></div>"});
h+="</div>"});
$('tools').innerHTML=h}
function startTool(id){var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
$('tools').innerHTML=h;benchEls={};
bench.tools.forEach(function(t){benchEls[t.id]={btn:$('tbtn-'+t.id),last:$('tlast-'+t.id)}})}
function updateBench(){if(!benchEls)return;var busy=isBusy();
bench.tools.forEach(function(t){var e=benchEls[t.id];if(!e)return;
var can=t.ported&&t.installed&&!busy;
setDis(e.btn,!can);
setProp(e.btn,'title',!t.ported?'not yet ported to the bench page':(!t.installed?'script not installed on this image':(busy?'a run is in progress':'')));
setHtml(e.last,t.last?("last: "+esc(t.last.result?t.last.result.result:'?')+" "+esc(fmtTs(t.last.ts))):'')})}
function startTool(id){if(isBusy())return;var t=null;bench.tools.forEach(function(x){if(x.id===id)t=x});if(!t)return;var args={};
(t.args||[]).forEach(function(a){var e=$('arg-'+id+'-'+a.name);if(e)args[a.name]=e.value});
var body={tool:id,args:args};if(t.safety==='live'){if(!confirmLive())return;body.ack_live=true}
api('POST','/bench/start',body,function(s,d){setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true})}
pending=nowMs();pendingId=null;updateBench();updateGroups();
setMsg('benchmsg','starting '+id+'...');
api('POST','/bench/start',body,function(s,d){
if(s!==200){pending=0;updateBench();updateGroups()}
setMsg('benchmsg',d.message||d.error,s!==200);if(s===200)benchNeedsRefresh=true;kick()})}
setIgnoreReq(ignoreReq);
poll();
</script></body></html>
+42 -3
View File
@@ -9,7 +9,10 @@ page. Read-only calls need the origin checks only.
Routes
GET / the page
GET /state campaign + per-test state + running run
GET /state campaign + per-test state + running run.
Carries an ETag; If-None-Match on an
unchanged state gets a 304, which is what an
idle page polls for.
GET /catalog test definitions (title, steps, covers...)
GET /bench bench tool listing
GET /result?test&ts one full result record (log, evidence)
@@ -23,6 +26,7 @@ Routes
POST /reset {reason}
POST /export build + save the artifact, returns it
"""
import hashlib
import hmac
import json
import os
@@ -96,6 +100,11 @@ class App:
class Handler(BaseHTTPRequestHandler):
server_version = "forgetest"
# Keep-alive: the page polls, so a handshake and a fresh thread per
# request is pure overhead. Idle connections are dropped after the
# timeout rather than holding a thread forever.
protocol_version = "HTTP/1.1"
timeout = 30
app = None # set on the server class
# -- plumbing ---------------------------------------------------------
@@ -108,6 +117,7 @@ class Handler(BaseHTTPRequestHandler):
body = json.dumps(body, sort_keys=True).encode("utf-8")
elif isinstance(body, str):
body = body.encode("utf-8")
self.responded = True
self.send_response(status)
self.send_header("Content-Type", ctype + ("; charset=utf-8" if ctype.startswith("text/") else ""))
self.send_header("Content-Length", str(len(body)))
@@ -119,8 +129,25 @@ class Handler(BaseHTTPRequestHandler):
if self.command != "HEAD":
self.wfile.write(body)
def _send_304(self, etag):
self.responded = True
self.send_response(304)
self.send_header("ETag", etag)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", "0")
self.end_headers()
def _deny(self, status, msg):
self._send(status, {"error": msg})
# One response per request: on a kept-alive connection a second
# one would desynchronize the stream.
if getattr(self, "responded", False):
return
# A refused POST still has its body in the socket. Leaving it
# there would make the next read on this connection take the
# body for a request line, so the connection ends here.
# send_header sets close_connection from this.
extra = None if getattr(self, "body_read", True) else {"Connection": "close"}
self._send(status, {"error": msg}, extra=extra)
def _read_ok(self):
if not origin_ok(self.headers):
@@ -141,10 +168,12 @@ class Handler(BaseHTTPRequestHandler):
def _body_json(self):
n = int(self.headers.get("Content-Length") or 0)
if n <= 0:
self.body_read = True
return {}
if n > 1 << 20:
raise ValueError("body too large")
raw = self.rfile.read(n)
self.body_read = True
ctype = self.headers.get("Content-Type", "")
if "json" in ctype:
data = json.loads(raw.decode("utf-8"))
@@ -161,14 +190,22 @@ class Handler(BaseHTTPRequestHandler):
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
if not self._read_ok():
return
try:
if path == "/":
self._send(200, _page.render(self.app.token), "text/html")
elif path == "/state":
# Conditional: an idle page polls an unchanged state, and
# a 304 spares both ends the payload and the re-render.
state, _ = r.state()
self._send(200, state)
body = json.dumps(state, sort_keys=True).encode("utf-8")
etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32]
if self.headers.get("If-None-Match") == etag:
self._send_304(etag)
else:
self._send(200, body, extra={"ETag": etag})
elif path == "/catalog":
self._send(200, {"tests": [t.describe() for t in r.tests()],
"catalog_hash": r.catalog_hash})
@@ -210,6 +247,8 @@ class Handler(BaseHTTPRequestHandler):
path = url.path
query = urllib.parse.parse_qs(url.query)
r = self.app.runner
self.responded = False
self.body_read = False
if not self._write_ok(query):
return
try:
+199
View File
@@ -0,0 +1,199 @@
"""What keeps the page answering the operator instead of the timer.
Two things went wrong on the bench and are pinned here:
- the state cost. Every poll re-read and re-parsed the whole result log,
and recomputed every test's domain fingerprint. A result record carries
its run log, so the file reaches megabytes over a campaign and the poll
grew with it. Both are now parsed and computed once.
- the wasted payload. An idle page polls an unchanged state; it now gets
a 304 instead of the whole thing.
The click-swallowing defect these were found with lives in the page's
JavaScript and is not reachable from here: rows, prompt buttons and tool
entries are built once and afterwards only updated in place, because a
poll that rebuilt them removed the button between the operator's mousedown
and mouseup, and no click event was ever raised. `test_page_never_rebuilds`
holds the shape of that rule.
"""
import json
import os
import shutil
import tempfile
import unittest
import helpers
from forgetest import catalog, manifest as manifest_mod, page
from forgetest.log import Log
def t_noop(ctx):
pass
class LogCacheTests(unittest.TestCase):
"""The log is append-only, so a read parses each line exactly once."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-log-")
self.path = os.path.join(self.tmp, "results.jsonl")
self.log = Log(self.path)
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def write(self, *lines):
with open(self.path, "a", encoding="utf-8") as f:
for line in lines:
f.write(line + "\n")
def test_missing_file_reads_empty(self):
self.assertEqual(self.log.read(), [])
def test_appends_are_picked_up(self):
self.log.append({"t": "campaign", "id": "c1"})
self.assertEqual([r["id"] for r in self.log.read()], ["c1"])
self.log.append({"t": "result", "test": "a.b"})
self.log.append({"t": "result", "test": "c.d"})
recs = self.log.read()
self.assertEqual([r["t"] for r in recs], ["campaign", "result", "result"])
self.assertEqual(recs[2]["test"], "c.d")
def test_only_new_bytes_are_parsed(self):
for i in range(5):
self.log.append({"t": "result", "test": "t%d" % i})
self.log.read()
# a second read must not touch the file at all
real_open = open
opened = []
def counting_open(*a, **kw):
opened.append(a[0])
return real_open(*a, **kw)
import builtins
builtins.open = counting_open
try:
recs = self.log.read()
finally:
builtins.open = real_open
self.assertEqual(opened, [], "an unchanged log was re-opened")
self.assertEqual(len(recs), 5)
def test_read_returns_a_fresh_list(self):
"""Callers filter and sort the result; the cache must not be theirs."""
self.log.append({"t": "result", "test": "a.b"})
first = self.log.read()
first.append({"t": "bogus"})
self.assertEqual(len(self.log.read()), 1)
def test_partial_trailing_line_is_held_not_counted_corrupt(self):
self.write('{"t":"result","test":"a.b"}')
with open(self.path, "a", encoding="utf-8") as f:
f.write('{"t":"result","te') # a line still being written
recs = self.log.read()
self.assertEqual(len(recs), 1)
self.assertEqual(self.log.corrupt, 0)
with open(self.path, "a", encoding="utf-8") as f:
f.write('st":"c.d"}\n') # ... now finished
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 0)
def test_corrupt_lines_counted_once_not_per_read(self):
self.write('{"t":"result","test":"a.b"}', "not json at all", '["not","a","record"]',
'{"t":"result","test":"c.d"}')
recs = self.log.read()
self.assertEqual([r["test"] for r in recs], ["a.b", "c.d"])
self.assertEqual(self.log.corrupt, 2)
self.log.read()
self.log.read()
self.assertEqual(self.log.corrupt, 2, "corrupt lines re-counted on every read")
def test_replaced_file_is_read_again(self):
self.write('{"t":"result","test":"a.b"}', '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
with open(self.path, "w", encoding="utf-8") as f:
f.write('{"t":"campaign","id":"c9"}\n')
recs = self.log.read()
self.assertEqual([r.get("id") for r in recs], ["c9"])
def test_blank_lines_are_not_corrupt(self):
self.write('{"t":"result","test":"a.b"}', "", " ", '{"t":"result","test":"c.d"}')
self.assertEqual(len(self.log.read()), 2)
self.assertEqual(self.log.corrupt, 0)
class FingerprintCacheTests(unittest.TestCase):
"""Memoized per manifest: the page recomputes every test's fingerprint
on every poll, and a manifest never changes under a running tool."""
def setUp(self):
self.man = helpers.make_manifest()
self.t = helpers.make_test("fake.fp", [("forgectrl", "src/ui.c")], fn=t_noop)
def test_repeat_calls_agree(self):
first = self.t.fingerprint(self.man)
self.assertEqual(self.t.fingerprint(self.man), first)
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_changed_manifest_is_not_served_from_the_cache(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/ui.c", "ui v2")
self.assertNotEqual(self.t.fingerprint(man2), first)
# and back again: the cache must not have latched the new one either
self.assertEqual(self.t.fingerprint(self.man), first)
def test_a_file_outside_the_coverage_does_not_move_it(self):
first = self.t.fingerprint(self.man)
man2 = helpers.with_file(self.man, "forgectrl", "src/cool.c", "cool v2")
self.assertEqual(self.t.fingerprint(man2), first)
def test_manifest_without_a_content_hash_is_not_cached(self):
"""The cache is keyed by the manifest's content hash. A manifest
that has none must still fingerprint correctly, not collide."""
a = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "aaa"]]}},
"platform": {}})
b = manifest_mod.Manifest({"components": {"forgectrl": {"files": [["src/ui.c", "bbb"]]}},
"platform": {}})
self.assertIsNone(a.content_sha)
self.assertNotEqual(self.t.fingerprint(a), self.t.fingerprint(b))
def test_component_file_list_is_stable_and_read_only(self):
files = self.man.files("forgectrl")
self.assertEqual(files, self.man.files("forgectrl"))
self.assertIsNone(self.man.files("no-such-component"))
class PageTests(unittest.TestCase):
def test_page_never_rebuilds_what_the_operator_may_be_pressing(self):
"""A poll must update rows, prompt buttons and tool entries in
place. Assigning innerHTML to their containers on every poll is
what swallowed the clicks; only the one-time build may do it."""
html = page.render("0" * 32)
for container, builder in (("groups", "buildGroups"), ("tools", "buildBench")):
self.assertIn("function %s(" % builder, html,
"no one-time builder for #%s" % container)
assigns = html.count("$('%s').innerHTML=" % container)
self.assertEqual(assigns, 1,
"#%s is assigned innerHTML %d times; it belongs to the "
"builder alone" % (container, assigns))
# the per-poll path writes through the guarded setters only
self.assertIn("function setHtml(e,h){if(e&&e.__h!==h)", html)
self.assertIn("function updateGroups()", html)
self.assertIn("function updateBench()", html)
# the prompt buttons are rebuilt only when the prompt changes
self.assertIn("if(pk!==promptKey)", html)
def test_page_is_self_contained_ascii(self):
html = page.render("0" * 32)
self.assertNotIn("__TOKEN__", html)
html.encode("ascii") # no stray typography in an embedded page
stray = sorted(set(hex(ord(c)) for c in html if ord(c) < 32 and c != "\n"))
self.assertEqual(stray, [], "control characters in the page source")
for remote in ("http://", "https://", "//cdn"):
self.assertNotIn(remote, html)
if __name__ == "__main__":
unittest.main()
+70 -4
View File
@@ -97,6 +97,11 @@ class ServerTests(unittest.TestCase):
# -- helpers ----------------------------------------------------------
def call(self, method, path, body=None, token=True, headers=None):
st, payload, _ = self.call_h(method, path, body, token, headers)
return st, payload
def call_h(self, method, path, body=None, token=True, headers=None):
"""As call, plus the response headers."""
url = "http://127.0.0.1:%d%s" % (self.port, path)
hdrs = {"Host": "127.0.0.1:%d" % self.port}
if token:
@@ -112,14 +117,15 @@ class ServerTests(unittest.TestCase):
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
st = r.status
ct = r.headers.get("Content-Type", "")
rh = r.headers
except urllib.error.HTTPError as e:
raw = e.read()
st = e.code
ct = e.headers.get("Content-Type", "")
rh = e.headers
ct = rh.get("Content-Type", "")
if "json" in ct:
return st, json.loads(raw.decode())
return st, raw
return st, json.loads(raw.decode()), rh
return st, raw, rh
def wait_idle(self, timeout=10):
deadline = time.time() + timeout
@@ -161,6 +167,66 @@ class ServerTests(unittest.TestCase):
st, d = self.call("GET", "/nope")
self.assertEqual(st, 404)
def test_01b_state_is_conditional(self):
"""The page polls; an unchanged state must cost a 304, and the
ETag must move as soon as the state does."""
st, d, hdrs = self.call_h("GET", "/state")
self.assertEqual(st, 200)
etag = hdrs.get("ETag")
self.assertTrue(etag and etag.startswith('"'), "no ETag on /state")
st, _, hdrs2 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 304)
self.assertEqual(hdrs2.get("ETag"), etag)
# a stale validator must not be honored
st, _, _ = self.call_h("GET", "/state", headers={"If-None-Match": '"stale"'})
self.assertEqual(st, 200)
# and the validator moves with the state
self.call("POST", "/invalidate", {"reason": "etag check"})
st, _, hdrs3 = self.call_h("GET", "/state", headers={"If-None-Match": etag})
self.assertEqual(st, 200)
self.assertNotEqual(hdrs3.get("ETag"), etag)
def test_01c_connection_is_kept_alive(self):
"""A poll per second over a fresh TCP connection each time is
waste the board does not need to pay."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
for _ in range(3):
c.request("GET", "/state", headers={"Host": "127.0.0.1:%d" % self.port})
r = c.getresponse()
r.read()
self.assertEqual(r.status, 200)
self.assertEqual(r.version, 11)
self.assertNotEqual((r.getheader("Connection") or "").lower(), "close")
self.assertIsNotNone(r.getheader("Content-Length"))
finally:
c.close()
def test_01d_refused_post_does_not_poison_the_connection(self):
"""A POST refused before its body is read leaves that body in the
socket. On a kept-alive connection the next read would take it for
a request line, so a refusal must end the connection instead."""
import http.client
c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
try:
body = json.dumps({"test": "fake.pass"}).encode()
c.request("POST", "/start", body=body,
headers={"Host": "127.0.0.1:%d" % self.port,
"Content-Type": "application/json"}) # no token
r = c.getresponse()
payload = json.loads(r.read().decode())
self.assertEqual(r.status, 403)
self.assertEqual(payload["error"], "authentication required")
self.assertEqual((r.getheader("Connection") or "").lower(), "close",
"an unread body was left on a connection kept alive")
finally:
c.close()
# the server is still healthy, and the body was never taken for a request
st, d = self.call("GET", "/state")
self.assertEqual(st, 200)
self.assertIsNone(d["running"])
def test_02_run_pass_opens_campaign(self):
st, d = self.call("GET", "/state")
self.assertIsNone(d["campaign"])