diff --git a/CLAUDE.md b/CLAUDE.md
index 56e69c39..f04ecd6e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -343,6 +343,25 @@ fix flows to every page automatically; two hand-written HTML pages never will.
`write_rows`, verbatim `["cc"|"cf"|"cs", …]`). The record schema lives in ONE place — the header
comment of `js/arena-telemetry.js` — mirror any firmware change there + in `wedge-scan.py`
(`CTL_TAGS`/`CTL_STATE_KINDS`). Never drain while the post-mortem owns the link (its `canPoll`).
+- **Bridge-loss lifecycle (LAB-303 S1, ww18 2026-10-15) — the other fail-closed rule:** an OPEN
+ bridge socket that closes without `disconnect()` asking is a LOSS (`disconnect()` clears `_ws`
+ BEFORE `close()` — that is the discriminator; keep it). `FicTracBridgeClient` then forces apply
+ OFF, calls `_failPending('bridge lost')` (THE one place every request/reply waiter settles —
+ register any NEW request type there, e.g. #254's connector_command waiters) and only then emits
+ `'lost'`. `ArenaSession` → `runner.onBridgeLost(info)`: it STOPS the run when apply was on, when
+ `sequenceUsesBridge` finds a bridge-routed plugin command (today `fictracPluginNames`; add any new
+ bridge-routed plugin set there), or with `runSequence({bridgeRequired})` (the Studio passes it for
+ recorded runs) — via the ordinary `stop()` (serial link is fine → real STOP), never a controller
+ fault (no post-mortem). Otherwise a test run continues with a `bridge-lost` event. Outcome
+ `BRIDGE_LOST` (`summary.bridgeLost`; `run-log.js` + adapter) is NOT auto-committed, and a push
+ refuses a `log_export` whose name ≠ the run's `log.bridgeFile` (a restarted bridge serves another
+ file). Studio: persistent `#runAlert` (`Studio.showRunAlert`), not the auto-hiding `#banner`.
+- **Never await a remote call before the `?p=` load (LAB-303 S2).** `initFromUrl` awaited the course
+ repo's GitHub `patterns/` listing first, so a stalled api.github.com request left `?p=` links
+ empty until a refresh. Best-effort listings are now fire-and-forget; that is safe because
+ pattern-preview byte sources are LAYERED (`js/studio-preview-sources.js`: builtin < shared <
+ colocated < uploaded — every write names its layer, arrival order never decides). The awaits
+ before the load are allowlisted in `tests/test-studio-preview-sources.js`.
- **Wire module exports:** `js/arena-wire-g6.js` defines more than it exports —
when adding encoders/decoders, add them to the export list AND a test; audit
with `Object.keys(require('./js/arena-wire-g6.js'))` vs the page's `Wire.*`
diff --git a/arena_studio.html b/arena_studio.html
index 5e3a4a97..8eed44ca 100644
--- a/arena_studio.html
+++ b/arena_studio.html
@@ -820,6 +820,11 @@
#ghQuickLinks .ql-link:hover{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent)}
.banner{background:color-mix(in srgb,var(--warn) 12%,transparent);border:1px solid var(--warn);color:var(--warn);border-radius:8px;padding:8px 12px;margin:10px 20px;font-size:12px;display:none}
.banner.show{display:block}
+ /* Run alert: a safety event the operator must SEE (bridge lost mid-run) — stays until
+ dismissed or the next run starts, unlike #banner, which later messages replace. */
+ .run-alert{background:color-mix(in srgb,var(--danger) 12%,transparent);border-color:var(--danger);color:var(--danger);font-weight:600;align-items:center;gap:12px}
+ .run-alert.show{display:flex}
+ .run-alert button{margin-left:auto;flex:0 0 auto;background:transparent;border:1px solid currentColor;color:inherit;border-radius:5px;padding:1px 8px;cursor:pointer;font:inherit}
/* ── Edit view: tab bar (Designer | YAML) + YAML pane ───────────── */
.edit-view .edit-tabs{display:flex;border:1px solid var(--border);border-radius:5px;overflow:hidden;flex:0 0 auto}
@@ -2502,6 +2507,7 @@
+
@@ -2674,7 +2680,7 @@
Sequence · test any single condition ▶ Clear
-
+
@@ -3442,10 +3448,10 @@
Import error
-
-
-
-
+
+
+
+
@@ -3464,7 +3470,8 @@
Import error
-
+
+
@@ -3692,7 +3699,7 @@
Import error
function renderRunEvent(ev) {
if (!$('runlogBox') || !ev) return;
const div = document.createElement('div');
- if (ev.phase === 'error') div.className = 'l-err';
+ if (ev.phase === 'error' || ev.phase === 'bridge-lost') div.className = 'l-err';
else if (ev.phase === 'skip' || ev.phase === 'warn') div.className = 'l-warn';
// Reuse run-log.js's own transcript line if available, else a compact fallback.
div.textContent =
@@ -3740,7 +3747,7 @@
Import error
// broker has already aborted the runner (bench 2026-09-13, forced watchdog reset: the
// terminal event arrived with fault null). The link being down at the terminal event
// of an abort nobody requested is the same evidence — use either.
- if (sm && !sm.fault && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) {
+ if (sm && !sm.fault && !sm.bridgeLost && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) {
sm.fault = 'link_dropped';
sm.faultDetail = { kind: 'link_dropped', note: 'controller re-enumerated mid-run (watchdog self-reset?)' };
}
@@ -3934,6 +3941,12 @@
Import error
rawLine('run log NOT auto-committed (' + outcome + ') — click "⇪ Push log" to commit it anyway', 'warn');
return;
}
+ if (outcome === 'BRIDGE_LOST') {
+ // The bridge that wrote this run's file is gone; it cannot be exported from here.
+ rawLine('run log NOT auto-committed (BRIDGE_LOST) — the bridge’s file ' +
+ (log.bridgeFile || 'arena-log-*.jsonl') + ' stays on the bridge PC; ⬇ Save keeps this browser’s record', 'warn');
+ return;
+ }
await commitRunLog(log, {});
}
// SHA-256 hex of a byte buffer (SubtleCrypto; secure-context only — the course
@@ -4007,9 +4020,12 @@
Import error
return;
}
const bridge = Studio.session && Studio.session.bridge;
+ const lostRun = !!(log.summary && log.summary.outcome === 'BRIDGE_LOST');
if (!bridge || !bridge.connected) {
showUploadModal('Run log not committed', { error: true,
- msg: 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' });
+ msg: lostRun
+ ? 'This run lost its bridge. Its log file (' + (log.bridgeFile || 'arena-log-*.jsonl') + ') stays on the bridge PC — a restarted bridge cannot serve it. Commit it from there; ⬇ Save keeps this browser’s record.'
+ : 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' });
return;
}
let exported;
@@ -4022,6 +4038,19 @@
Import error
showUploadModal('Run-log export failed', { error: true, msg: m + '\n\nThe log stays on the bridge machine — nothing lost.' });
return;
}
+ // log_export serves the bridge's current-or-most-recent file. After a bridge loss
+ // and restart that is a DIFFERENT file — never commit it under this run's name.
+ const runFile = log.bridgeFile || null;
+ if ((runFile && exported.name && exported.name !== runFile) || (lostRun && !runFile)) {
+ const m = runFile
+ ? 'The bridge served ' + exported.name + ', not this run’s log ' + runFile +
+ ' (the bridge was restarted or logged something else since).'
+ : 'This run lost its bridge, and the bridge did not name the run’s log file, so the file it serves now cannot be matched to this run.';
+ rawLine('run log NOT committed — ' + m, 'err');
+ showUploadModal('Run log not committed', { error: true,
+ msg: m + '\n\nThis run’s file is on the bridge PC (' + (runFile || 'arena-log-*.jsonl') + ') — commit it from there. ⬇ Save keeps this browser’s record.' });
+ return;
+ }
const stamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const proto = GH.slug(String(log.meta.protocol_filename || 'protocol').replace(/\.ya?ml$/i, ''));
const who = GH.slug(log.meta.experimenter || 'unknown');
@@ -4437,6 +4466,21 @@
Import error
setTimeout(() => b.classList.remove('show'), 8000);
}
Studio.showBanner = showBanner;
+ // Persistent alert for safety events (#runAlert): no auto-hide, not replaced by
+ // routine banners. Cleared by its ✕ or when the next run starts.
+ function showRunAlert(msg) {
+ const a = $('runAlert');
+ if (!a) { showBanner(msg); return; }
+ $('runAlertTxt').textContent = msg;
+ a.classList.add('show');
+ }
+ function hideRunAlert() {
+ const a = $('runAlert');
+ if (a) a.classList.remove('show');
+ }
+ Studio.showRunAlert = showRunAlert;
+ Studio.hideRunAlert = hideRunAlert;
+ if ($('runAlertClose')) $('runAlertClose').addEventListener('click', hideRunAlert);
// ---- Safe mode (default student mode) -------------------------------------
// Safe mode is the DEFAULT: a plain load locks Edit, Console, the session-rig
@@ -5828,6 +5872,28 @@
Import error
(d.lastError ? ' (' + d.lastError + ')' : ''), 'err');
showBanner('⚠ Controller stopped answering — run aborted. Post-mortem probes follow (see the run log).');
});
+ // FicTrac bridge lost (LAB-303 S1): the socket dropped without a Disconnect.
+ // The broker has already stopped the run when it needed the bridge (closed loop,
+ // a FicTrac step, or a recorded run's log) — say so where nobody can miss it.
+ Studio.session.on('bridgelost', (e) => {
+ const info = (e && e.info) || {};
+ const action = e && e.action;
+ const why = info.apply ? 'the closed loop was running' : 'the bridge connection dropped';
+ const restart = ' Restart the bridge program on the rig PC before the next run.';
+ let msg = null;
+ if (action === 'aborted') {
+ msg = '⚠ FicTrac bridge lost mid-run (' + why + ') — the run was STOPPED (outcome BRIDGE_LOST). ' +
+ 'The display was stopped and the closed loop is off. The bridge’s log of this run ends at the loss; ' +
+ '⬇ Save keeps this browser’s record.' + restart;
+ } else if (action === 'continued') {
+ msg = '⚠ FicTrac bridge lost — this test run continues, but nothing after this point is in the bridge log.' + restart;
+ } else if (info.apply) {
+ msg = '⚠ FicTrac bridge lost — the closed loop stopped (the arena no longer follows the ball).' + restart;
+ }
+ rawLine('⚠ bridge lost' + (info.code != null ? ' (code ' + info.code + ')' : '') +
+ (action ? ' — run ' + (action === 'aborted' ? 'stopped' : action) : ''), 'err');
+ if (msg) showRunAlert(msg);
+ });
Studio.session.on('disconnect', (info) => {
if (info && info.involuntary) Studio._linkDropped = true; // consumed by runSteps (fault lifecycle)
if (info && info.involuntary && Studio.activeRunLog && !Studio.activeRunLog.summary) {
@@ -8667,8 +8733,11 @@
Import error
const PP = window.PatPreview;
const BUILTIN_BASE = './patterns/g6_2x10/';
Studio.patternSet = null;
-const webPreviewBySdName = new Map(); // filename → () => Promise
-const repoSharedPreviewKeys = new Set(); // sources currently wired from course-repo patterns/
+// filename → () => Promise, LAYERED so precedence never depends on which
+// listing arrives first: builtin < shared (repo patterns/) < colocated (_patterns/)
+// < uploaded (js/studio-preview-sources.js). Reads see the effective source per filename.
+const webPreviewBySdName = window.StudioPreviewSources.create();
+let sharedPreviewGen = 0; // the newest shared-library listing wins; an older reply is dropped
// In-memory sources first (logical-name tolerant via webBytesForName). The
// fallback for a pattern already on the card from a prior session is a live
// 0x84 fetch of the WHOLE file (the controller has no ranged read), gated by
@@ -9022,7 +9091,7 @@
Import error
const buf = u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength
? u8.buffer
: u8.slice().buffer;
- webPreviewBySdName.set(f.name, () => Promise.resolve(buf));
+ webPreviewBySdName.set('uploaded', f.name, () => Promise.resolve(buf));
});
patPreviewCache = {}; // drop cached renders so the new sources take effect
const sel = $('cPatName');
@@ -9037,7 +9106,7 @@
Import error
m.source = 'manifest';
for (const n of Object.keys(m.patterns)) {
const sd = m.patterns[n].sd_name;
- webPreviewBySdName.set(sd, async () => {
+ webPreviewBySdName.set('builtin', sd, async () => {
const r = await fetch(BUILTIN_BASE + 'patterns/' + sd, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
@@ -10246,7 +10315,7 @@
Import error
// Open loop: the controller steps the pattern itself (no 0x70s) — exposure is
// in the telemetry FRAME rows, judged by telemetry-report.py, not here.
const exercised = !closedLoop || applied >= Math.min(1000, 5 * durS); // ≥ 5 Hz average, or 1000 total
- let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised';
+ let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.bridgeLost ? 'bridge-lost' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised';
logSoak({
phase: 'iteration-end', iteration: s.iteration,
outcome,
@@ -10266,7 +10335,8 @@
Import error
if (!pmo || (pmo.outcome !== 'recovered' && pmo.outcome !== 'self-reset')) { reason = pmo ? 'fault-' + pmo.outcome : 'fault'; break; }
s.resets++;
Studio.rawLog('soak: controller recovered (reset ' + s.resets + '/' + s.maxResets + ') — continuing', 'warn');
- } else if (res.aborted) { reason = 'operator-stop'; break; }
+ } else if (res.bridgeLost) { reason = 'bridge-lost'; break; }
+ else if (res.aborted) { reason = 'operator-stop'; break; }
if (s.stop) { reason = s.stopReason || 'operator'; break; }
// Gap + input freshness re-check (a dead simulator is a harness
// problem, not a controller fault — pause, don't count it).
@@ -10670,6 +10740,20 @@
Import error
const ackLevel = bridge.connected && bridge.waitForLogLevelAck ? await bridge.waitForLogLevelAck(800) : null;
const logLevel = ackLevel || (wantLevel === 'behavior_v2' ? 'behavior_v1' : wantLevel);
Studio._runLogFormat = bridge.connected ? logLevel : null;
+ // The file the bridge opened for THIS run (log_control_ack). A later push checks
+ // that log_export serves it — a bridge restarted after a loss serves another file.
+ if (Studio.activeRunLog) {
+ const runLog = Studio.activeRunLog;
+ runLog.bridgeFile = (bridge.connected && bridge.logFile) || null;
+ // A slow ack (> 800 ms on a loaded rig PC) names the file after this point: take
+ // it then, for THIS run only, so the push check is not silently skipped.
+ if (!runLog.bridgeFile && bridge.connected && typeof bridge.on === 'function') {
+ const off = bridge.on('loglevel', (info) => {
+ if (Studio.activeRunLog !== runLog) { off(); return; }
+ if (info && info.enabled && info.file) { runLog.bridgeFile = info.file; off(); }
+ });
+ }
+ }
const levelNote = !bridge.connected ? ''
: ackLevel === wantLevel ? ' (' + ackLevel + ')'
: ackLevel ? ' ⚠ bridge too old for ' + wantLevel + ' — logging ' + ackLevel + ' (restart `pixi run bridge` from the current checkout)'
@@ -10726,6 +10810,7 @@
Import error
let summary = null;
Studio._userStopRequested = false;
Studio._linkDropped = false;
+ if (Studio.hideRunAlert) Studio.hideRunAlert(); // a new run starts with no stale bridge-lost alert
if (Studio.trialQuality) Studio.trialQuality.reset(); // per-trial stimulus-quality verdicts start fresh
Studio._tqFinal = null; // finalizeTrialQuality() runs once per run (first caller wins, others await it)
// Bind the verdict to THIS run: a late terminal event from a previous (aborted) run must not finalize
@@ -10745,6 +10830,10 @@
Import error
? Studio.resolveRuntimeCondition
: undefined,
fictracPluginNames: new Set(ftNames),
+ // A recorded run's record IS the bridge log (the run is gated on the bridge and
+ // the bridge is logging it), so losing the bridge stops it — not only when a
+ // FicTrac step needs it. With bridge logging off the record is in the browser.
+ bridgeRequired: !!isExperiment && !!(bridge && bridge.connected && bridge.logging),
degPerFrame: Studio.rigDegPerFrame(),
defaultCoupling: Studio._legacyDefaultCoupling == null ? 1 : Studio._legacyDefaultCoupling
});
@@ -11840,23 +11929,20 @@
Import error
// as soon as its filename matches the repo library. Bytes stay lazy: this
// request lists names only; the selected .pat is downloaded on first preview.
// Public repos work signed out, while a token transparently enables private
-// repos. Protocol-colocated sources are registered afterwards and may
-// intentionally override a shared file with the same name.
+// repos. Protocol-colocated sources override a shared file with the same name
+// whenever either listing arrives (the 'colocated' layer outranks 'shared').
async function registerRepoSharedPatternPreviews(repo, token) {
if (!repo) return;
+ const gen = ++sharedPreviewGen;
try {
const res = await GH.run(fetch, GH.reqGetContents(repo.owner, repo.name, 'patterns', null, token));
+ if (gen !== sharedPreviewGen) return; // a newer listing (repo change / sign-in) was requested
if (!res.ok || !Array.isArray(res.data)) return;
- for (const key of repoSharedPreviewKeys) webPreviewBySdName.delete(key);
- repoSharedPreviewKeys.clear();
- let n = 0;
- res.data
+ const entries = res.data
.filter((f) => f && f.type === 'file' && /\.pat$/i.test(f.name))
- .forEach((f) => {
- n++;
- repoSharedPreviewKeys.add(f.name);
- webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path));
- });
+ .map((f) => [f.name, () => repoFileBytes(repo, f.path)]);
+ webPreviewBySdName.replaceLayer('shared', entries);
+ const n = entries.length;
if (n) {
patPreviewCache = {};
const sel = $('cPatName');
@@ -11890,7 +11976,7 @@
Import error
.forEach((f) => {
n++;
names.add(sdLogicalName(f.name));
- webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path));
+ webPreviewBySdName.set('colocated', f.name, () => repoFileBytes(repo, f.path));
});
if (n) {
// The colocated _patterns/ set IS this protocol's web pattern set
@@ -12155,8 +12241,12 @@
Import error
await populateSessionRig();
wirePatPicker();
loadBuiltinPatternSet();
- await refreshRepoSharedPatternPreviews(); // shared patterns/ previews do not depend on a protocol
- rewireRepoPatternPreviews(); // restore last-opened course protocol's colocated thumbs
+ // Best-effort GitHub listings — NEVER awaited here: a stalled api.github.com request
+ // used to hold the `?p=` load below until the page was refreshed (LAB-303 S2, ww18
+ // 2026-10-15). Order no longer matters: preview sources are layered, so a late shared
+ // listing cannot override the colocated sources rewired here.
+ refreshRepoSharedPatternPreviews().catch(() => {}); // shared patterns/ previews
+ rewireRepoPatternPreviews().catch(() => {}); // restore last-opened course protocol's colocated thumbs
let allowedKeys = [];
let protoIndex = [];
try {
@@ -18049,6 +18139,7 @@
Import error
'#btnAllOff': 'Turn every LED off.',
'#btnLogSave': 'Download the run log as files (.json and .txt).',
'#btnLogPush': 'Send the last run’s log to the course repo — use this if the automatic push was skipped or failed.',
+ '#runAlert': 'Something stopped your run or the closed loop (for example, the FicTrac bridge closed). The message says what happened and what to do; the run log keeps it too. ✕ hides it.',
'#btnLogClear': 'Clear the log display (saved logs are not deleted).',
'#btnLogCopy': 'Copy the log text to the clipboard.',
'.seqlist': 'The experiment’s sequence. Each row is a condition — ▶ on a row tests just that one on the arena.',
diff --git a/js/arena-runner-g6.js b/js/arena-runner-g6.js
index 94fa7f61..ca4d8f5b 100644
--- a/js/arena-runner-g6.js
+++ b/js/arena-runner-g6.js
@@ -714,6 +714,47 @@ var ArenaRunnerG6 = (function () {
return out;
}
+ // JSON-safe copy of a bridge 'lost' info for the run record (the summary and the
+ // bridge-lost event both serialize into the run log).
+ function lostDetail(info) {
+ const out = { kind: 'bridge_lost' };
+ if (!info || typeof info !== 'object') return out;
+ for (const k of ['code', 'reason', 'wasClean', 'apply', 'logFile', 'url', 't']) {
+ const v = info[k];
+ if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') out[k] = v;
+ }
+ return out;
+ }
+
+ /**
+ * Does any condition this sequence runs carry a plugin command that acts through
+ * the bridge (FicTrac today)? Losing the bridge mid-run makes every later such
+ * step run blind, so ArenaRunner.onBridgeLost stops a run for which this is true.
+ * Scans the authored command lists (runtime controls change parameters, never
+ * plugin names), each condition once.
+ * @param {Array} steps
+ * @param {Map} conditionsByName
+ * @param {Set|null} bridgePluginNames plugin instance names routed via the bridge
+ * @returns {boolean}
+ */
+ function sequenceUsesBridge(steps, conditionsByName, bridgePluginNames) {
+ if (!bridgePluginNames || typeof bridgePluginNames.has !== 'function') return false;
+ if (!bridgePluginNames.size) return false;
+ const seen = new Set();
+ for (const step of Array.isArray(steps) ? steps : []) {
+ const name = step && step.conditionName;
+ if (seen.has(name)) continue;
+ seen.add(name);
+ const cond = conditionsByName && conditionsByName.get(name);
+ if (!cond || !Array.isArray(cond.commands)) continue;
+ for (const cmd of cond.commands) {
+ if (cmd && cmd.type === 'plugin' && bridgePluginNames.has(cmd.plugin_name))
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Translate ONE v3 command into a wire-neutral instruction descriptor (the
* "command IR"). PURE and wire-free so it is trivially unit-testable; the
@@ -1104,6 +1145,9 @@ var ArenaRunnerG6 = (function () {
this._emit = null; // current run's status emit (for async LED events)
this._faultReason = null; // set by fault() / a timed-out wire command
this._faultDetail = null;
+ this._bridgeLost = null; // set by bridgeLost(): the bridge's 'lost' info
+ this._bridgeRequired = false; // this run's record lives on the bridge (recorded runs)
+ this._bridgeDependent = false; // this run has steps that act through the bridge
}
get active() {
@@ -1140,6 +1184,9 @@ var ArenaRunnerG6 = (function () {
this._active = true;
this._conditionName = a.conditionName || null;
+ this._bridgeLost = null;
+ this._bridgeRequired = false; // a single trial depends on the bridge only via apply
+ this._bridgeDependent = false;
let frame;
try {
@@ -1247,6 +1294,72 @@ var ArenaRunnerG6 = (function () {
return this._faultReason;
}
+ /**
+ * The bridge WebSocket dropped (FicTracBridgeClient 'lost' — ArenaSession calls
+ * this). Decides what the active run does:
+ * - 'aborted' the run needs the bridge — a closed loop was applying, a later
+ * step acts through the bridge (a FicTrac plugin), or the run's
+ * record lives on it (`bridgeRequired`, recorded runs) → bridgeLost()
+ * - 'continued' a test run that needs nothing from the bridge: it keeps going,
+ * with a 'bridge-lost' event so the transcript says logging stopped
+ * - 'idle' no run is active
+ * @param {object} [info] the client's 'lost' info ({code, apply, logFile, …})
+ * @returns {'aborted'|'continued'|'idle'}
+ */
+ onBridgeLost(info) {
+ if (!this._active) return 'idle';
+ const apply = !!(info && info.apply);
+ if (apply || this._bridgeDependent || this._bridgeRequired) {
+ this.bridgeLost(info);
+ return 'aborted';
+ }
+ if (this._emit) {
+ try {
+ this._emit({
+ phase: 'bridge-lost',
+ reason: 'bridge_lost',
+ action: 'continued',
+ detail: lostDetail(info)
+ });
+ } catch (_) {
+ /* a status sink must not break the caller */
+ }
+ }
+ return 'continued';
+ }
+
+ /**
+ * End the active run because the bridge is gone. Records the loss (the terminal
+ * summary carries `bridgeLost`, which the run log turns into the BRIDGE_LOST
+ * outcome — never ABORTED_BY_USER, never a controller fault), emits a
+ * `bridge-lost` status event, then takes the ordinary stop() path: the serial
+ * link is healthy, so the display gets a real STOP, the LED goes off and the
+ * closed loop is disarmed, exactly as for an operator STOP.
+ * @param {object} [info] the client's 'lost' info
+ * @returns {Promise} the stop() result (never rejects)
+ */
+ bridgeLost(info) {
+ this._bridgeLost = lostDetail(info);
+ if (this._emit) {
+ try {
+ this._emit({
+ phase: 'bridge-lost',
+ reason: 'bridge_lost',
+ action: 'stopped',
+ detail: this._bridgeLost
+ });
+ } catch (_) {
+ /* a status sink must not break the stop */
+ }
+ }
+ return this.stop().catch(() => null);
+ }
+
+ /** The bridge-loss record of the current/last run, or null. */
+ get bridgeLostInfo() {
+ return this._bridgeLost;
+ }
+
/** Clear run-state without sending STOP (used on disconnect/error). Also
* aborts a running sequence and unblocks its current wait. */
_clear() {
@@ -1533,6 +1646,9 @@ var ArenaRunnerG6 = (function () {
* a non-mutating condition copy around those commands. The hook
* runs after step-start and before any command translation/send.
* @param {Set|Array} [a.fictracPluginNames] plugin names whose class is FicTracPlugin
+ * @param {boolean} [a.bridgeRequired] the run's record lives on the bridge (a
+ * recorded run): losing the bridge stops it even
+ * when no step acts through the bridge
* @param {function} [a.onProgress] progress/event callback (see phases below)
* @param {function} [a.timing] async (durationSec, sleep) ⇒ trial end
* (defaults to host-side; firmware #4 swap)
@@ -1586,6 +1702,10 @@ var ArenaRunnerG6 = (function () {
this._frameCountCache = new Map();
this._faultReason = null;
this._faultDetail = null;
+ this._bridgeLost = null;
+ this._bridgeRequired = !!a.bridgeRequired;
+ this._bridgeDependent =
+ !!this._bridge && sequenceUsesBridge(steps, conditionsByName, fictracPluginNames);
this._oldBridgeStartWarned = false; // one "bridge ignores the start frame" warning per run
// A stale closed-loop apply (left on by Console use or an aborted run)
// would push FicTrac frames into the opening Mode-2 step — the firmware
@@ -1816,6 +1936,9 @@ var ArenaRunnerG6 = (function () {
// the controller stopped answering (fault() / a timed-out command).
summary.fault = this._faultReason || null;
if (this._faultDetail) summary.faultDetail = this._faultDetail;
+ // The bridge dropped and the run stopped for it (BRIDGE_LOST outcome).
+ // A continued test run records only its bridge-lost event, not this.
+ if (this._bridgeLost) summary.bridgeLost = this._bridgeLost;
// The terminal event is emitted from `finally`, AFTER the best-effort
// STOP, so the serialized summary (run log, auto-commit trigger)
// carries `stopAcked` — review finding: it used to be present only
@@ -2134,6 +2257,7 @@ var ArenaRunnerG6 = (function () {
conditionCommandCount,
flattenStructure,
translateCommand,
+ sequenceUsesBridge, // does a run have steps that act through the bridge? (bridge-loss policy)
hostSideTrialEnd,
RUNNABLE_CONTROLLER_COMMANDS,
LED_OFF_MV, // BuckPuck "LED dark" analog level (mV) — the scope's on/off threshold
diff --git a/js/arena-session.js b/js/arena-session.js
index eb5f8e3a..eeac82c4 100644
--- a/js/arena-session.js
+++ b/js/arena-session.js
@@ -30,6 +30,13 @@
* 'disconnect' ({ involuntary:boolean }) — port closed (cable-pull = true, user Disconnect = false)
* 'state' () — connection/run state changed; re-evaluate UI gating
* 'runstatus' (s:object) — runner status/progress phase (single-trial AND sequence)
+ * 'fault' (f:object) — closed-loop controller fault (fw #50); the run is aborting
+ * 'bridgelost' ({info, action, running}) — the FicTrac bridge socket dropped without being
+ * asked to. action: 'aborted' (the run is stopping,
+ * outcome BRIDGE_LOST) | 'continued' (a test run that
+ * needs nothing from the bridge) | 'idle' (no run).
+ * info = the client's 'lost' info (info.apply: a
+ * closed loop was running, e.g. the Console's)
*
* SEND / STOP SEMANTICS: ArenaLink.send is single-flight + echo-correlated +
* FIFO-queued (arena-link.js). STOP therefore QUEUES behind any in-flight send
@@ -49,7 +56,7 @@
? () => performance.now()
: () => Date.now();
- const EVENTS = ['log', 'error', 'disconnect', 'state', 'runstatus', 'fault'];
+ const EVENTS = ['log', 'error', 'disconnect', 'state', 'runstatus', 'fault', 'bridgelost'];
class ArenaSession {
/**
@@ -160,9 +167,29 @@
this._emit('fault', f);
this._emit('state');
});
+ // Bridge lost mid-run (LAB-303 S1, ww18 2026-10-15): the socket dropped
+ // without disconnect() asking. The client has already forced apply off
+ // and failed every pending request; the RUNNER decides whether the run
+ // stops (it needs the bridge) or continues, and the page banners it.
+ this._bridge.on('lost', (info) => this._onBridgeLost(info));
}
}
+ _onBridgeLost(info) {
+ const running = !!this._runner.active;
+ let action = running ? 'continued' : 'idle';
+ if (typeof this._runner.onBridgeLost === 'function') {
+ action = this._runner.onBridgeLost(info);
+ } else if (running && !this.outputInhibited) {
+ // Stale-cache runner without onBridgeLost: stop the run the ordinary way
+ // (the serial link is fine) rather than let it run on blind.
+ this._runner.stop().catch(() => {});
+ action = 'aborted';
+ }
+ this._emit('bridgelost', { info: info || null, action, running });
+ this._emit('state');
+ }
+
/** Web Serial available? (Chromium-desktop only.) Gate every connect UI on this. */
static isSupported() {
const LinkClass = typeof window !== 'undefined' ? window.ArenaLink : undefined;
@@ -477,6 +504,8 @@
* @param {Function} [a.resolveCondition] optional async trial-boundary resolver;
* forwarded verbatim to ArenaRunner.runSequence
* @param {Function} [a.onProgress] per-call progress sink (also broadcast as 'runstatus')
+ * @param {boolean} [a.bridgeRequired] the run's record lives on the bridge (recorded runs):
+ * a bridge loss stops it even with no FicTrac step (see ArenaRunner.onBridgeLost)
* @param {Function} [a.timing] host-timing fn (default runner's hostSideTrialEnd — FW#4 swap point)
* @param {Function} [a.sleep] sleep(ms) (default runner's abort-aware sleep; tests inject instant)
* @returns {Promise<{completed,aborted,steps,errors,skipped}>}
@@ -492,6 +521,7 @@
resolvePatternFrames: args.resolvePatternFrames,
resolveCondition: args.resolveCondition,
fictracPluginNames: args.fictracPluginNames,
+ bridgeRequired: args.bridgeRequired, // recorded runs: losing the bridge stops the run
timing: args.timing,
sleep: args.sleep,
onProgress: (s) => this._onRunStatus(s, args.onProgress)
@@ -542,7 +572,8 @@
'on',
'ledPercent',
'mv', // v0.86 graded zones: the commanded AO voltage per level step
- 'ledActivation'
+ 'ledActivation',
+ 'action' // bridge-lost: 'stopped' | 'continued'
];
for (const k of keys) if (s[k] !== undefined) out[k] = s[k];
if (s.params && typeof s.params === 'object') out.params = s.params;
@@ -575,6 +606,7 @@
'skipped',
'fault',
'faultDetail',
+ 'bridgeLost',
'stopAcked'
]) {
if (s.summary[k] !== undefined) sm[k] = s.summary[k];
diff --git a/js/fictrac-bridge-client.js b/js/fictrac-bridge-client.js
index 6a4cada2..d3230f42 100644
--- a/js/fictrac-bridge-client.js
+++ b/js/fictrac-bridge-client.js
@@ -58,6 +58,16 @@
* ok=false ⇒ the bridge cannot write the
* requested level (too old) and `level`
* is what it logs instead.
+ * 'fault' (fault:object) — the closed-loop apply loop latched a
+ * controller fault (fw #50)
+ * 'lost' (info:object) — an OPEN bridge socket closed without
+ * disconnect() asking for it (bridge killed
+ * or crashed). Fail closed BEFORE this fires:
+ * apply is already OFF and every pending
+ * request already settled. info = {url, code,
+ * reason, wasClean, apply (was it on), logging,
+ * logFile, recv, t}. ArenaSession routes it to
+ * the runner (ArenaRunner.onBridgeLost).
*/
(function (global) {
'use strict';
@@ -73,7 +83,8 @@
'bias',
'log',
'loglevel',
- 'fault'
+ 'fault',
+ 'lost'
];
// Log levels this client knows how to request, most-preferred first. The
// bridge advertises ITS list in hello_ack; a level missing there is one the
@@ -169,7 +180,9 @@
this._stale = 0;
this._bridgeInfo = null; // from hello_ack: {version, levels, level} (null = old bridge / not yet)
this._ackedLevel = null; // from log_control_ack while logging is enabled
+ this._logFile = null; // basename of the log file the bridge opened (log_control_ack)
this._ackWaiters = []; // waitForLogLevelAck() resolvers
+ this._lastLoss = null; // the most recent 'lost' info (null = never lost)
}
// ---- events ----------------------------------------------------------
@@ -294,12 +307,17 @@
this._emit('log', 'bridge: bad URL — ' + (e && (e.message || e)), 'err');
return;
}
+ // Superseding a socket that is still closing: its waiters can never be answered
+ // by the new one, and its late onclose is ignored as stale (below).
+ if (this._ws) this._failPending('bridge reconnecting');
this._ws = ws;
this._recv = 0;
this._applied = 0;
this._pending = null;
+ let opened = false; // a socket that never opened was a failed connect, not a loss
this._emit('status', 'connecting…', 'dim');
ws.onopen = () => {
+ opened = true;
this._emit('status', 'connected', 'accent');
this._emit('log', 'bridge: connected to ' + this._url, 'info');
this._send({ type: 'hello', client: 'webDisplayTools', v: 1 });
@@ -326,31 +344,96 @@
else if (msg && msg.type === 'log_control_ack') this._handleLogControlAck(msg);
};
ws.onerror = () => this._emit('status', 'error', 'err');
- ws.onclose = () => {
+ ws.onclose = (ev) => {
+ // A superseded socket closing after a reconnect must not tear down the
+ // new one's state.
+ if (this._ws !== ws && this._ws !== null) return;
+ // disconnect() clears _ws BEFORE closing, so a close that finds this
+ // socket still current was not asked for: the bridge went away.
+ const lost = opened && this._ws === ws;
+ const wasApply = this._apply;
this._stopRateTimer();
this._ws = null;
+ if (lost) {
+ // Fail closed first: nothing may keep "driving" from a dead feed.
+ this._pending = null;
+ if (this._apply) {
+ this._apply = false;
+ this._emit('apply', false);
+ }
+ }
+ const logFile = this._logFile;
this._bridgeInfo = null;
this._ackedLevel = null;
- this._settleAckWaiters(null);
- this._settleExport('reject', new Error('bridge disconnected during log export'));
- this._emit('status', 'disconnected', 'dim');
+ this._logFile = null;
+ this._failPending(lost ? 'bridge lost' : 'bridge disconnected');
+ this._emit('status', lost ? 'disconnected (bridge lost)' : 'disconnected', 'dim');
this._emit('stats', this.stats);
+ if (lost) {
+ this._lastLoss = {
+ url: this._url || null,
+ code: ev && Number.isFinite(ev.code) ? ev.code : null,
+ reason: (ev && ev.reason) || '',
+ wasClean: !!(ev && ev.wasClean),
+ apply: wasApply,
+ logging: this._logging,
+ logFile,
+ recv: this._recv,
+ t: this._now()
+ };
+ this._emit(
+ 'log',
+ 'bridge lost (' +
+ (this._lastLoss.code != null
+ ? 'code ' + this._lastLoss.code
+ : 'socket closed') +
+ (wasApply ? ', closed loop was running' : '') +
+ ')',
+ 'err'
+ );
+ this._emit('lost', Object.assign({}, this._lastLoss));
+ }
};
}
- /** Close the bridge WebSocket (idempotent). */
+ /** Close the bridge WebSocket (idempotent). A close asked for here is never 'lost'. */
disconnect() {
- if (this._ws) {
+ const ws = this._ws;
+ this._ws = null; // first: onclose may run synchronously, and must see a requested close
+ // A requested close kills every request still waiting on this socket (export,
+ // level ack): settle them NOW. Its onclose may arrive late or, after a reconnect,
+ // be ignored as stale — so it must not be the only place they settle.
+ if (ws) this._failPending('bridge disconnected');
+ if (ws) {
try {
- this._ws.close();
+ ws.close();
} catch (_) {
/* closing socket can throw — ignore */
}
}
- this._ws = null;
this._stopRateTimer();
}
+ /** The most recent unexpected loss ('lost' info), or null if the bridge never dropped. */
+ get lastLoss() {
+ return this._lastLoss ? Object.assign({}, this._lastLoss) : null;
+ }
+
+ // Settle every request still waiting on the bridge, NOW — a dead socket will
+ // never answer, and a caller must not sit out its timeout (log export 15 s).
+ // The one place a new request/reply pair registers its fail-fast path.
+ _failPending(reason) {
+ this._settleAckWaiters(null);
+ this._settleExport(
+ 'reject',
+ new Error(
+ reason === 'bridge lost'
+ ? 'bridge lost — the connection dropped during log export'
+ : 'bridge disconnected during log export'
+ )
+ );
+ }
+
// ---- config / logging ------------------------------------------------
/**
* Merge config (any subset of fictrac_port/gain/offset/frames/bias) and push if
@@ -558,6 +641,15 @@
get ackedLogLevel() {
return this._ackedLevel;
}
+ /**
+ * Basename of the log file the bridge opened for the current logging session
+ * (log_control_ack `file`), or null: not acked yet, logging off, disconnected,
+ * or a bridge too old to name it. A run stashes it so a later push can check
+ * that log_export serves THIS run's file, not one a restarted bridge opened.
+ */
+ get logFile() {
+ return this._logFile;
+ }
/** hello_ack facts {version, levels, level} — null until a ≥3.0 bridge answers. */
get bridgeInfo() {
return this._bridgeInfo;
@@ -632,6 +724,7 @@
const level = msg.level || null;
const enabled = !!msg.enabled;
this._ackedLevel = enabled ? level : null;
+ this._logFile = enabled && typeof msg.file === 'string' ? msg.file : null;
const ok = !enabled || level === requested;
if (!ok) {
this._emit(
@@ -660,6 +753,7 @@
setLogging(on) {
this._logging = !!on;
this._ackedLevel = null; // pending until the bridge acks this request
+ this._logFile = null; // the ack names the file this request opens
const msg = { type: 'log_control', enabled: this._logging };
if (this._logging) msg.level = this._config.logLevel;
this._send(msg);
diff --git a/js/run-log.js b/js/run-log.js
index f85f3916..f5a3ddf6 100644
--- a/js/run-log.js
+++ b/js/run-log.js
@@ -50,6 +50,7 @@
if (override) return override;
if (!summary) return 'UNKNOWN';
if (summary.fault) return 'CONTROLLER_FAULT'; // controller stopped answering (fw #50)
+ if (summary.bridgeLost) return 'BRIDGE_LOST'; // the bridge dropped; the run stopped for it
if (summary.aborted) return 'ABORTED_BY_USER';
if (summary.errors > 0) return 'ERRORED';
if (summary.completed) return 'COMPLETED';
@@ -155,6 +156,18 @@
(ev.reason || 'controller_unresponsive') +
(ev.detail && ev.detail.lastError ? ' — ' + ev.detail.lastError : '')
);
+ case 'bridge-lost': {
+ const d = ev.detail || {};
+ return (
+ stamp +
+ '⚠ BRIDGE LOST' +
+ (d.code != null ? ' (code ' + d.code + ')' : '') +
+ (d.apply ? ' — closed loop was running' : '') +
+ (ev.action === 'continued'
+ ? ' — test run continues, no longer logged by the bridge'
+ : ' — run stopped')
+ );
+ }
case 'step-done':
return stamp + ' ' + where + 'done';
case 'sequence-complete':
diff --git a/js/studio-preview-sources.js b/js/studio-preview-sources.js
new file mode 100644
index 00000000..a22ad5ac
--- /dev/null
+++ b/js/studio-preview-sources.js
@@ -0,0 +1,100 @@
+/**
+ * studio-preview-sources.js — where a pattern thumbnail's bytes come from (Arena Studio).
+ *
+ * The Studio previews a pattern by FILENAME from several byte sources that arrive at
+ * different times: the site's built-in library (a same-origin manifest), the course
+ * repo's shared `patterns/` folder (a GitHub listing), an opened protocol's colocated
+ * `_patterns/` folder (another GitHub listing) and bytes uploaded to the SD card
+ * this session. When two sources share a filename the more specific one must win:
+ *
+ * builtin < shared < colocated < uploaded (low → high precedence)
+ *
+ * This used to be "last write wins" on one Map, which only came out right because the
+ * Studio AWAITED the shared GitHub listing before loading a `?p=` protocol. That await
+ * is what left a `?p=` link empty until a refresh when api.github.com stalled (LAB-303
+ * S2, ww18 2026-10-15). Layers make the precedence independent of arrival order, so no
+ * best-effort listing has to gate the page.
+ *
+ * The registry reads like a Map of the EFFECTIVE sources: get / has / iteration
+ * (`for (const [name, fn] of reg)`) see one entry per filename, from the highest layer
+ * that has it. Writes name their layer: set(layer, name, fn), replaceLayer(layer, entries).
+ *
+ * LOADING: classic