Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`
Expand Down
149 changes: 120 additions & 29 deletions arena_studio.html

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions js/arena-runner-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 34 additions & 2 deletions js/arena-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}>}
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -575,6 +606,7 @@
'skipped',
'fault',
'faultDetail',
'bridgeLost',
'stopAcked'
]) {
if (s.summary[k] !== undefined) sm[k] = s.summary[k];
Expand Down
Loading
Loading