From dd394b3eb3e8782bff0d297b89e95eeb0ef5f4f1 Mon Sep 17 00:00:00 2001 From: jenkins Date: Fri, 2 Oct 2026 21:47:13 -0500 Subject: [PATCH] av-test: generic page and bounded numeric-only receipts --- .../av-observer/deployment.yaml | 26 ++++- .../bstein-dev-home/av-observer/index.html | 13 ++- .../bstein-dev-home/av-observer/ingress.yaml | 6 + .../av-observer/kustomization.yaml | 6 + .../bstein-dev-home/av-observer/metrics.js | 2 +- .../bstein-dev-home/av-observer/nginx.conf | 32 +++++- .../bstein-dev-home/av-observer/observer.js | 19 +++- .../av-observer/report-service/receipts.py | 68 ++++++++++++ .../av-observer/report-service/server.py | 103 ++++++++++++++++++ .../av-observer/report-service/validation.py | 48 ++++++++ .../bstein-dev-home/av-observer/reporting.js | 91 ++++++++++++++++ 11 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 services/bstein-dev-home/av-observer/report-service/receipts.py create mode 100644 services/bstein-dev-home/av-observer/report-service/server.py create mode 100644 services/bstein-dev-home/av-observer/report-service/validation.py create mode 100644 services/bstein-dev-home/av-observer/reporting.js diff --git a/services/bstein-dev-home/av-observer/deployment.yaml b/services/bstein-dev-home/av-observer/deployment.yaml index d0bf66fb..a49ae4e0 100644 --- a/services/bstein-dev-home/av-observer/deployment.yaml +++ b/services/bstein-dev-home/av-observer/deployment.yaml @@ -1,4 +1,4 @@ -# Static assets only; no API, uploads, credentials or outbound connections. +# Static page plus numeric-only receipts; no media, installed credentials or egress. apiVersion: apps/v1 kind: Deployment metadata: @@ -36,14 +36,30 @@ spec: requests: {cpu: 10m, memory: 16Mi} limits: {cpu: 100m, memory: 64Mi} readinessProbe: - httpGet: {path: /lesavka-av-test/, port: http} + httpGet: {path: /av-test/, port: http} livenessProbe: - httpGet: {path: /lesavka-av-test/, port: http} + httpGet: {path: /av-test/, port: http} initialDelaySeconds: 10 volumeMounts: - {name: config, mountPath: /etc/nginx/observer, readOnly: true} - - {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true} + - {name: assets, mountPath: /usr/share/nginx/html/av-test, readOnly: true} - {name: tmp, mountPath: /tmp} + - name: receipts + image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6 + command: [python, -u, -B, /app/server.py] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + resources: + requests: {cpu: 10m, memory: 16Mi} + limits: {cpu: 100m, memory: 64Mi} + readinessProbe: + exec: + command: [python, -c, "import socket; socket.create_connection(('127.0.0.1', 8081), 2).close()"] + periodSeconds: 10 + volumeMounts: + - {name: receipts, mountPath: /app, readOnly: true} volumes: - name: config configMap: {name: lesavka-av-observer-nginx} @@ -51,3 +67,5 @@ spec: configMap: {name: lesavka-av-observer-assets} - name: tmp emptyDir: {medium: Memory, sizeLimit: 8Mi} + - name: receipts + configMap: {name: lesavka-av-observer-receipts} diff --git a/services/bstein-dev-home/av-observer/index.html b/services/bstein-dev-home/av-observer/index.html index cdb2d8ff..5db27f16 100644 --- a/services/bstein-dev-home/av-observer/index.html +++ b/services/bstein-dev-home/av-observer/index.html @@ -1,18 +1,19 @@ - -Lesavka — bundled A/V observer + +A/V Test -

Bundled A/V observer

-

No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.

+

A/V Test

+

Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.

Stopped

+

Reporting starts with the test.

No observations.
-

Sync and capture-to-RCT freshness: not measured by this observer. +

Audio/video sync and end-to-end delay: not measured by this check. Video callback gaps can include browser scheduling. Silence is not proof of audio loss. -Use the qualified paired-marker/clock-bounded probe for release acceptance.

+A separate paired-signal measurement is needed to verify synchronization.

diff --git a/services/bstein-dev-home/av-observer/ingress.yaml b/services/bstein-dev-home/av-observer/ingress.yaml index 1f8fba01..c5788c8f 100644 --- a/services/bstein-dev-home/av-observer/ingress.yaml +++ b/services/bstein-dev-home/av-observer/ingress.yaml @@ -15,6 +15,12 @@ spec: - host: bstein.dev http: paths: + - path: /av-test + pathType: Prefix + backend: + service: + name: lesavka-av-observer + port: {number: 80} - path: /lesavka-av-test pathType: Prefix backend: diff --git a/services/bstein-dev-home/av-observer/kustomization.yaml b/services/bstein-dev-home/av-observer/kustomization.yaml index d633d081..b2169498 100644 --- a/services/bstein-dev-home/av-observer/kustomization.yaml +++ b/services/bstein-dev-home/av-observer/kustomization.yaml @@ -13,5 +13,11 @@ configMapGenerator: - index.html - observer.js - metrics.js + - reporting.js - activity-worklet.js - style.css + - name: lesavka-av-observer-receipts + files: + - report-service/server.py + - report-service/receipts.py + - report-service/validation.py diff --git a/services/bstein-dev-home/av-observer/metrics.js b/services/bstein-dev-home/av-observer/metrics.js index 9598b9d9..e42fb1a0 100644 --- a/services/bstein-dev-home/av-observer/metrics.js +++ b/services/bstein-dev-home/av-observer/metrics.js @@ -23,7 +23,7 @@ export class VideoMetrics { } export function receipt(video,audio,settings,elapsedMs,reason) { - return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs, + return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs, settings,video:video.summary(),audio, syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false, releaseEligible:false,recordedMedia:false}; diff --git a/services/bstein-dev-home/av-observer/nginx.conf b/services/bstein-dev-home/av-observer/nginx.conf index 04b1345f..e6b9cd3d 100644 --- a/services/bstein-dev-home/av-observer/nginx.conf +++ b/services/bstein-dev-home/av-observer/nginx.conf @@ -7,7 +7,9 @@ http { default_type application/octet-stream; access_log off; server_tokens off; - client_max_body_size 1k; + client_max_body_size 4k; + client_body_timeout 5s; + client_header_timeout 5s; client_body_temp_path /tmp/client; proxy_temp_path /tmp/proxy; fastcgi_temp_path /tmp/fastcgi; @@ -17,19 +19,37 @@ http { listen 8080; absolute_redirect off; root /usr/share/nginx/html; - if ($request_method !~ ^(GET|HEAD)$) { return 405; } add_header Cache-Control "no-store" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "no-referrer" always; add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always; add_header Cross-Origin-Opener-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; - location = /lesavka-av-test { return 308 /lesavka-av-test/; } - location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; } - location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ { + add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; + location = /lesavka-av-test { return 308 /av-test/; } + location = /lesavka-av-test/ { return 308 /av-test/; } + location = /lesavka-av-test/index.html { return 308 /av-test/; } + location = /av-test { return 308 /av-test/; } + location = /av-test/ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + try_files /av-test/index.html =404; + } + location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|style\.css)$ { + if ($request_method !~ ^(GET|HEAD)$) { return 405; } try_files $uri =404; } + location ~ ^/av-test/api/(start|report)$ { + if ($request_method != POST) { return 405; } + proxy_pass http://127.0.0.1:8081; + proxy_connect_timeout 2s; + proxy_read_timeout 5s; + proxy_send_timeout 5s; + proxy_pass_request_headers off; + proxy_set_header Content-Type $http_content_type; + proxy_set_header Origin $http_origin; + proxy_set_header Authorization $http_authorization; + proxy_set_header Host localhost; + } location / { return 404; } } } diff --git a/services/bstein-dev-home/av-observer/observer.js b/services/bstein-dev-home/av-observer/observer.js index 5805bda1..368a0004 100644 --- a/services/bstein-dev-home/av-observer/observer.js +++ b/services/bstein-dev-home/av-observer/observer.js @@ -1,6 +1,7 @@ import {VideoMetrics,receipt} from './metrics.js'; +import {RunReporter} from './reporting.js'; const el=id=>document.getElementById(id); -let generation=0, current=null, last=null; +let generation=0, current=null, last=null, reportingRun=null; const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop()); function release(run) { if(run.released)return; @@ -18,6 +19,7 @@ function stop(reason='operator') { if(run) { release(run); last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason); + run.reporter.finish(last); el('metrics').textContent=JSON.stringify(last,null,2); el('export').disabled=false; } @@ -40,11 +42,11 @@ el('devices').onclick=async()=>{ if(generation!==ticket)return; for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) { const select=el(id), saved=select.value; - select.replaceChildren(new Option('Select Lesavka device','')); + select.replaceChildren(new Option('Select a device','')); for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId)); select.value=saved; } - el('status').textContent='Select both Lesavka devices, then Start.'; + el('status').textContent='Select a camera and microphone, then Start.'; el('devices').disabled=false; el('stop').disabled=true; } catch { if(generation===ticket)stop('device permission unavailable'); } finally {clearTimeout(timer);} @@ -61,6 +63,10 @@ el('start').onclick=async()=>{ const ticket=++generation; const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}}; current=run; last=null; + reportingRun=run; + run.reporter=new RunReporter(message=>{ + if(reportingRun===run)el('report-status').textContent=message; + }); el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true; el('status').textContent='Starting…'; run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000); @@ -84,7 +90,9 @@ el('start').onclick=async()=>{ run.node.port.onmessage=({data})=>{ if(current!==run)return; run.audio=data; run.node.port.postMessage('ack'); - el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2); + const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'); + el('metrics').textContent=JSON.stringify(sample,null,2); + if(run.observing)run.reporter.observe(sample); }; run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks())); run.source.connect(run.node);run.node.connect(run.context.destination); @@ -95,6 +103,7 @@ el('start').onclick=async()=>{ if(generation!==ticket)return; clearTimeout(run.setupTimer); run.started=performance.now(); + run.observing=true; function frame(now,metadata) { if(current!==run)return; run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames); @@ -110,7 +119,7 @@ el('stop').onclick=()=>stop(); el('export').onclick=()=>{ if(!last)return; const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'})); - const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click(); + const link=document.createElement('a');link.href=url;link.download='av-test-metrics.json';link.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); }; window.addEventListener('pagehide',()=>stop('page closed')); diff --git a/services/bstein-dev-home/av-observer/report-service/receipts.py b/services/bstein-dev-home/av-observer/report-service/receipts.py new file mode 100644 index 00000000..b6508962 --- /dev/null +++ b/services/bstein-dev-home/av-observer/report-service/receipts.py @@ -0,0 +1,68 @@ +"""Bounded ephemeral write capabilities; validated numeric receipts go to stdout. + +These are unverified browser diagnostics, never release-gate attestations. +No public read API, database, cookies, media, device identities or credentials. +""" +from collections import deque +import hashlib +import json +import secrets +import threading +import time + +from validation import validate_report + + +class Refused(Exception): + def __init__(self, status): + self.status = status + + +class ReceiptStore: + def __init__(self, emit, clock=time.monotonic, wall=time.time): + self.emit, self.clock, self.wall = emit, clock, wall + self.runs, self.starts = {}, deque() + self.lock = threading.Lock() + + def start(self): + with self.lock: + now = self.clock() + self.runs = {key: value for key, value in self.runs.items() if value["expires"] > now} + while self.starts and now-self.starts[0] >= 60: + self.starts.popleft() + if len(self.starts) >= 12 or len(self.runs) >= 64: + raise Refused(429) + run_id, token = secrets.token_hex(16), secrets.token_urlsafe(32) + self.runs[run_id] = {"token": hashlib.sha256(token.encode()).digest(), "expires": now+600, + "sequence": 0, "digest": None, "terminal": False, "last_progress": None} + self.starts.append(now) + self.log({"run_id": run_id, "sequence": 0, "state": "starting", "metrics": None}) + return {"run_id": run_id, "write_token": token} + + def report(self, body, token): + data = validate_report(body) + if type(token) is not str or len(token) != 43: + raise Refused(401) + with self.lock: + now = self.clock() + run = self.runs.get(data["run_id"]) + if not run or run["expires"] <= now or not secrets.compare_digest( + run["token"], hashlib.sha256(token.encode()).digest()): + raise Refused(401) + digest = hashlib.sha256(json.dumps(data, sort_keys=True, allow_nan=False).encode()).digest() + if data["sequence"] == run["sequence"] and run["digest"] == digest: + return # Idempotent network retry, no duplicate log entry. + if run["terminal"] or data["sequence"] <= run["sequence"]: + raise Refused(409) + if data["state"] == "running" and run["last_progress"] is not None and now-run["last_progress"] < 8: + raise Refused(429) + self.log(data) + run.update(sequence=data["sequence"], digest=digest, terminal=data["state"] != "running") + if data["state"] == "running": + run["last_progress"] = now + else: + run["expires"] = now+60 # Retain briefly for idempotent final retry. + + def log(self, data): + self.emit({"schema": "av-test.receipt.v1", "kind": "unverified-browser-diagnostic", + "received_unix_ms": int(self.wall()*1000), "release_eligible": False, **data}) diff --git a/services/bstein-dev-home/av-observer/report-service/server.py b/services/bstein-dev-home/av-observer/report-service/server.py new file mode 100644 index 00000000..7f4074fa --- /dev/null +++ b/services/bstein-dev-home/av-observer/report-service/server.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Loopback-only aggregate receipt service behind the site's exact-path proxy.""" +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn +import json +import os +import threading + +from receipts import ReceiptStore, Refused +from validation import no_duplicate_fields + + +MAX_BODY = 4096 + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass # Never log request paths, headers, tokens, bodies or errors. + + def reply(self, status, data=None): + body = json.dumps(data, allow_nan=False).encode() if data is not None else b"" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def do_GET(self): + self.reply(405) + + do_HEAD = do_GET + do_OPTIONS = do_GET + + def do_POST(self): + try: + if self.path not in ("/av-test/api/start", "/av-test/api/report"): + return self.reply(404) + if any(len(self.headers.get_all(name, [])) > 1 for name in ("Origin", "Authorization", "Content-Type")): + return self.reply(400) + if self.headers.get("Origin") != self.server.origin: + return self.reply(403) + if self.headers.get("Content-Type") != "application/json" or self.headers.get("Transfer-Encoding"): + return self.reply(415) + sizes = self.headers.get_all("Content-Length", []) + if len(sizes) != 1 or not sizes[0].isdigit() or not 0 < int(sizes[0]) <= MAX_BODY: + return self.reply(413) + raw = self.rfile.read(int(sizes[0])) + if len(raw) != int(sizes[0]): + return self.reply(400) + data = json.loads(raw, object_pairs_hook=no_duplicate_fields) + if self.path == "/av-test/api/start": + if data != {}: + return self.reply(400) + return self.reply(201, self.server.store.start()) + authorization = self.headers.get("Authorization", "") + token = authorization[7:] if authorization.startswith("Bearer ") else "" + self.server.store.report(data, token) + self.reply(204) + except Refused as error: + self.reply(error.status) + except (ValueError, UnicodeError, RecursionError, TypeError): + self.reply(400) + except (OSError, TimeoutError): + pass + + +class Server(ThreadingMixIn, HTTPServer): + daemon_threads = True + def __init__(self, address, origin, store): + self.origin, self.store = origin, store + self.workers = threading.BoundedSemaphore(4) + super().__init__(address, Handler) + + def get_request(self): + connection, address = super().get_request() + connection.settimeout(3) + return connection, address + + def process_request(self, request, client_address): + if not self.workers.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except Exception: + self.workers.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.workers.release() + + def handle_error(self, *_args): + pass # No raw exception/request logging, including unexpected input. + + +if __name__ == "__main__": + store = ReceiptStore(lambda entry: print(json.dumps(entry, allow_nan=False), flush=True)) + Server(("127.0.0.1", 8081), os.environ.get("OBSERVER_ORIGIN", "https://bstein.dev"), store).serve_forever() diff --git a/services/bstein-dev-home/av-observer/report-service/validation.py b/services/bstein-dev-home/av-observer/report-service/validation.py new file mode 100644 index 00000000..6db6d89e --- /dev/null +++ b/services/bstein-dev-home/av-observer/report-service/validation.py @@ -0,0 +1,48 @@ +"""Strict aggregate-only receipt schema: unknown fields and text are refused.""" +import math + + +STATES = {"running", "completed", "hidden", "closed", "device-change", "stopped", "failed"} +BOUNDS = { + "elapsed_ms": 180_000, + "video_width": 8192, "video_height": 8192, "video_fps": 240, + "audio_rate": 384_000, "audio_channels": 32, "audio_context_rate": 384_000, + "video_callbacks": 100_000, "video_unobserved": 100_000, "video_resets": 100_000, + "video_interval_p95_ms": 180_000, "video_gap_max_ms": 180_000, + "audio_input_frames": 100_000_000, "audio_processed_frames": 100_000_000, + "audio_zero_frames": 100_000_000, "audio_rms": 32, "audio_peak": 32, +} + + +def no_duplicate_fields(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate field") + result[key] = value + return result + + +def validate_report(data): + """Return a rebuilt, bounded numeric receipt, never caller-owned text.""" + if type(data) is not dict or set(data) != {"run_id", "sequence", "state", "metrics"}: + raise ValueError("report shape") + run_id, sequence, state = data["run_id"], data["sequence"], data["state"] + if type(run_id) is not str or len(run_id) != 32 or any(c not in "0123456789abcdef" for c in run_id): + raise ValueError("run id") + if type(sequence) is not int or not 1 <= sequence <= 64: + raise ValueError("sequence") + if type(state) is not str or state not in STATES: + raise ValueError("state") + metrics = data["metrics"] + if type(metrics) is not dict or metrics.keys() != BOUNDS.keys(): + raise ValueError("metric fields") + clean = {} + for name, limit in BOUNDS.items(): + value = metrics[name] + if value is not None and (type(value) not in (int, float) or not 0 <= value <= limit or not math.isfinite(value)): + raise ValueError("metric value") + clean[name] = value + if clean["elapsed_ms"] is None: + raise ValueError("elapsed missing") + return {"run_id": run_id, "sequence": sequence, "state": state, "metrics": clean} diff --git a/services/bstein-dev-home/av-observer/reporting.js b/services/bstein-dev-home/av-observer/reporting.js new file mode 100644 index 00000000..59342a1a --- /dev/null +++ b/services/bstein-dev-home/av-observer/reporting.js @@ -0,0 +1,91 @@ +// Only the explicit numeric projection below can leave the browser. Never +// pass a receipt, device object, error string, frame, PCM or DOM value to fetch. +export function reportMetrics(receipt) { + const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null; + const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{}; + return Object.fromEntries(Object.entries({ + elapsed_ms:receipt.elapsedMs, + video_width:s.video?.width,video_height:s.video?.height,video_fps:s.video?.frameRate, + audio_rate:s.audio?.sampleRate,audio_channels:s.audio?.channelCount, + video_callbacks:v.callbacks,video_unobserved:v.unobservedPresentedFrames, + video_resets:v.clockOrCounterResets,video_interval_p95_ms:v.callbackIntervalP95Ms, + video_gap_max_ms:v.callbackGapMaxMs,audio_input_frames:a.inputFrames, + audio_processed_frames:a.processedFrames,audio_zero_frames:a.exactZeroFrames, + audio_rms:a.rms,audio_peak:a.peak,audio_context_rate:a.sampleRate, + }).map(([key,value])=>[key,finite(value)])); +} + +export function stopState(reason) { + if(reason==='120 seconds complete')return 'completed'; + if(reason==='page hidden; timing invalid')return 'hidden'; + if(reason==='page closed')return 'closed'; + if(reason==='device ended'||reason==='device selection changed')return 'device-change'; + if(reason==='operator'||reason==='selecting devices')return 'stopped'; + return 'failed'; +} + +export class RunReporter { + constructor(status,request=globalThis.fetch.bind(globalThis)) { + this.status=status;this.request=request;this.pending=null;this.busy=false; + this.terminal=false;this.sequence=0;this.lastProgress=-Infinity;this.available=true; + status('Reporting: connecting…'); + this.ready=this.open(); + } + async open() { + try { + const r=await this.request('/av-test/api/start',{method:'POST',credentials:'omit', + headers:{'Content-Type':'application/json'},body:'{}',signal:AbortSignal.timeout(5000)}); + if(!r.ok)throw new Error('start rejected'); + const data=await r.json(); + if(!/^[a-f0-9]{32}$/.test(data.run_id)||!/^[A-Za-z0-9_-]{43}$/.test(data.write_token))throw new Error('bad receipt'); + this.runId=data.run_id;this.token=data.write_token; + this.status(`Reporting enabled · test ${this.runId.slice(0,8)}`); + } catch { + this.available=false;this.status('Reporting unavailable · Save metrics remains available'); + } + } + observe(receipt) { + if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return; + this.lastProgress=receipt.elapsedMs;this.queue('running',receipt); + } + finish(receipt) { + if(this.terminal)return; + this.terminal=true;this.queue(stopState(receipt.reason),receipt); + } + queue(state,receipt) { + // One latest snapshot, not a growing queue. Terminal state supersedes + // pending progress; a stopped run never becomes running again. + this.pending={state,metrics:reportMetrics(receipt)}; + void this.flush(); + } + async flush() { + if(this.busy)return; + this.busy=true; + try { + await this.ready; + if(!this.available){this.pending=null;return;} + while(this.pending) { + const report=this.pending;this.pending=null; + const payload={run_id:this.runId,sequence:++this.sequence,...report}; + let delivered=false; + for(let attempt=0;attempt<2&&!delivered;attempt++) { + try { + const response=await this.request('/av-test/api/report',{ + method:'POST',credentials:'omit',keepalive:true, + headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.token}`}, + body:JSON.stringify(payload),signal:AbortSignal.timeout(5000), + }); + delivered=response.ok; + if(response.status>=400&&response.status<500)break; + } catch { /* No raw exception or endpoint response is displayed. */ } + } + if(report.state!=='running') { + this.status(delivered ? `Result reported · test ${this.runId.slice(0,8)}` : + 'Result not reported · use Save metrics'); + } else if(!delivered) { + this.status('Reporting interrupted · local test continues'); + } + } + } finally {this.busy=false;} + } +}