flashgate 0.7.0__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flashgate-0.7.0 → flashgate-0.7.2}/PKG-INFO +44 -6
- {flashgate-0.7.0 → flashgate-0.7.2}/README.md +43 -5
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/bench.py +28 -4
- flashgate-0.7.2/flashgate/bench_serve.py +268 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/cli.py +35 -1
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/records.py +39 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/PKG-INFO +44 -6
- {flashgate-0.7.0 → flashgate-0.7.2}/pyproject.toml +1 -1
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_bench.py +52 -0
- flashgate-0.7.2/tests/test_bench_serve.py +257 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_cli.py +74 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_records.py +60 -0
- flashgate-0.7.0/flashgate/bench_serve.py +0 -122
- flashgate-0.7.0/tests/test_bench_serve.py +0 -127
- {flashgate-0.7.0 → flashgate-0.7.2}/LICENSE +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/__init__.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/__main__.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/board.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/flasher.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/gatestate.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/mcp_server.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/probes.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/results.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/serialmon.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/sttools.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate/swdsig.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/SOURCES.txt +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/setup.cfg +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_board.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_flasher.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_gatestate.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_hook_stop.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_mcp.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_probes.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_serialmon.py +0 -0
- {flashgate-0.7.0 → flashgate-0.7.2}/tests/test_swdsig.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flashgate
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Lion-1209/flashgate
|
|
@@ -69,6 +69,7 @@ including register readbacks (TIM3 CCR), not firmware self-reports:
|
|
|
69
69
|
```bash
|
|
70
70
|
pip install -e . # Python 3.11+
|
|
71
71
|
pip install -e ".[mcp]" # optional MCP server
|
|
72
|
+
pip install -e ".[bench]" # optional remote bench (device-connect)
|
|
72
73
|
|
|
73
74
|
flashgate doctor # ST-Link / serial / toolchain sanity
|
|
74
75
|
flashgate verify --all-probes # build → flash → evidence → sha → probes
|
|
@@ -134,6 +135,9 @@ tree+profile+tool-version identity the verdict applies to. The MCP
|
|
|
134
135
|
firmware repo's `.gitignore` (the shipped example does), or every record
|
|
135
136
|
write churns the fingerprint. A record that cannot be written is a
|
|
136
137
|
warning, never a changed exit code — and a crashed run still leaves one.
|
|
138
|
+
The newest ~500 records are kept (best-effort pruning). `flashgate verify
|
|
139
|
+
--json` prints this run's record as pure-ASCII JSON on stdout (human logs
|
|
140
|
+
move to stderr) for scripting — safe on any console codepage.
|
|
137
141
|
|
|
138
142
|
## Remote bench (device-connect, Stage 2)
|
|
139
143
|
|
|
@@ -148,11 +152,16 @@ DEVICE_CONNECT_ALLOW_INSECURE=true DEVICE_CONNECT_DISCOVERY_MODE=d2d flashgate
|
|
|
148
152
|
```
|
|
149
153
|
|
|
150
154
|
Remote callers discover `device_type=flashgate-bench` and use
|
|
151
|
-
`describe_bench` / `start_verify` / `get_operation` / `cancel_operation
|
|
155
|
+
`describe_bench` / `start_verify` / `get_operation` / `cancel_operation`,
|
|
156
|
+
plus a `verify_completed` event per operation at its terminal state (an
|
|
157
|
+
operation drained during server shutdown does not emit one — polling
|
|
158
|
+
remains the authoritative contract).
|
|
152
159
|
`start_verify` returns an `op_id` immediately (single verify at a time —
|
|
153
160
|
a client retrying over a flaky network can never trigger a second
|
|
154
161
|
flash); poll `get_operation` to the terminal snapshot, which carries the
|
|
155
|
-
exit code and the run's full evidence record.
|
|
162
|
+
exit code and the run's full evidence record. Stop the server without
|
|
163
|
+
hunting PIDs: `flashgate --board <profile> bench-serve --stop` (it
|
|
164
|
+
drains the in-flight operation, then exits).
|
|
156
165
|
|
|
157
166
|
**The red line for every consumer**: the mesh wraps any normally-delivered
|
|
158
167
|
reply as `success: true` — including a busy bench answering
|
|
@@ -164,6 +173,29 @@ and per-check verdicts equal; `artifact_sha256` differs by design — it
|
|
|
164
173
|
identifies the BUILD, which embeds its timestamp, while the tree
|
|
165
174
|
fingerprint identifies the SOURCE).
|
|
166
175
|
|
|
176
|
+
**Deployment notes** (from the first real cross-host test, wired host
|
|
177
|
+
+ Wi-Fi laptop, 2026-09-15):
|
|
178
|
+
|
|
179
|
+
- Multicast discovery does not cross all wired↔wireless routers. If
|
|
180
|
+
discovery finds nothing across machines but `ping` works, switch to
|
|
181
|
+
unicast: on the bench host set `ZENOH_LISTEN=tcp/0.0.0.0:7447` before
|
|
182
|
+
`bench-serve`; on the client set `MESSAGING_URLS=tcp/<host-ip>:7447`.
|
|
183
|
+
- Open UDP 7446 (multicast scouting) or TCP 7447 (unicast) inbound on
|
|
184
|
+
the host's firewall; a Wi-Fi client set to "Public network" blocks the
|
|
185
|
+
announcements it needs to receive.
|
|
186
|
+
- **One bench-serve per bench**: a second instance for the same
|
|
187
|
+
firmware dir is refused at startup (a loopback lock socket, released
|
|
188
|
+
automatically when the process dies). Before the lock existed, two
|
|
189
|
+
same-named servers split-brained the mesh and raced each other for
|
|
190
|
+
the board's serial port.
|
|
191
|
+
|
|
192
|
+
**Third-party licensing**: the `bench` extra depends on
|
|
193
|
+
[device-connect-edge](https://github.com/arm/device-connect)
|
|
194
|
+
(Apache-2.0) as a pip dependency only — flashgate stays MIT, and no
|
|
195
|
+
device-connect source is vendored into this repository (fixes go
|
|
196
|
+
upstream as PRs). Commercial distributions that bundle the dependency
|
|
197
|
+
must include its LICENSE and NOTICE files.
|
|
198
|
+
|
|
167
199
|
**Security posture, stated plainly**: D2D mode is zero-authentication —
|
|
168
200
|
anyone on the same LAN can discover the bench and `start_verify`, which
|
|
169
201
|
FLASHES THE BOARD. Descriptions and records also carry local paths.
|
|
@@ -225,9 +257,11 @@ full profile field reference.
|
|
|
225
257
|
|
|
226
258
|
## Status
|
|
227
259
|
|
|
228
|
-
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel
|
|
229
|
-
|
|
230
|
-
|
|
260
|
+
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
261
|
+
verification records (per-check evidence with artifact hashes), and the
|
|
262
|
+
remote bench over device-connect — all implemented and validated on real
|
|
263
|
+
hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
|
|
264
|
+
bench, 2026-09-15). Windows-first; Linux/macOS untested.
|
|
231
265
|
|
|
232
266
|
## License
|
|
233
267
|
|
|
@@ -251,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
|
|
|
251
285
|
- 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
|
|
252
286
|
- 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
|
|
253
287
|
静默失效的设置第一步就会露馅
|
|
288
|
+
- 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
|
|
289
|
+
产物哈希——事后可审计"当时验证的是什么、板子说了什么"
|
|
290
|
+
- `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
|
|
291
|
+
协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
|
|
254
292
|
- Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
|
|
255
293
|
会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
|
|
256
294
|
打警告,会话不会被卡死
|
|
@@ -48,6 +48,7 @@ including register readbacks (TIM3 CCR), not firmware self-reports:
|
|
|
48
48
|
```bash
|
|
49
49
|
pip install -e . # Python 3.11+
|
|
50
50
|
pip install -e ".[mcp]" # optional MCP server
|
|
51
|
+
pip install -e ".[bench]" # optional remote bench (device-connect)
|
|
51
52
|
|
|
52
53
|
flashgate doctor # ST-Link / serial / toolchain sanity
|
|
53
54
|
flashgate verify --all-probes # build → flash → evidence → sha → probes
|
|
@@ -113,6 +114,9 @@ tree+profile+tool-version identity the verdict applies to. The MCP
|
|
|
113
114
|
firmware repo's `.gitignore` (the shipped example does), or every record
|
|
114
115
|
write churns the fingerprint. A record that cannot be written is a
|
|
115
116
|
warning, never a changed exit code — and a crashed run still leaves one.
|
|
117
|
+
The newest ~500 records are kept (best-effort pruning). `flashgate verify
|
|
118
|
+
--json` prints this run's record as pure-ASCII JSON on stdout (human logs
|
|
119
|
+
move to stderr) for scripting — safe on any console codepage.
|
|
116
120
|
|
|
117
121
|
## Remote bench (device-connect, Stage 2)
|
|
118
122
|
|
|
@@ -127,11 +131,16 @@ DEVICE_CONNECT_ALLOW_INSECURE=true DEVICE_CONNECT_DISCOVERY_MODE=d2d flashgate
|
|
|
127
131
|
```
|
|
128
132
|
|
|
129
133
|
Remote callers discover `device_type=flashgate-bench` and use
|
|
130
|
-
`describe_bench` / `start_verify` / `get_operation` / `cancel_operation
|
|
134
|
+
`describe_bench` / `start_verify` / `get_operation` / `cancel_operation`,
|
|
135
|
+
plus a `verify_completed` event per operation at its terminal state (an
|
|
136
|
+
operation drained during server shutdown does not emit one — polling
|
|
137
|
+
remains the authoritative contract).
|
|
131
138
|
`start_verify` returns an `op_id` immediately (single verify at a time —
|
|
132
139
|
a client retrying over a flaky network can never trigger a second
|
|
133
140
|
flash); poll `get_operation` to the terminal snapshot, which carries the
|
|
134
|
-
exit code and the run's full evidence record.
|
|
141
|
+
exit code and the run's full evidence record. Stop the server without
|
|
142
|
+
hunting PIDs: `flashgate --board <profile> bench-serve --stop` (it
|
|
143
|
+
drains the in-flight operation, then exits).
|
|
135
144
|
|
|
136
145
|
**The red line for every consumer**: the mesh wraps any normally-delivered
|
|
137
146
|
reply as `success: true` — including a busy bench answering
|
|
@@ -143,6 +152,29 @@ and per-check verdicts equal; `artifact_sha256` differs by design — it
|
|
|
143
152
|
identifies the BUILD, which embeds its timestamp, while the tree
|
|
144
153
|
fingerprint identifies the SOURCE).
|
|
145
154
|
|
|
155
|
+
**Deployment notes** (from the first real cross-host test, wired host
|
|
156
|
+
+ Wi-Fi laptop, 2026-09-15):
|
|
157
|
+
|
|
158
|
+
- Multicast discovery does not cross all wired↔wireless routers. If
|
|
159
|
+
discovery finds nothing across machines but `ping` works, switch to
|
|
160
|
+
unicast: on the bench host set `ZENOH_LISTEN=tcp/0.0.0.0:7447` before
|
|
161
|
+
`bench-serve`; on the client set `MESSAGING_URLS=tcp/<host-ip>:7447`.
|
|
162
|
+
- Open UDP 7446 (multicast scouting) or TCP 7447 (unicast) inbound on
|
|
163
|
+
the host's firewall; a Wi-Fi client set to "Public network" blocks the
|
|
164
|
+
announcements it needs to receive.
|
|
165
|
+
- **One bench-serve per bench**: a second instance for the same
|
|
166
|
+
firmware dir is refused at startup (a loopback lock socket, released
|
|
167
|
+
automatically when the process dies). Before the lock existed, two
|
|
168
|
+
same-named servers split-brained the mesh and raced each other for
|
|
169
|
+
the board's serial port.
|
|
170
|
+
|
|
171
|
+
**Third-party licensing**: the `bench` extra depends on
|
|
172
|
+
[device-connect-edge](https://github.com/arm/device-connect)
|
|
173
|
+
(Apache-2.0) as a pip dependency only — flashgate stays MIT, and no
|
|
174
|
+
device-connect source is vendored into this repository (fixes go
|
|
175
|
+
upstream as PRs). Commercial distributions that bundle the dependency
|
|
176
|
+
must include its LICENSE and NOTICE files.
|
|
177
|
+
|
|
146
178
|
**Security posture, stated plainly**: D2D mode is zero-authentication —
|
|
147
179
|
anyone on the same LAN can discover the bench and `start_verify`, which
|
|
148
180
|
FLASHES THE BOARD. Descriptions and records also carry local paths.
|
|
@@ -204,9 +236,11 @@ full profile field reference.
|
|
|
204
236
|
|
|
205
237
|
## Status
|
|
206
238
|
|
|
207
|
-
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel
|
|
208
|
-
|
|
209
|
-
|
|
239
|
+
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel,
|
|
240
|
+
verification records (per-check evidence with artifact hashes), and the
|
|
241
|
+
remote bench over device-connect — all implemented and validated on real
|
|
242
|
+
hardware, the remote bench cross-host (Wi-Fi laptop driving a wired
|
|
243
|
+
bench, 2026-09-15). Windows-first; Linux/macOS untested.
|
|
210
244
|
|
|
211
245
|
## License
|
|
212
246
|
|
|
@@ -230,6 +264,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
|
|
|
230
264
|
- 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
|
|
231
265
|
- 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
|
|
232
266
|
静默失效的设置第一步就会露馅
|
|
267
|
+
- 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
|
|
268
|
+
产物哈希——事后可审计"当时验证的是什么、板子说了什么"
|
|
269
|
+
- `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
|
|
270
|
+
协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
|
|
233
271
|
- Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
|
|
234
272
|
会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
|
|
235
273
|
打警告,会话不会被卡死
|
|
@@ -110,10 +110,12 @@ class _Operation:
|
|
|
110
110
|
class BenchDriver:
|
|
111
111
|
"""Single-board bench: owns one in-flight verify and its history."""
|
|
112
112
|
|
|
113
|
-
def __init__(self, board: Board, verify_fn: VerifyFn | None = None
|
|
113
|
+
def __init__(self, board: Board, verify_fn: VerifyFn | None = None,
|
|
114
|
+
on_complete: "Callable[[dict], None] | None" = None):
|
|
114
115
|
self._board = board
|
|
115
116
|
self._verify_fn = verify_fn or (
|
|
116
117
|
lambda b, names: cli_mod.cmd_verify(b, names))
|
|
118
|
+
self._on_complete = on_complete # auxiliary: see set_on_complete
|
|
117
119
|
self._lock = threading.Lock()
|
|
118
120
|
self._ops: dict[str, _Operation] = {}
|
|
119
121
|
self._threads: dict[str, threading.Thread] = {}
|
|
@@ -128,6 +130,22 @@ class BenchDriver:
|
|
|
128
130
|
f"(single-flight and record association both assume it)")
|
|
129
131
|
_DRIVERS[key] = self
|
|
130
132
|
|
|
133
|
+
def set_on_complete(self, cb: "Callable[[dict], None] | None") -> None:
|
|
134
|
+
"""Register a best-effort completion callback: invoked once with
|
|
135
|
+
the terminal snapshot when an operation reaches succeeded/failed/
|
|
136
|
+
cancelled. Exceptions in the callback are swallowed —
|
|
137
|
+
notification must never affect the operation or the bench (the
|
|
138
|
+
RPC front-end uses this to emit verify_completed events)."""
|
|
139
|
+
self._on_complete = cb
|
|
140
|
+
|
|
141
|
+
def _notify(self, op: _Operation) -> None:
|
|
142
|
+
if self._on_complete is None:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
self._on_complete(op.snapshot())
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
131
149
|
# ------------------------------------------------------ describe_bench
|
|
132
150
|
def describe(self) -> dict:
|
|
133
151
|
probes_error = ""
|
|
@@ -200,9 +218,14 @@ class BenchDriver:
|
|
|
200
218
|
op.state = "cancelled"
|
|
201
219
|
op.finished_at = _iso()
|
|
202
220
|
op.summary = "cancelled before execution"
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
221
|
+
cancelled = True
|
|
222
|
+
else:
|
|
223
|
+
cancelled = False
|
|
224
|
+
op.cancel_requested = True
|
|
225
|
+
if cancelled:
|
|
226
|
+
self._notify(op) # outside the lock: a custom
|
|
227
|
+
return True # callback may query the bench
|
|
228
|
+
return False
|
|
206
229
|
|
|
207
230
|
# ------------------------------------------------------ wait (helper)
|
|
208
231
|
def wait(self, op_id: str, timeout_s: float = 600.0) -> dict | None:
|
|
@@ -237,6 +260,7 @@ class BenchDriver:
|
|
|
237
260
|
op.state = "failed"
|
|
238
261
|
op.error = ((op.error + " | ") if op.error else "") + \
|
|
239
262
|
"worker exited without a verdict"
|
|
263
|
+
self._notify(op)
|
|
240
264
|
|
|
241
265
|
def _attach_record(self, op: _Operation) -> None:
|
|
242
266
|
"""Attach THIS run's evidence record via the in-process registry.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""flashgate bench-serve: expose the local bench over device-connect (L1).
|
|
2
|
+
|
|
3
|
+
A thin shell over BenchDriver (L0) — exactly four named RPCs, nothing
|
|
4
|
+
else: no arbitrary shell, no path arguments, no console passthrough.
|
|
5
|
+
Which board a bench serves is fixed by the --board profile the server
|
|
6
|
+
was started with; remote callers cannot choose it.
|
|
7
|
+
|
|
8
|
+
Requires the optional extra:
|
|
9
|
+
|
|
10
|
+
pip install "flashgate[bench]" # -> device-connect-edge
|
|
11
|
+
|
|
12
|
+
D2D mode (no broker, Zenoh multicast scouting) is the default shape for
|
|
13
|
+
a bench: set DEVICE_CONNECT_ALLOW_INSECURE=true for local development
|
|
14
|
+
and force DEVICE_CONNECT_DISCOVERY_MODE=d2d when no broker URLs exist.
|
|
15
|
+
|
|
16
|
+
THE RED LINE for every consumer (GPT6 report §4.2): the mesh wraps a
|
|
17
|
+
normally-delivered reply as success:true regardless of what the bench
|
|
18
|
+
verified. Judge verification ONLY by the operation payload —
|
|
19
|
+
state / exit_code / status and the embedded record's checks — never by
|
|
20
|
+
transport success alone.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import socket
|
|
27
|
+
import threading
|
|
28
|
+
import _thread
|
|
29
|
+
import zlib
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from .bench import BenchBusyError, BenchDriver
|
|
34
|
+
from .board import Board
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _lock_port(fw_dir: Path) -> int:
|
|
38
|
+
return 17500 + (zlib.crc32(
|
|
39
|
+
str(fw_dir.resolve()).lower().encode("utf-8")) % 1000)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def acquire_bench_lock(fw_dir: Path) -> socket.socket:
|
|
43
|
+
"""OS-level single-instance lock per bench, auto-released when the
|
|
44
|
+
process dies (a bound socket cannot outlive its process).
|
|
45
|
+
|
|
46
|
+
Why: two bench-serves for the SAME firmware dir register the same
|
|
47
|
+
device_id on the mesh and split-brain every RPC — the losing process
|
|
48
|
+
races the winner for the board's serial port and surfaces as
|
|
49
|
+
access-denied (seen live in the first cross-host test, 2026-09-15).
|
|
50
|
+
Different boards (different fw dirs) lock different ports and may
|
|
51
|
+
share a host."""
|
|
52
|
+
s = socket.socket()
|
|
53
|
+
try:
|
|
54
|
+
s.bind(("127.0.0.1", _lock_port(fw_dir)))
|
|
55
|
+
s.listen(1)
|
|
56
|
+
return s
|
|
57
|
+
except OSError:
|
|
58
|
+
s.close()
|
|
59
|
+
raise RuntimeError(
|
|
60
|
+
f"another bench-serve for {fw_dir} appears to be running on "
|
|
61
|
+
f"this machine — one bench-serve per bench (the running one "
|
|
62
|
+
f"owns lock port {_lock_port(fw_dir)}; kill it first)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _start_lock_listener(lock: socket.socket,
|
|
66
|
+
interrupt=None) -> threading.Thread:
|
|
67
|
+
"""Accept `stop` commands on the bench lock port. The stopper gets an
|
|
68
|
+
immediate ack; THIS process then exits through serve()'s drain path —
|
|
69
|
+
the operator never has to hunt for a PID (the pain that motivated
|
|
70
|
+
this, see v0.7.1 field notes).
|
|
71
|
+
|
|
72
|
+
`interrupt` defaults to interrupting the MAIN thread (where serve()
|
|
73
|
+
runs asyncio.run — the KeyboardInterrupt unwinds through the drain
|
|
74
|
+
path). It is injectable so tests can observe the signal without
|
|
75
|
+
receiving a real KeyboardInterrupt."""
|
|
76
|
+
if interrupt is None:
|
|
77
|
+
interrupt = _thread.interrupt_main
|
|
78
|
+
def _loop() -> None:
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
conn, _ = lock.accept()
|
|
82
|
+
except OSError:
|
|
83
|
+
return # lock closed: server exiting
|
|
84
|
+
try:
|
|
85
|
+
conn.settimeout(2) # a silent local connection must
|
|
86
|
+
data = conn.recv(64) # not wedge the stop channel (R4)
|
|
87
|
+
if data.strip().lower() == b"stop":
|
|
88
|
+
try:
|
|
89
|
+
conn.sendall(b"ok: draining and stopping\n")
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
interrupt()
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return
|
|
97
|
+
conn.sendall(b"unknown command\n")
|
|
98
|
+
except OSError:
|
|
99
|
+
pass
|
|
100
|
+
finally:
|
|
101
|
+
try:
|
|
102
|
+
conn.close()
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
t = threading.Thread(target=_loop, daemon=True,
|
|
106
|
+
name="flashgate-bench-lock")
|
|
107
|
+
t.start()
|
|
108
|
+
return t
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stop_bench(fw_dir: Path) -> bool:
|
|
112
|
+
"""Signal a running bench-serve for this firmware dir to stop.
|
|
113
|
+
True = signalled (it drains its in-flight operation, then exits);
|
|
114
|
+
False = no bench-serve is holding the lock."""
|
|
115
|
+
try:
|
|
116
|
+
with socket.create_connection(("127.0.0.1", _lock_port(fw_dir)),
|
|
117
|
+
timeout=3) as s:
|
|
118
|
+
s.sendall(b"stop\n")
|
|
119
|
+
reply = s.recv(128).decode("utf-8", errors="replace")
|
|
120
|
+
return reply.startswith("ok")
|
|
121
|
+
except OSError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_driver(bench: BenchDriver):
|
|
126
|
+
from device_connect_edge.drivers import DeviceDriver, emit, rpc
|
|
127
|
+
|
|
128
|
+
class FlashGateBenchDriver(DeviceDriver):
|
|
129
|
+
device_type = "flashgate-bench"
|
|
130
|
+
|
|
131
|
+
def __init__(self, bench: BenchDriver):
|
|
132
|
+
super().__init__() # base builds _functions_cache etc.
|
|
133
|
+
self._bench = bench
|
|
134
|
+
self._loop: "asyncio.AbstractEventLoop | None" = None
|
|
135
|
+
|
|
136
|
+
@emit()
|
|
137
|
+
async def verify_completed(self, op_id: str, state: str,
|
|
138
|
+
exit_code: Optional[int] = None,
|
|
139
|
+
status: Optional[str] = None) -> None:
|
|
140
|
+
"""Fires once per operation at its terminal state
|
|
141
|
+
(succeeded / failed / cancelled) — polling-free completion
|
|
142
|
+
for remote consumers. Auxiliary: polling get_operation
|
|
143
|
+
remains the authoritative contract."""
|
|
144
|
+
|
|
145
|
+
def _operation_done(self, snapshot: dict) -> None:
|
|
146
|
+
# Called from the bench worker THREAD: hop onto the runtime
|
|
147
|
+
# loop. Best-effort — an emit failure never touches the op.
|
|
148
|
+
loop = self._loop
|
|
149
|
+
if loop is None or loop.is_closed():
|
|
150
|
+
return
|
|
151
|
+
payload = {"op_id": snapshot["op_id"], "state": snapshot["state"]}
|
|
152
|
+
# Omit null fields entirely: the SDK's schema converter drops
|
|
153
|
+
# Optional's null, so emitting exit_code=None (cancelled /
|
|
154
|
+
# crashed ops) would produce payload/schema contradictions
|
|
155
|
+
# for validating consumers (adversarial review R2).
|
|
156
|
+
if snapshot.get("exit_code") is not None:
|
|
157
|
+
payload["exit_code"] = snapshot["exit_code"]
|
|
158
|
+
if snapshot.get("status") is not None:
|
|
159
|
+
payload["status"] = snapshot["status"]
|
|
160
|
+
try:
|
|
161
|
+
asyncio.run_coroutine_threadsafe(
|
|
162
|
+
self.verify_completed(**payload), loop)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def _remember_loop(self) -> None:
|
|
167
|
+
if self._loop is None:
|
|
168
|
+
self._loop = asyncio.get_running_loop()
|
|
169
|
+
|
|
170
|
+
@rpc()
|
|
171
|
+
async def describe_bench(self) -> dict:
|
|
172
|
+
"""Board identity, probe list, busy state of this bench."""
|
|
173
|
+
self._remember_loop()
|
|
174
|
+
return bench.describe()
|
|
175
|
+
|
|
176
|
+
@rpc()
|
|
177
|
+
async def start_verify(self, probes: Optional[list] = None) -> dict:
|
|
178
|
+
"""Start one hardware verify; returns the operation snapshot
|
|
179
|
+
(state=running, op_id). Single-flight: a busy bench answers
|
|
180
|
+
{"error": "busy", ...} — a transport success that must NOT be
|
|
181
|
+
read as a verification pass.
|
|
182
|
+
|
|
183
|
+
Note: Optional[list] (typing.Union), NOT `list | None` — the
|
|
184
|
+
SDK's schema converter misses PEP 604 unions and would
|
|
185
|
+
advertise probes as a STRING, steering well-behaved callers
|
|
186
|
+
into flashing the board only to die on "unknown probe 'a'"
|
|
187
|
+
(adversarial review F1)."""
|
|
188
|
+
if probes is not None and (
|
|
189
|
+
not isinstance(probes, list)
|
|
190
|
+
or not all(isinstance(x, str) for x in probes)):
|
|
191
|
+
# reject BEFORE any hardware is touched (F2): a malformed
|
|
192
|
+
# argument must not cost a build+flash cycle
|
|
193
|
+
return {"error": "invalid_argument",
|
|
194
|
+
"detail": "probes must be a list of probe names or null"}
|
|
195
|
+
self._remember_loop()
|
|
196
|
+
try:
|
|
197
|
+
return bench.start_verify(probes)
|
|
198
|
+
except BenchBusyError as exc:
|
|
199
|
+
return {"error": "busy", "detail": str(exc)}
|
|
200
|
+
|
|
201
|
+
@rpc()
|
|
202
|
+
async def get_operation(self, op_id: str) -> dict:
|
|
203
|
+
"""Poll an operation until state is succeeded|failed|cancelled;
|
|
204
|
+
the terminal snapshot carries exit_code/status and the run's
|
|
205
|
+
evidence record (checks + what the board actually said)."""
|
|
206
|
+
op = bench.get_operation(op_id)
|
|
207
|
+
if op is None:
|
|
208
|
+
return {"error": "unknown_operation", "op_id": op_id}
|
|
209
|
+
return op
|
|
210
|
+
|
|
211
|
+
@rpc()
|
|
212
|
+
async def cancel_operation(self, op_id: str) -> dict:
|
|
213
|
+
"""True = cancelled (only before execution starts). Executing
|
|
214
|
+
operations are never torn down mid-run — the request is
|
|
215
|
+
recorded and the run completes with its REAL verdict."""
|
|
216
|
+
return {"op_id": op_id, "cancelled": bench.cancel_operation(op_id)}
|
|
217
|
+
|
|
218
|
+
return FlashGateBenchDriver
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def serve(board: Board, device_id: str | None = None) -> int:
|
|
222
|
+
"""Run the bench server until Ctrl+C. Blocks forever."""
|
|
223
|
+
try:
|
|
224
|
+
from device_connect_edge import DeviceRuntime
|
|
225
|
+
except ImportError:
|
|
226
|
+
print("bench-serve needs the optional dependency: "
|
|
227
|
+
'pip install "flashgate[bench]" (device-connect-edge)')
|
|
228
|
+
return 2
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
lock = acquire_bench_lock(board.firmware_dir) # held for life
|
|
232
|
+
except RuntimeError as exc:
|
|
233
|
+
print(f"[bench-serve] {exc}")
|
|
234
|
+
return 2
|
|
235
|
+
|
|
236
|
+
_start_lock_listener(lock)
|
|
237
|
+
bench = BenchDriver(board)
|
|
238
|
+
driver = _build_driver(bench)(bench)
|
|
239
|
+
bench.set_on_complete(driver._operation_done) # verify_completed events
|
|
240
|
+
ident = device_id or f"flashgate-bench-{board.name}"
|
|
241
|
+
print(f"[bench-serve] {ident}: {board.name} ({board.mcu}) — "
|
|
242
|
+
f"four RPCs over device-connect; Ctrl+C or `bench-serve --stop` "
|
|
243
|
+
f"to stop")
|
|
244
|
+
try:
|
|
245
|
+
asyncio.run(DeviceRuntime(driver=driver,
|
|
246
|
+
device_id=ident).run())
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
# Drain before exit (bench.py's deployment constraint): a daemon
|
|
249
|
+
# worker killed mid-flash leaves CubeProgrammer orphaned and the
|
|
250
|
+
# run without its evidence record. A SECOND interrupt during the
|
|
251
|
+
# drain would escape and kill the worker mid-flash (R6) — ignore
|
|
252
|
+
# Ctrl+C/stop re-sends until the drain completes.
|
|
253
|
+
import signal
|
|
254
|
+
try:
|
|
255
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
256
|
+
except (ValueError, OSError):
|
|
257
|
+
pass
|
|
258
|
+
current = bench.describe().get("current_operation")
|
|
259
|
+
if current:
|
|
260
|
+
print(f"\n[bench-serve] draining {current} before stop...")
|
|
261
|
+
done = bench.wait(current, timeout_s=120)
|
|
262
|
+
print(f"[bench-serve] drained: {done['state']} "
|
|
263
|
+
f"exit={done.get('exit_code')}")
|
|
264
|
+
print("[bench-serve] stopped")
|
|
265
|
+
except Exception as exc: # e.g. invalid device_id (F5)
|
|
266
|
+
print(f"[bench-serve] failed: {type(exc).__name__}: {exc}")
|
|
267
|
+
return 2
|
|
268
|
+
return 0
|
|
@@ -10,6 +10,9 @@ Exit-code contract (the M3 Stop hook enforces these):
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
+
import contextlib
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
13
16
|
import os
|
|
14
17
|
import re
|
|
15
18
|
import shutil
|
|
@@ -648,6 +651,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
648
651
|
help="run every probe defined in the board profile")
|
|
649
652
|
p_verify.add_argument("--evidence", choices=["uart", "swd", "auto"],
|
|
650
653
|
help="boot-evidence channel (default: board profile evidence.mode)")
|
|
654
|
+
p_verify.add_argument("--json", action="store_true",
|
|
655
|
+
help="print this run's evidence record as JSON on "
|
|
656
|
+
"stdout (human logs move to stderr)")
|
|
651
657
|
p_probe = sub.add_parser("probe", help="run probes against running firmware")
|
|
652
658
|
p_probe.add_argument("names", nargs="*", metavar="NAME",
|
|
653
659
|
help="probe names (default: all defined in the board profile)")
|
|
@@ -656,6 +662,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
656
662
|
"bench-serve", help="expose this bench over device-connect (optional extra: flashgate[bench])")
|
|
657
663
|
p_bench.add_argument("--device-id", default=None,
|
|
658
664
|
help="device-connect id (default: flashgate-bench-<board>)")
|
|
665
|
+
p_bench.add_argument("--stop", action="store_true",
|
|
666
|
+
help="signal the running bench-serve for this "
|
|
667
|
+
"board to stop (drains, then exits) instead "
|
|
668
|
+
"of starting a new one")
|
|
659
669
|
|
|
660
670
|
args = parser.parse_args(argv)
|
|
661
671
|
try:
|
|
@@ -669,11 +679,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
669
679
|
names: list[str] | None = args.probe
|
|
670
680
|
if args.all_probes:
|
|
671
681
|
names = ["all"]
|
|
682
|
+
if getattr(args, "json", False):
|
|
683
|
+
# stdout is the machine channel: human logs go to stderr
|
|
684
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
685
|
+
rc = cmd_verify(board, names, getattr(args, "evidence", None))
|
|
686
|
+
last = records.LAST
|
|
687
|
+
if last is not None and Path(last["fw_dir"]) == board.firmware_dir:
|
|
688
|
+
# ensure_ascii (default): the JSON must survive ANY
|
|
689
|
+
# consumer codepage — a cp936 pipe meeting U+FFFD from
|
|
690
|
+
# serial noise used to raise UnicodeEncodeError, wipe
|
|
691
|
+
# stdout and exit 1, which scripts misread as BUILD
|
|
692
|
+
# FAILED (adversarial review R1).
|
|
693
|
+
print(json.dumps(last["record"]))
|
|
694
|
+
else:
|
|
695
|
+
print(json.dumps({"error": "no record written",
|
|
696
|
+
"exit_code": rc}))
|
|
697
|
+
return rc
|
|
672
698
|
return cmd_verify(board, names, getattr(args, "evidence", None))
|
|
673
699
|
if args.cmd == "probe":
|
|
674
700
|
return cmd_probe(board, args.names or None)
|
|
675
701
|
if args.cmd == "bench-serve":
|
|
676
|
-
from .bench_serve import serve
|
|
702
|
+
from .bench_serve import serve, stop_bench
|
|
703
|
+
if args.stop:
|
|
704
|
+
if stop_bench(board.firmware_dir):
|
|
705
|
+
print("[bench-serve] stop signalled — the server drains "
|
|
706
|
+
"its in-flight operation, then exits")
|
|
707
|
+
return 0
|
|
708
|
+
print("[bench-serve] no bench-serve is holding the lock for "
|
|
709
|
+
f"{board.firmware_dir}")
|
|
710
|
+
return 2
|
|
677
711
|
return serve(board, args.device_id)
|
|
678
712
|
simple = {
|
|
679
713
|
"doctor": cmd_doctor, "build": cmd_build,
|
|
@@ -30,6 +30,12 @@ if TYPE_CHECKING: # stdlib-only at runtime (CLI dep);
|
|
|
30
30
|
RECORDS_SCHEMA_VERSION = "1.0"
|
|
31
31
|
RECORDS_DIRNAME = ".flashgate/records"
|
|
32
32
|
|
|
33
|
+
# Retention: records are small (a few KB each), but a bench left running
|
|
34
|
+
# for months would grow the dir without bound (architecture doc open
|
|
35
|
+
# question #2). Oldest files are pruned past the cap; the newest PASS
|
|
36
|
+
# and the running audit window are always preserved well inside it.
|
|
37
|
+
MAX_RECORDS = 500
|
|
38
|
+
|
|
33
39
|
# In-process registry of the record written by the LAST write_record call.
|
|
34
40
|
# The MCP server (which runs cmd_verify in-process) uses it to attach THIS
|
|
35
41
|
# run's record — never a stale one picked blindly by mtime.
|
|
@@ -219,9 +225,42 @@ def write_record(record: dict, fw_dir: Path, fingerprint: str = "") -> Path:
|
|
|
219
225
|
encoding="utf-8")
|
|
220
226
|
LAST = {"path": path, "record": record, "fw_dir": fw_dir,
|
|
221
227
|
"at": time.monotonic()}
|
|
228
|
+
_prune(fw_dir, exempt=path)
|
|
222
229
|
return path
|
|
223
230
|
|
|
224
231
|
|
|
232
|
+
def _prune(fw_dir: Path, keep: int | None = None,
|
|
233
|
+
exempt: Path | None = None) -> None:
|
|
234
|
+
"""Keep only the newest `keep` records (mtime order). Best-effort:
|
|
235
|
+
a file that cannot be deleted is skipped — retention must never
|
|
236
|
+
break record writing. `keep=None` resolves MAX_RECORDS at call time
|
|
237
|
+
(a plain default arg would freeze the constant at import)."""
|
|
238
|
+
if keep is None:
|
|
239
|
+
keep = MAX_RECORDS
|
|
240
|
+
directory = records_dir(fw_dir)
|
|
241
|
+
try:
|
|
242
|
+
# Secondary key = filename (its timestamp prefix): rapid writes
|
|
243
|
+
# land on the same mtime tick and a glob-order tiebreak could
|
|
244
|
+
# judge the JUST-WRITTEN file as oldest (flake found by the
|
|
245
|
+
# mutation round's baseline runs).
|
|
246
|
+
candidates = sorted(directory.glob("*.json"),
|
|
247
|
+
key=lambda p: (p.stat().st_mtime, p.name))
|
|
248
|
+
excess = candidates[:-keep] if keep else []
|
|
249
|
+
if exempt is not None:
|
|
250
|
+
# After an NTP clock rollback every EXISTING file looks
|
|
251
|
+
# "newer" than this one — the just-written record may land in
|
|
252
|
+
# the excess list; drop it from the DELETION list only (it
|
|
253
|
+
# still counts toward the cap, so steady state stays exact).
|
|
254
|
+
excess = [f for f in excess if f != exempt]
|
|
255
|
+
except OSError:
|
|
256
|
+
return
|
|
257
|
+
for stale in excess:
|
|
258
|
+
try:
|
|
259
|
+
stale.unlink()
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
225
264
|
def latest_record(fw_dir: Path) -> dict | None:
|
|
226
265
|
"""Most recent record (by mtime), or None when none exist yet."""
|
|
227
266
|
directory = records_dir(fw_dir)
|