test: reuse browser sync probe session

This commit is contained in:
Brad Stein 2026-05-02 14:21:33 -03:00
parent ba2514021c
commit ed63827ae0
9 changed files with 104 additions and 19 deletions

View File

@ -344,3 +344,19 @@ Context: 0.17.14 can keep one Lesavka session alive across multiple measured seg
- [x] Update manual probe contract tests for the adaptive artifacts and controls. - [x] Update manual probe contract tests for the adaptive artifacts and controls.
- [x] Run focused script checks and package checks. - [x] Run focused script checks and package checks.
- [x] Push clean semver `0.17.15` for installed client/server testing. - [x] Push clean semver `0.17.15` for installed client/server testing.
## 0.17.16 Continuous Browser Evidence Checklist
Context: 0.17.15 proved the adaptive/live-edit loop is structurally useful, but each segment restarted the Tethys browser/getUserMedia receiver. That contaminates calibration segments with receiver startup noise and keeps usable coded pairs too low (`3-5` pairs instead of the `8+` needed for safe calibration). 0.17.16 is scoped to making the probe evidence continuous and attributable before any more media playout changes.
- [x] Keep 0.17.16 scoped to probe/tooling reliability; do not change server media playout policy, freshness ceilings, queue policy, or UAC smoothness.
- [x] Make adaptive mirrored runs keep one Tethys browser/getUserMedia session alive after the first segment.
- [x] Preserve single-segment/manual probe behavior by default.
- [x] Add an explicit `BROWSER_CONSUMER_REUSE_SESSION` control to the browser probe runner.
- [x] Verify browser uploads by start token so the fetched WebM belongs to the segment just triggered.
- [x] Track browser upload counts/tokens in `browser_consumer_probe.py` status JSON for post-run debugging.
- [x] Wire adaptive mirrored mode to reuse the existing Tethys receiver for segments 2+.
- [x] Keep calibration mutation behind the existing analyzer `calibration.ready=true` gate.
- [x] Update manual probe contract tests for continuous browser session behavior.
- [x] Run shell syntax checks, focused contract tests, and package checks.
- [ ] Push clean semver `0.17.16` for installed client/server testing.

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.17.15" version = "0.17.16"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.17.15" version = "0.17.16"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.17.15" version = "0.17.16"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.17.15" version = "0.17.16"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.17.15" version = "0.17.16"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -5,6 +5,7 @@ import json
import socketserver import socketserver
import threading import threading
import time import time
import urllib.parse
from pathlib import Path from pathlib import Path
@ -36,6 +37,9 @@ class ProbeState:
"devices": [], "devices": [],
"page_message": "booting", "page_message": "booting",
"last_update": time.time(), "last_update": time.time(),
"active_start_token": 0,
"uploaded_start_token": None,
"upload_count": 0,
} }
self.write_status() self.write_status()
@ -62,6 +66,8 @@ class ProbeState:
with self.lock: with self.lock:
self.start_token += 1 self.start_token += 1
self.status.update({ self.status.update({
"active_start_token": self.start_token,
"uploaded_start_token": None,
"recording": False, "recording": False,
"uploaded": False, "uploaded": False,
"last_error": None, "last_error": None,
@ -71,12 +77,15 @@ class ProbeState:
self.write_status() self.write_status()
return self.snapshot() return self.snapshot()
def store_upload(self, blob: bytes) -> dict: def store_upload(self, blob: bytes, start_token: int | None) -> dict:
with self.lock: with self.lock:
self.output_path.parent.mkdir(parents=True, exist_ok=True) self.output_path.parent.mkdir(parents=True, exist_ok=True)
self.output_path.write_bytes(blob) self.output_path.write_bytes(blob)
upload_count = int(self.status.get("upload_count") or 0) + 1
self.status.update({ self.status.update({
"uploaded": True, "uploaded": True,
"uploaded_start_token": start_token,
"upload_count": upload_count,
"recording": False, "recording": False,
"page_message": f"capture uploaded to {self.output_path}", "page_message": f"capture uploaded to {self.output_path}",
"upload_size": len(blob), "upload_size": len(blob),
@ -224,7 +233,7 @@ async function maybeStartRecording() {{
recorder.ondataavailable = event => {{ if (event.data && event.data.size > 0) chunks.push(event.data); }}; recorder.ondataavailable = event => {{ if (event.data && event.data.size > 0) chunks.push(event.data); }};
recorder.onstop = async () => {{ recorder.onstop = async () => {{
const blob = new Blob(chunks, {{ type: recorder.mimeType || 'video/webm' }}); const blob = new Blob(chunks, {{ type: recorder.mimeType || 'video/webm' }});
await postBlob('/upload', blob); await postBlob('/upload?start_token=' + encodeURIComponent(startToken), blob);
recording = false; recording = false;
}}; }};
recorder.start(250); recorder.start(250);
@ -257,7 +266,8 @@ class ProbeHandler(http.server.BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self) -> None: def do_GET(self) -> None:
if self.path in ("/", "/index.html"): parsed = urllib.parse.urlparse(self.path)
if parsed.path in ("/", "/index.html"):
snap = self.state.snapshot() snap = self.state.snapshot()
self.state.update({ self.state.update({
"page_message": "html served", "page_message": "html served",
@ -265,27 +275,35 @@ class ProbeHandler(http.server.BaseHTTPRequestHandler):
}) })
self._send(200, page_html(self.state.duration_seconds).encode("utf-8"), "text/html; charset=utf-8") self._send(200, page_html(self.state.duration_seconds).encode("utf-8"), "text/html; charset=utf-8")
return return
if self.path == "/command": if parsed.path == "/command":
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
if self.path == "/status": if parsed.path == "/status":
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")
def do_POST(self) -> None: def do_POST(self) -> None:
parsed = urllib.parse.urlparse(self.path)
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) body = self.rfile.read(length)
if self.path == "/status": if parsed.path == "/status":
payload = json.loads(body.decode("utf-8")) payload = json.loads(body.decode("utf-8"))
self.state.update(payload) self.state.update(payload)
self._send(200, json.dumps(self.state.snapshot()).encode("utf-8")) self._send(200, json.dumps(self.state.snapshot()).encode("utf-8"))
return return
if self.path == "/start": if parsed.path == "/start":
self._send(200, json.dumps(self.state.request_start()).encode("utf-8")) self._send(200, json.dumps(self.state.request_start()).encode("utf-8"))
return return
if self.path == "/upload": if parsed.path == "/upload":
self._send(200, json.dumps(self.state.store_upload(body)).encode("utf-8")) query = urllib.parse.parse_qs(parsed.query)
start_token = None
if query.get("start_token"):
try:
start_token = int(query["start_token"][0])
except ValueError:
start_token = None
self._send(200, json.dumps(self.state.store_upload(body, start_token)).encode("utf-8"))
return return
self._send(404, b"not found", "text/plain; charset=utf-8") self._send(404, b"not found", "text/plain; charset=utf-8")

View File

@ -15,6 +15,7 @@ LESAVKA_SERVER_ADDR=${LESAVKA_SERVER_ADDR:-https://38.28.125.112:50051}
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15} PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-15}
BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}} BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}}
BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-} BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-}
BROWSER_CONSUMER_REUSE_SESSION=${BROWSER_CONSUMER_REUSE_SESSION:-0}
SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-} SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-}
BROWSER_PORT=${BROWSER_PORT:-18443} BROWSER_PORT=${BROWSER_PORT:-18443}
REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py} REMOTE_SCRIPT=${REMOTE_SCRIPT:-/tmp/lesavka-browser-consumer-probe.py}
@ -44,7 +45,8 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
"${BROWSER_RECORD_SECONDS}" \ "${BROWSER_RECORD_SECONDS}" \
"${BROWSER_PORT}" \ "${BROWSER_PORT}" \
"${DISPLAY_ENV}" \ "${DISPLAY_ENV}" \
"${REMOTE_RUNTIME_DIR}" <<'REMOTE_SETUP' "${REMOTE_RUNTIME_DIR}" \
"${BROWSER_CONSUMER_REUSE_SESSION}" <<'REMOTE_SETUP'
set -euo pipefail set -euo pipefail
remote_script=$1 remote_script=$1
remote_capture=$2 remote_capture=$2
@ -54,8 +56,22 @@ duration=$5
port=$6 port=$6
display_env=$7 display_env=$7
runtime_dir=$8 runtime_dir=$8
reuse_session=$9
dbus_address="" dbus_address=""
xauthority_path="" xauthority_path=""
if [[ "${reuse_session}" == "1" ]] && status_json="$(curl --max-time 2 -fsS "http://127.0.0.1:${port}/status" 2>/dev/null)"; then
if STATUS_JSON="${status_json}" python3 - <<'PY'
import json
import os
import sys
status = json.loads(os.environ["STATUS_JSON"])
sys.exit(0 if status.get("ready") else 1)
PY
then
exit 0
fi
fi
firefox_pid="$(pgrep -n -x firefox-esr || true)" firefox_pid="$(pgrep -n -x firefox-esr || true)"
if [[ -n "${firefox_pid}" && -r "/proc/${firefox_pid}/environ" ]]; then if [[ -n "${firefox_pid}" && -r "/proc/${firefox_pid}/environ" ]]; then
while IFS='=' read -r key value; do while IFS='=' read -r key value; do
@ -128,12 +144,22 @@ while true; do
done done
echo "==> triggering browser recording" echo "==> triggering browser recording"
if ! ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start >/dev/null"; then start_json=""
if ! start_json="$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "curl --max-time 10 -fsS -X POST http://127.0.0.1:${BROWSER_PORT}/start")"; then
status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true) status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true)
echo "browser consumer start request failed or timed out" >&2 echo "browser consumer start request failed or timed out" >&2
[[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2 [[ -n "${status_json:-}" ]] && echo "last status: ${status_json}" >&2
exit 1 exit 1
fi fi
browser_start_token="$(START_JSON="${start_json}" python3 - <<'PY'
import json
import os
status = json.loads(os.environ["START_JSON"])
print(int(status.get("start_token") or 0))
PY
)"
echo " ↪ browser_start_token=${browser_start_token}"
sleep 1 sleep 1
@ -155,7 +181,17 @@ deadline_upload=$(( $(date +%s) + PROBE_DURATION_SECONDS + 60 ))
while true; do while true; do
status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true) status_json=$(ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_STATUS}' && cat '${REMOTE_STATUS}'" || true)
if [[ -n "${status_json}" ]]; then if [[ -n "${status_json}" ]]; then
if STATUS_JSON="${status_json}" python3 -c 'import json, os, sys; status = json.loads(os.environ["STATUS_JSON"]); sys.exit(0 if status.get("uploaded") else 1)' if STATUS_JSON="${status_json}" BROWSER_START_TOKEN="${browser_start_token}" python3 - <<'PY'
import json
import os
import sys
status = json.loads(os.environ["STATUS_JSON"])
expected = int(os.environ["BROWSER_START_TOKEN"])
uploaded = bool(status.get("uploaded"))
uploaded_token = status.get("uploaded_start_token")
sys.exit(0 if uploaded and uploaded_token == expected else 1)
PY
then then
echo "==> browser recording uploaded" echo "==> browser recording uploaded"
break break

View File

@ -29,6 +29,7 @@ LESAVKA_SYNC_APPLY_CALIBRATION=${LESAVKA_SYNC_APPLY_CALIBRATION:-0}
LESAVKA_SYNC_SAVE_CALIBRATION=${LESAVKA_SYNC_SAVE_CALIBRATION:-0} LESAVKA_SYNC_SAVE_CALIBRATION=${LESAVKA_SYNC_SAVE_CALIBRATION:-0}
LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video} LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video}
LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1} LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1}
LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}
LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3} LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}
STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_PORT=${STIMULUS_PORT:-18444}
STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10}
@ -438,14 +439,21 @@ start_real_lesavka_client() {
run_browser_capture_with_real_driver() { run_browser_capture_with_real_driver() {
local segment_label="$1" local segment_label="$1"
local segment_output_dir="$2" local segment_output_dir="$2"
local segment_index="${3:-1}"
local record_seconds=$((PROBE_DURATION_SECONDS + 3)) local record_seconds=$((PROBE_DURATION_SECONDS + 3))
local wait_seconds=$((PROBE_DURATION_SECONDS + 2)) local wait_seconds=$((PROBE_DURATION_SECONDS + 2))
local driver_command="curl -fsS -X POST http://127.0.0.1:${STIMULUS_PORT}/start >/dev/null; sleep ${wait_seconds}" local driver_command="curl -fsS -X POST http://127.0.0.1:${STIMULUS_PORT}/start >/dev/null; sleep ${wait_seconds}"
local reuse_browser_session=0
if [[ "${LESAVKA_SYNC_CONTINUOUS_BROWSER}" == "1" && "${segment_index}" != "1" ]]; then
reuse_browser_session=1
fi
mkdir -p "${segment_output_dir}" mkdir -p "${segment_output_dir}"
echo "==> starting Tethys browser consumer and mirrored driver (${segment_label})" echo "==> starting Tethys browser consumer and mirrored driver (${segment_label})"
echo " ↪ browser_consumer_reuse_session=${reuse_browser_session}"
BROWSER_RECORD_SECONDS="${record_seconds}" \ BROWSER_RECORD_SECONDS="${record_seconds}" \
PROBE_DURATION_SECONDS="${PROBE_DURATION_SECONDS}" \ PROBE_DURATION_SECONDS="${PROBE_DURATION_SECONDS}" \
BROWSER_SYNC_DRIVER_COMMAND="${driver_command}" \ BROWSER_SYNC_DRIVER_COMMAND="${driver_command}" \
BROWSER_CONSUMER_REUSE_SESSION="${reuse_browser_session}" \
SYNC_ANALYZE_EVENT_WIDTH_CODES="${PROBE_EVENT_WIDTH_CODES}" \ SYNC_ANALYZE_EVENT_WIDTH_CODES="${PROBE_EVENT_WIDTH_CODES}" \
LOCAL_OUTPUT_DIR="${segment_output_dir}" \ LOCAL_OUTPUT_DIR="${segment_output_dir}" \
LESAVKA_SERVER_ADDR="${RESOLVED_LESAVKA_SERVER_ADDR}" \ LESAVKA_SERVER_ADDR="${RESOLVED_LESAVKA_SERVER_ADDR}" \
@ -462,7 +470,7 @@ run_mirrored_segments() {
echo "==> mirrored calibration ${segment_label}" echo "==> mirrored calibration ${segment_label}"
print_upstream_calibration_state "before ${segment_label}" "${segment_dir}/calibration-before.env" print_upstream_calibration_state "before ${segment_label}" "${segment_dir}/calibration-before.env"
print_upstream_sync_state "before ${segment_label}" "${segment_dir}/planner-before.env" print_upstream_sync_state "before ${segment_label}" "${segment_dir}/planner-before.env"
if run_browser_capture_with_real_driver "${segment_label}" "${segment_dir}"; then if run_browser_capture_with_real_driver "${segment_label}" "${segment_dir}" "${segment}"; then
maybe_apply_probe_calibration "${segment_dir}" "${segment_label}" maybe_apply_probe_calibration "${segment_dir}" "${segment_label}"
print_upstream_sync_state "after ${segment_label}" "${segment_dir}/planner-after.env" print_upstream_sync_state "after ${segment_label}" "${segment_dir}/planner-after.env"
print_upstream_calibration_state "after ${segment_label}" "${segment_dir}/calibration-after.env" print_upstream_calibration_state "after ${segment_label}" "${segment_dir}/calibration-after.env"

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.17.15" version = "0.17.16"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -73,9 +73,13 @@ fn browser_sync_script_can_delegate_to_a_real_path_driver() {
for expected in [ for expected in [
"BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}}", "BROWSER_RECORD_SECONDS=${BROWSER_RECORD_SECONDS:-${PROBE_DURATION_SECONDS}}",
"BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-}", "BROWSER_SYNC_DRIVER_COMMAND=${BROWSER_SYNC_DRIVER_COMMAND:-}",
"BROWSER_CONSUMER_REUSE_SESSION=${BROWSER_CONSUMER_REUSE_SESSION:-0}",
"SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-}", "SYNC_ANALYZE_EVENT_WIDTH_CODES=${SYNC_ANALYZE_EVENT_WIDTH_CODES:-}",
"==> running custom browser sync driver", "==> running custom browser sync driver",
"bash -lc \"${BROWSER_SYNC_DRIVER_COMMAND}\"", "bash -lc \"${BROWSER_SYNC_DRIVER_COMMAND}\"",
"browser_start_token=${browser_start_token}",
"uploaded_start_token",
"BROWSER_START_TOKEN",
"--event-width-codes", "--event-width-codes",
"--report-dir \"${LOCAL_REPORT_DIR}\"", "--report-dir \"${LOCAL_REPORT_DIR}\"",
"for attempt in 1 2 3 4 5", "for attempt in 1 2 3 4 5",
@ -116,9 +120,12 @@ fn mirrored_sync_script_uses_real_client_capture_path() {
"LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video}", "LESAVKA_SYNC_CALIBRATION_TARGET=${LESAVKA_SYNC_CALIBRATION_TARGET:-video}",
"LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}", "LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}",
"LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1}", "LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1}",
"LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}",
"LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}", "LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}",
"LESAVKA_SYNC_ADAPTIVE_CALIBRATION", "LESAVKA_SYNC_ADAPTIVE_CALIBRATION",
"LESAVKA_SYNC_CALIBRATION_SEGMENTS=4", "LESAVKA_SYNC_CALIBRATION_SEGMENTS=4",
"browser_consumer_reuse_session=${reuse_browser_session}",
"BROWSER_CONSUMER_REUSE_SESSION=\"${reuse_browser_session}\"",
"LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer", "LESAVKA_SYNC_CALIBRATION_SEGMENTS must be a positive integer",
"run_mirrored_segments", "run_mirrored_segments",
"summarize_adaptive_probe_metrics", "summarize_adaptive_probe_metrics",