From eeb89a301a6992ece467af3dbb6053d4bc03315b Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:51:46 +0200 Subject: [PATCH 1/2] fix(hardware): let a flashed board actually run, and stop refusing big twin results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects stood between the twin-desk differential and its first live measurement. Both were found by running it against a real NUCLEO-H563ZI. `probe-rs download` leaves the core HALTED. Nothing reset it, so the board held the exact bytes we had just written and executed none of them. Every observation that followed read a silent port and recorded "serial marker was not observed" — a false negative indistinguishable from firmware that does not work. Measured: serial-capture read 0 bytes after download and matched immediately after an explicit reset. Fixed in lib/probe.sh and lib/probe-flash.ps1 together, because leaving Windows behind would keep the bug where it is hardest to see. Reading the simulator's result.json and PERSISTING it shared one 128 KB budget. A 10M-step H563 run publishes 280 KB, of which 106 KB is the `inspect` register dump — a blob no behavior decision reads. The run was refused at READ time, so the twin lane failed and there was no bundle to diff against a board. The read budget is now separate and generous (the simulator is our own tool writing to a path we identity-check); what we KEEP is still bounded. The persisted projection drops members by SIZE and names every one in `evidence_omitted`. An allowlist was the first attempt and was wrong twice: it discarded `diagnostics`, whose redaction the suite proves, and it would discard every field the simulator adds next. A bundle that quietly loses part of its source is the thing this format exists to prevent. End to end on real silicon: hardware run PASS (receipt dbfad84d), twin-only run PASS (receipt 16a22498), hardware diff verdict "agree", exit 0 — both sides authenticity "verified", twin model_observed, desk hardware_observed, one paired behavior, same artifact digest 3beaf379. --- lib/hardware/adapters.mjs | 30 ++++++++++++++++++++++++++++-- lib/probe-flash.ps1 | 4 ++++ lib/probe.sh | 7 +++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/lib/hardware/adapters.mjs b/lib/hardware/adapters.mjs index b696155..6e743c6 100644 --- a/lib/hardware/adapters.mjs +++ b/lib/hardware/adapters.mjs @@ -13,6 +13,32 @@ import { resolveLaunch, runLaunch } from './process.mjs'; const SAFE_ENVIRONMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const SHA256 = /^[a-f0-9]{64}$/i; const MAX_SIMULATOR_RESULT_BYTES = 128 * 1024; +// Reading the simulator's result.json and PERSISTING it are different budgets, +// and collapsing them into one number made a perfectly good run unusable: a +// 10M-step NUCLEO-H563ZI run publishes 280 KB, of which 106 KB is the `inspect` +// register dump — a blob no behavior decision reads. The run was refused at +// READ time, so the twin lane failed and there was no bundle to diff against a +// board. The sim is our own tool writing to a path we already identity-check, +// so the read budget is generous; what we keep is not. +const MAX_SIMULATOR_RESULT_READ_BYTES = 4 * 1024 * 1024; +// What gets DROPPED is chosen by size, not by an allowlist. An allowlist looked +// tidier and was wrong: it silently discarded `diagnostics`, whose redaction the +// suite proves, and it would discard every field the simulator adds next. Only +// genuinely bulky members are removed, and each one is NAMED in +// `evidence_omitted` — a bundle that quietly loses part of its source is the +// thing this format exists to prevent. +const MAX_SIMULATOR_FIELD_BYTES = 16 * 1024; + +/** Drop only oversized members; name every one that was dropped. */ +function projectSimulatorEvidence(observed) { + const kept = {}; + const omitted = []; + for (const [key, value] of Object.entries(observed)) { + if (Buffer.byteLength(JSON.stringify(value) ?? 'null') > MAX_SIMULATOR_FIELD_BYTES) omitted.push(key); + else kept[key] = value; + } + return omitted.length === 0 ? kept : { ...kept, evidence_omitted: omitted.sort() }; +} const SIMULATOR_EVIDENCE_REF = 'twin/simulator-output.json'; const MAX_PHYSICAL_EVIDENCE_BYTES = 64 * 1024; const PHYSICAL_LOGIC_DRIVERS = new Set(['saleae-logic16', 'fx2lafw', 'dreamsourcelab-dslogic', 'kingst-la2016']); @@ -339,7 +365,7 @@ async function verifyEvidenceBundleSnapshot(expected) { async function readSimulatorResult(file, temporaryRoot, snapshotFile) { const snapshot = await snapshotFile(file, temporaryRoot, { captureBytes: true, - maximumBytes: MAX_SIMULATOR_RESULT_BYTES, + maximumBytes: MAX_SIMULATOR_RESULT_READ_BYTES, label: 'simulator result.json', }); if (!snapshot) throw new Error('simulator did not publish result.json'); @@ -366,7 +392,7 @@ async function persistSimulatorEvidence(bundle, observed, redactValues, snapshot } catch (error) { if (error?.code !== 'ENOENT') throw error; } - const serialized = `${JSON.stringify(redactDeep(observed, redactValues), null, 2)}\n`; + const serialized = `${JSON.stringify(redactDeep(projectSimulatorEvidence(observed), redactValues), null, 2)}\n`; if (Buffer.byteLength(serialized) > MAX_SIMULATOR_RESULT_BYTES) throw new Error('redacted simulator evidence exceeds the capture size limit'); const temporary = path.join(directory, `.simulator-output.tmp-${process.pid}-${randomUUID()}`); let handle; diff --git a/lib/probe-flash.ps1 b/lib/probe-flash.ps1 index 9eba64c..5894bdd 100644 --- a/lib/probe-flash.ps1 +++ b/lib/probe-flash.ps1 @@ -20,6 +20,10 @@ $workspacePath=(Resolve-Path -LiteralPath $Workspace).Path if($Provider -eq 'probe-rs'){ if([IO.Path]::GetExtension($artifactPath) -ine '.elf'){Fail 'probe-rs requires ELF'} & $ProbeRs download --chip $Chip --probe $Probe --binary-format elf $artifactPath + # `download` leaves the core HALTED — same defect as lib/probe.sh. Without the + # reset the board holds the exact bytes and runs none of them, so every + # observation reads a silent port and reports a false negative. + if ($LASTEXITCODE -eq 0) { & $ProbeRs reset --chip $Chip --probe $Probe } if($LASTEXITCODE -ne 0){exit $LASTEXITCODE} } else { if([IO.Path]::GetExtension($artifactPath) -ine '.bin'){Fail 'PlatformIO requires BIN'} diff --git a/lib/probe.sh b/lib/probe.sh index 914a7ce..019edc9 100644 --- a/lib/probe.sh +++ b/lib/probe.sh @@ -356,6 +356,13 @@ if(matches.length!==1) process.exit(1);' "$port" "$probe_sel" || { echo "labwire local prs prs="$(labwired_resolve_probe_rs)" || { echo "labwired probe flash: probe-rs not found" >&2; return 2; } "$prs" download --chip "$chip" --probe "$probe_sel" --binary-format elf "$elf" || return $? + # `download` leaves the core HALTED. Without this the board holds the exact + # bytes we just wrote and executes none of them, so every observation that + # follows reads a silent port and reports "marker was not observed" — a + # false negative that looks exactly like firmware that does not work. + # Verified on a NUCLEO-H563ZI 2026-08-20: serial-capture read 0 bytes after + # download and matched immediately after an explicit reset. + "$prs" reset --chip "$chip" --probe "$probe_sel" || return $? ;; *) echo "labwired probe flash: unsupported explicit provider $provider" >&2; return 2 ;; esac From 3a124b9b2c1737f85c8118a72899413703b28883 Mon Sep 17 00:00:00 2001 From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:45:21 +0200 Subject: [PATCH 2/2] fix(hardware): open the port before the target boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A banner printed once at startup could never be observed. The flash stage reset the board, the board printed, and only afterwards did serial-capture open the port — so `LABWIRED_OK` went to nobody and came back as "marker was not observed", which reads exactly like firmware that does not work. A physical serial observation now starts the target itself, from inside the capture, after the port is open and flushed. This is not an arbitrary command hook: serial-capture takes `--reset-chip` and `--reset-probe` and the only thing it will run is `probe-rs reset` for that explicit chip and probe, resolved in the shell rather than passed in. A failed start is reported as a failed start. `started_target` is false and `start_error` carries the reason, and the status becomes `blocked` rather than `failed` — otherwise a launch we broke is indistinguishable from a board that had nothing to say, which is the same false negative one level up. The flags are attached only when the profile has a flash stage and a probe: if nothing of ours put the firmware there, nothing of ours resets it. Both cases are asserted, so their absence is a property and not an accident. Measured on a NUCLEO-H563ZI before it was unplugged: capturing `LABWIRED_OK`, previously unobservable, returned matched=true, 1219 bytes, started_target=true, with `H563 blink start / LABWIRED_OK` in the excerpt. --- lib/hardware/adapters.mjs | 11 +++++- lib/serial-capture.sh | 55 ++++++++++++++++++++++++++++ tests/hardware-observations.test.mjs | 15 +++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/lib/hardware/adapters.mjs b/lib/hardware/adapters.mjs index 6e743c6..d0d6283 100644 --- a/lib/hardware/adapters.mjs +++ b/lib/hardware/adapters.mjs @@ -1088,8 +1088,17 @@ export function createTrustedAdapters(dependencies = {}) { const capability = capabilities.get(prepared); if (!capability) throw new TypeError('adapter-owned preflight capability is required'); if (capability.fingerprint !== physicalFingerprint(profile, { observation })) throw new TypeError('observation inputs changed after preflight'); + // A physical serial observation starts the target ITSELF, after the port + // is open. The flash stage resets too, but that reset happens before + // this process exists — so a banner printed once at boot was emitted to + // nobody and read back as "marker was not observed", which is + // indistinguishable from firmware that does not work. + const bootsTarget = provider === 'serial' + && Boolean(profile.flash) + && Boolean(profile.target.probeSerial); const args = provider === 'serial' - ? ['serial-capture', profile.target.serialPort, '115200', observation.contains, String(observation.timeoutSeconds ?? 8)] + ? ['serial-capture', profile.target.serialPort, '115200', observation.contains, String(observation.timeoutSeconds ?? 8), + ...(bootsTarget ? ['--reset-chip', profile.target.chip, '--reset-probe', profile.target.probeSerial] : [])] : ['probe', 'rtt-capture', '--chip', profile.target.chip, '--probe', profile.target.probeSerial, '--elf', profile.build.artifact, '--marker', observation.contains, '--timeout', String(observation.timeoutSeconds ?? 8)]; return launch(agentPath, args, profile.build.workspace, safePhysicalEnvironment(environment)); }, diff --git a/lib/serial-capture.sh b/lib/serial-capture.sh index c74c4a0..6cb85f4 100755 --- a/lib/serial-capture.sh +++ b/lib/serial-capture.sh @@ -19,6 +19,19 @@ labwired_serial_capture() { local baud="${2:-}" local marker="${3:-}" local timeout="${4:-}" + shift 4 2>/dev/null || true + # Optional: boot the target AFTER the port is open. A banner printed once at + # startup is invisible otherwise — the flash stage resets, the board prints, + # and only then does this capture open the port. Not an arbitrary command: + # the only thing we will run is `probe-rs reset` for an explicit chip+probe. + local reset_chip="" reset_probe="" + while [[ $# -gt 0 ]]; do + case "$1" in + --reset-chip) reset_chip="${2:-}"; shift 2 ;; + --reset-probe) reset_probe="${2:-}"; shift 2 ;; + *) echo "serial-capture: unknown option $1" >&2; return 2 ;; + esac + done if [[ -z "$port" || -z "$baud" || -z "$marker" || -z "$timeout" ]]; then echo "usage: labwired_serial_capture " >&2 @@ -36,6 +49,22 @@ labwired_serial_capture() { fi # Export for python child (avoid fragile shell quoting of marker) + if [[ -n "$reset_chip" || -n "$reset_probe" ]]; then + [[ -n "$reset_chip" && -n "$reset_probe" ]] \ + || { echo "serial-capture: --reset-chip and --reset-probe are required together" >&2; return 2; } + local sc_prs="" + if declare -F labwired_resolve_probe_rs >/dev/null 2>&1; then + sc_prs="$(labwired_resolve_probe_rs 2>/dev/null || true)" + fi + [[ -n "$sc_prs" ]] || sc_prs="$(command -v probe-rs 2>/dev/null || true)" + [[ -n "$sc_prs" ]] || { echo "serial-capture: probe-rs not found for --reset-chip" >&2; return 2; } + export LABWIRED_SC_RESET_EXE="$sc_prs" + export LABWIRED_SC_RESET_CHIP="$reset_chip" + export LABWIRED_SC_RESET_PROBE="$reset_probe" + else + unset LABWIRED_SC_RESET_EXE LABWIRED_SC_RESET_CHIP LABWIRED_SC_RESET_PROBE + fi + export LABWIRED_SC_PORT="$port" export LABWIRED_SC_BAUD="$baud" export LABWIRED_SC_MARKER="$marker" @@ -202,6 +231,25 @@ def _is_char_device(path: str) -> bool: stream, is_tty, closer = open_stream(port, baud) + +# The port is open and flushed before the target is started, so a banner emitted +# once at boot lands in this buffer instead of being printed to nobody. +reset_exe = os.environ.get("LABWIRED_SC_RESET_EXE") +reset_error = None +if reset_exe: + import subprocess + try: + completed = subprocess.run( + [reset_exe, "reset", + "--chip", os.environ["LABWIRED_SC_RESET_CHIP"], + "--probe", os.environ["LABWIRED_SC_RESET_PROBE"]], + capture_output=True, timeout=max(5.0, timeout_s), + ) + if completed.returncode != 0: + reset_error = (completed.stderr or b"").decode("utf-8", "replace").strip()[:200] or "reset failed" + except Exception as error: # never let a start failure masquerade as silence + reset_error = f"{type(error).__name__}: {error}"[:200] + buf = bytearray() matched = False excerpt = "" @@ -284,6 +332,13 @@ result = { "status": "hardware_observed" if matched else "failed", "fixture": (not is_tty) or bool(os.environ.get("LABWIRED_SERIAL_FIXTURE")), } +# A target we failed to start must never be reported as a target that said +# nothing: zero bytes then reads as broken firmware rather than a broken launch. +if reset_exe: + result["started_target"] = reset_error is None + if reset_error is not None: + result["start_error"] = reset_error + result["status"] = "blocked" print(json.dumps(result, separators=(",", ":"))) sys.exit(0 if matched else 1) diff --git a/tests/hardware-observations.test.mjs b/tests/hardware-observations.test.mjs index 9880371..35f500c 100644 --- a/tests/hardware-observations.test.mjs +++ b/tests/hardware-observations.test.mjs @@ -188,7 +188,20 @@ test('serial and RTT delegate to existing capture commands and cannot share capa const serial = { id: 'heartbeat', provider: 'serial', contains: 'alive', timeoutSeconds: 7, requiredLevel: 'hardware_observed' }; const rtt = { id: 'trace', provider: 'rtt', contains: 'ready', timeoutSeconds: 8, requiredLevel: 'hardware_observed' }; const serialReady = await adapters.observation.serial.preflight(p, serial); - assert.deepEqual(adapters.observation.serial.plan(p, serial, serialReady).args, ['serial-capture', '/dev/ttyACM0', '115200', 'alive', '7']); + // A physical profile boots the target from inside the capture, after the port + // is open — otherwise a banner printed once at boot is emitted to nobody and + // read back as "marker was not observed". + assert.deepEqual(adapters.observation.serial.plan(p, serial, serialReady).args, [ + 'serial-capture', '/dev/ttyACM0', '115200', 'alive', '7', + '--reset-chip', 'esp32c3', '--reset-probe', 'probe-123', + ]); + // No flash stage means nothing of ours put firmware there, so nothing of ours + // resets it either: the flags must be absent, not merely harmless. + const observeOnly = { ...p, flash: undefined }; + const observeOnlyReady = await adapters.observation.serial.preflight(observeOnly, serial); + assert.deepEqual(adapters.observation.serial.plan(observeOnly, serial, observeOnlyReady).args, [ + 'serial-capture', '/dev/ttyACM0', '115200', 'alive', '7', + ]); const rttReady = await adapters.observation.rtt.preflight(p, rtt); assert.deepEqual(adapters.observation.rtt.plan(p, rtt, rttReady).args, ['probe', 'rtt-capture', '--chip', 'esp32c3', '--probe', 'probe-123', '--elf', p.build.artifact, '--marker', 'ready', '--timeout', '8']); assert.throws(() => adapters.observation.rtt.plan(p, rtt, serialReady), /capability/);