Skip to content
Merged
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
41 changes: 38 additions & 3 deletions lib/hardware/adapters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -1062,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));
},
Expand Down
4 changes: 4 additions & 0 deletions lib/probe-flash.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
7 changes: 7 additions & 0 deletions lib/probe.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions lib/serial-capture.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port> <baud> <marker> <timeout_seconds>" >&2
Expand All @@ -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"
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion tests/hardware-observations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down