flashgate 0.7.1__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.1 → flashgate-0.7.2}/PKG-INFO +28 -6
- {flashgate-0.7.1 → flashgate-0.7.2}/README.md +27 -5
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/bench.py +28 -4
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/bench_serve.py +114 -5
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/cli.py +35 -1
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/records.py +39 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/PKG-INFO +28 -6
- {flashgate-0.7.1 → flashgate-0.7.2}/pyproject.toml +1 -1
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_bench.py +52 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_bench_serve.py +89 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_cli.py +74 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_records.py +60 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/LICENSE +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/__init__.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/__main__.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/board.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/flasher.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/gatestate.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/mcp_server.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/probes.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/results.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/serialmon.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/sttools.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate/swdsig.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/SOURCES.txt +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/dependency_links.txt +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/entry_points.txt +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/requires.txt +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/flashgate.egg-info/top_level.txt +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/setup.cfg +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_board.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_flasher.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_gatestate.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_hook_stop.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_mcp.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_probes.py +0 -0
- {flashgate-0.7.1 → flashgate-0.7.2}/tests/test_serialmon.py +0 -0
- {flashgate-0.7.1 → 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
|
|
@@ -180,6 +189,13 @@ fingerprint identifies the SOURCE).
|
|
|
180
189
|
same-named servers split-brained the mesh and raced each other for
|
|
181
190
|
the board's serial port.
|
|
182
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
|
+
|
|
183
199
|
**Security posture, stated plainly**: D2D mode is zero-authentication —
|
|
184
200
|
anyone on the same LAN can discover the bench and `start_verify`, which
|
|
185
201
|
FLASHES THE BOARD. Descriptions and records also carry local paths.
|
|
@@ -241,9 +257,11 @@ full profile field reference.
|
|
|
241
257
|
|
|
242
258
|
## Status
|
|
243
259
|
|
|
244
|
-
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
247
265
|
|
|
248
266
|
## License
|
|
249
267
|
|
|
@@ -267,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
|
|
|
267
285
|
- 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
|
|
268
286
|
- 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
|
|
269
287
|
静默失效的设置第一步就会露馅
|
|
288
|
+
- 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
|
|
289
|
+
产物哈希——事后可审计"当时验证的是什么、板子说了什么"
|
|
290
|
+
- `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
|
|
291
|
+
协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
|
|
270
292
|
- Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
|
|
271
293
|
会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
|
|
272
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
|
|
@@ -159,6 +168,13 @@ fingerprint identifies the SOURCE).
|
|
|
159
168
|
same-named servers split-brained the mesh and raced each other for
|
|
160
169
|
the board's serial port.
|
|
161
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
|
+
|
|
162
178
|
**Security posture, stated plainly**: D2D mode is zero-authentication —
|
|
163
179
|
anyone on the same LAN can discover the bench and `start_verify`, which
|
|
164
180
|
FLASHES THE BOARD. Descriptions and records also carry local paths.
|
|
@@ -220,9 +236,11 @@ full profile field reference.
|
|
|
220
236
|
|
|
221
237
|
## Status
|
|
222
238
|
|
|
223
|
-
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel
|
|
224
|
-
|
|
225
|
-
|
|
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.
|
|
226
244
|
|
|
227
245
|
## License
|
|
228
246
|
|
|
@@ -246,6 +264,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
|
|
|
246
264
|
- 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
|
|
247
265
|
- 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
|
|
248
266
|
静默失效的设置第一步就会露馅
|
|
267
|
+
- 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
|
|
268
|
+
产物哈希——事后可审计"当时验证的是什么、板子说了什么"
|
|
269
|
+
- `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
|
|
270
|
+
协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
|
|
249
271
|
- Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
|
|
250
272
|
会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
|
|
251
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.
|
|
@@ -24,6 +24,8 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
26
|
import socket
|
|
27
|
+
import threading
|
|
28
|
+
import _thread
|
|
27
29
|
import zlib
|
|
28
30
|
from pathlib import Path
|
|
29
31
|
from typing import Optional
|
|
@@ -60,8 +62,68 @@ def acquire_bench_lock(fw_dir: Path) -> socket.socket:
|
|
|
60
62
|
f"owns lock port {_lock_port(fw_dir)}; kill it first)")
|
|
61
63
|
|
|
62
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
|
+
|
|
63
125
|
def _build_driver(bench: BenchDriver):
|
|
64
|
-
from device_connect_edge.drivers import DeviceDriver, rpc
|
|
126
|
+
from device_connect_edge.drivers import DeviceDriver, emit, rpc
|
|
65
127
|
|
|
66
128
|
class FlashGateBenchDriver(DeviceDriver):
|
|
67
129
|
device_type = "flashgate-bench"
|
|
@@ -69,10 +131,46 @@ def _build_driver(bench: BenchDriver):
|
|
|
69
131
|
def __init__(self, bench: BenchDriver):
|
|
70
132
|
super().__init__() # base builds _functions_cache etc.
|
|
71
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()
|
|
72
169
|
|
|
73
170
|
@rpc()
|
|
74
171
|
async def describe_bench(self) -> dict:
|
|
75
172
|
"""Board identity, probe list, busy state of this bench."""
|
|
173
|
+
self._remember_loop()
|
|
76
174
|
return bench.describe()
|
|
77
175
|
|
|
78
176
|
@rpc()
|
|
@@ -94,6 +192,7 @@ def _build_driver(bench: BenchDriver):
|
|
|
94
192
|
# argument must not cost a build+flash cycle
|
|
95
193
|
return {"error": "invalid_argument",
|
|
96
194
|
"detail": "probes must be a list of probe names or null"}
|
|
195
|
+
self._remember_loop()
|
|
97
196
|
try:
|
|
98
197
|
return bench.start_verify(probes)
|
|
99
198
|
except BenchBusyError as exc:
|
|
@@ -134,18 +233,28 @@ def serve(board: Board, device_id: str | None = None) -> int:
|
|
|
134
233
|
print(f"[bench-serve] {exc}")
|
|
135
234
|
return 2
|
|
136
235
|
|
|
236
|
+
_start_lock_listener(lock)
|
|
137
237
|
bench = BenchDriver(board)
|
|
138
|
-
|
|
238
|
+
driver = _build_driver(bench)(bench)
|
|
239
|
+
bench.set_on_complete(driver._operation_done) # verify_completed events
|
|
139
240
|
ident = device_id or f"flashgate-bench-{board.name}"
|
|
140
241
|
print(f"[bench-serve] {ident}: {board.name} ({board.mcu}) — "
|
|
141
|
-
f"four RPCs over device-connect; Ctrl+C
|
|
242
|
+
f"four RPCs over device-connect; Ctrl+C or `bench-serve --stop` "
|
|
243
|
+
f"to stop")
|
|
142
244
|
try:
|
|
143
|
-
asyncio.run(DeviceRuntime(driver=
|
|
245
|
+
asyncio.run(DeviceRuntime(driver=driver,
|
|
144
246
|
device_id=ident).run())
|
|
145
247
|
except KeyboardInterrupt:
|
|
146
248
|
# Drain before exit (bench.py's deployment constraint): a daemon
|
|
147
249
|
# worker killed mid-flash leaves CubeProgrammer orphaned and the
|
|
148
|
-
# run without its evidence record.
|
|
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
|
|
149
258
|
current = bench.describe().get("current_operation")
|
|
150
259
|
if current:
|
|
151
260
|
print(f"\n[bench-serve] draining {current} before stop...")
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -180,6 +189,13 @@ fingerprint identifies the SOURCE).
|
|
|
180
189
|
same-named servers split-brained the mesh and raced each other for
|
|
181
190
|
the board's serial port.
|
|
182
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
|
+
|
|
183
199
|
**Security posture, stated plainly**: D2D mode is zero-authentication —
|
|
184
200
|
anyone on the same LAN can discover the bench and `start_verify`, which
|
|
185
201
|
FLASHES THE BOARD. Descriptions and records also carry local paths.
|
|
@@ -241,9 +257,11 @@ full profile field reference.
|
|
|
241
257
|
|
|
242
258
|
## Status
|
|
243
259
|
|
|
244
|
-
Boot gate, probe gate, Stop hook, MCP server, SWD signature channel
|
|
245
|
-
|
|
246
|
-
|
|
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.
|
|
247
265
|
|
|
248
266
|
## License
|
|
249
267
|
|
|
@@ -267,6 +285,10 @@ flashgate 回答一个很具体的问题:刚编译出来的固件,烧到板
|
|
|
267
285
|
- 版本身份带 `-dirty` 语义,验证通过意味着板上跑的就是当前工作区
|
|
268
286
|
- 探针下真命令、断言硬件寄存器读回值;响应报的是实际状态不是回声,
|
|
269
287
|
静默失效的设置第一步就会露馅
|
|
288
|
+
- 每次验证(无论成败)都留一份证据记录:逐项检查结论、板子的原话、
|
|
289
|
+
产物哈希——事后可审计"当时验证的是什么、板子说了什么"
|
|
290
|
+
- `bench-serve` 可以把整个台架挂上局域网:远程 agent 用 device-connect
|
|
291
|
+
协议发现它、请求真机验证、拿回带证据的结论(跨机实测通过)
|
|
270
292
|
- Stop hook 挂进 Claude Code:agent 改了固件没过真机验证就说"完成",
|
|
271
293
|
会被拦下并收到板子的失败证词;同一棵坏树最多拦两次,之后放行但
|
|
272
294
|
打警告,会话不会被卡死
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "flashgate"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Hardware-in-the-loop verification gate: your agent can't claim the firmware works until the board says so."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -347,3 +347,55 @@ class TestHardening:
|
|
|
347
347
|
bench_mod.probe_mod.load_probes = orig
|
|
348
348
|
assert info["probes"] == []
|
|
349
349
|
assert "bad probes yaml" in info["probes_error"]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class TestCompletionCallback:
|
|
353
|
+
def test_notified_on_success_and_failure(self, tmp_path):
|
|
354
|
+
board = make_board(tmp_path)
|
|
355
|
+
events = []
|
|
356
|
+
d = BenchDriver(board, verify_fn=lambda b, n: len(events))
|
|
357
|
+
d.set_on_complete(lambda snap: events.append((snap["op_id"], snap["state"])))
|
|
358
|
+
d.wait(d.start_verify()["op_id"], 5)
|
|
359
|
+
d.wait(d.start_verify()["op_id"], 5)
|
|
360
|
+
assert [s for _, s in events] == ["succeeded", "failed"]
|
|
361
|
+
|
|
362
|
+
def test_notified_on_pre_start_cancel(self, tmp_path):
|
|
363
|
+
from flashgate.bench import _Operation
|
|
364
|
+
board = make_board(tmp_path)
|
|
365
|
+
events = []
|
|
366
|
+
d = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
367
|
+
d.set_on_complete(lambda snap: events.append(snap["state"]))
|
|
368
|
+
with d._lock:
|
|
369
|
+
op = _Operation(op_id="op-x", created_at="now")
|
|
370
|
+
d._ops[op.op_id] = op
|
|
371
|
+
d._current = op
|
|
372
|
+
assert d.cancel_operation("op-x") is True
|
|
373
|
+
assert events == ["cancelled"]
|
|
374
|
+
|
|
375
|
+
def test_callback_exception_swallowed(self, tmp_path):
|
|
376
|
+
board = make_board(tmp_path)
|
|
377
|
+
|
|
378
|
+
def boom(snap):
|
|
379
|
+
raise RuntimeError("callback bug")
|
|
380
|
+
|
|
381
|
+
d = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
382
|
+
d.set_on_complete(boom)
|
|
383
|
+
done = d.wait(d.start_verify()["op_id"], 5)
|
|
384
|
+
assert done["state"] == "succeeded" # bench unaffected
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class TestCallbackGuard:
|
|
388
|
+
"""Mutation P5: a callback exception must not even surface as an
|
|
389
|
+
unhandled-thread-exception warning."""
|
|
390
|
+
|
|
391
|
+
@pytest.mark.filterwarnings(
|
|
392
|
+
"error::pytest.PytestUnhandledThreadExceptionWarning")
|
|
393
|
+
def test_callback_exception_no_thread_warning(self, tmp_path):
|
|
394
|
+
board = make_board(tmp_path)
|
|
395
|
+
|
|
396
|
+
def boom(snap):
|
|
397
|
+
raise RuntimeError("callback bug")
|
|
398
|
+
|
|
399
|
+
d = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
400
|
+
d.set_on_complete(boom)
|
|
401
|
+
assert d.wait(d.start_verify()["op_id"], 5)["state"] == "succeeded"
|
|
@@ -166,3 +166,92 @@ class TestSingleInstanceLock:
|
|
|
166
166
|
assert "one bench-serve per bench" in capsys.readouterr().out
|
|
167
167
|
finally:
|
|
168
168
|
lock.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestStopChannel:
|
|
172
|
+
def test_stop_signals_live_listener(self, tmp_path):
|
|
173
|
+
from pathlib import Path as P
|
|
174
|
+
from flashgate.bench_serve import (_start_lock_listener,
|
|
175
|
+
acquire_bench_lock, stop_bench)
|
|
176
|
+
signalled = []
|
|
177
|
+
# interrupt is called with NO arguments (like _thread.interrupt_main)
|
|
178
|
+
lock = acquire_bench_lock(tmp_path)
|
|
179
|
+
try:
|
|
180
|
+
thread = _start_lock_listener(
|
|
181
|
+
lock, interrupt=lambda: signalled.append("FIRED"))
|
|
182
|
+
assert stop_bench(tmp_path) is True
|
|
183
|
+
thread.join(timeout=3)
|
|
184
|
+
assert not thread.is_alive()
|
|
185
|
+
assert signalled == ["FIRED"] # the stop signal fired once
|
|
186
|
+
finally:
|
|
187
|
+
lock.close()
|
|
188
|
+
|
|
189
|
+
def test_stop_without_holder_is_false(self, tmp_path):
|
|
190
|
+
from flashgate.bench_serve import stop_bench
|
|
191
|
+
(tmp_path / "free").mkdir(exist_ok=True)
|
|
192
|
+
assert stop_bench(tmp_path / "free") is False
|
|
193
|
+
|
|
194
|
+
def test_cli_stop_without_server_exit_2(self, tmp_path, monkeypatch, capsys):
|
|
195
|
+
from flashgate import cli
|
|
196
|
+
board = make_board(tmp_path)
|
|
197
|
+
rc = cli.main(["--board", str(board.yaml_path), "bench-serve", "--stop"])
|
|
198
|
+
assert rc == 2
|
|
199
|
+
assert "no bench-serve" in capsys.readouterr().out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestCompletedEventWiring:
|
|
203
|
+
def test_operation_done_noop_without_loop(self, tmp_path):
|
|
204
|
+
board = make_board(tmp_path)
|
|
205
|
+
bench = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
206
|
+
drv = _build_driver(bench)(bench)
|
|
207
|
+
drv._operation_done({"op_id": "x", "state": "succeeded"}) # no crash
|
|
208
|
+
|
|
209
|
+
def test_loop_captured_on_first_rpc(self, tmp_path):
|
|
210
|
+
board = make_board(tmp_path)
|
|
211
|
+
bench = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
212
|
+
drv = _build_driver(bench)(bench)
|
|
213
|
+
assert drv._loop is None
|
|
214
|
+
asyncio.run(drv.describe_bench())
|
|
215
|
+
assert drv._loop is not None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class TestEventAndStopGuards:
|
|
219
|
+
"""Mutation P6/P8: the loop guard must actually skip scheduling, and
|
|
220
|
+
stop_bench must not claim success without an ok ack."""
|
|
221
|
+
|
|
222
|
+
def test_no_scheduling_without_loop(self, tmp_path, monkeypatch):
|
|
223
|
+
board = make_board(tmp_path)
|
|
224
|
+
bench = BenchDriver(board, verify_fn=lambda b, n: 0)
|
|
225
|
+
drv = _build_driver(bench)(bench)
|
|
226
|
+
calls = []
|
|
227
|
+
monkeypatch.attr_target = None
|
|
228
|
+
import flashgate.bench_serve as bs
|
|
229
|
+
monkeypatch.setattr(
|
|
230
|
+
bs.asyncio, "run_coroutine_threadsafe",
|
|
231
|
+
lambda coro, loop: calls.append(loop))
|
|
232
|
+
drv._operation_done({"op_id": "x", "state": "succeeded"})
|
|
233
|
+
assert calls == [] # loop None: nothing scheduled
|
|
234
|
+
import threading
|
|
235
|
+
loop = asyncio.new_event_loop()
|
|
236
|
+
t = threading.Thread(target=loop.run_forever, daemon=True)
|
|
237
|
+
t.start()
|
|
238
|
+
try:
|
|
239
|
+
drv._loop = loop # as _remember_loop would
|
|
240
|
+
drv._operation_done({"op_id": "y", "state": "failed"})
|
|
241
|
+
assert len(calls) == 1 and calls[0] is loop
|
|
242
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
243
|
+
finally:
|
|
244
|
+
t.join(2)
|
|
245
|
+
loop.close()
|
|
246
|
+
|
|
247
|
+
def test_stop_bench_false_without_ok_ack(self, tmp_path):
|
|
248
|
+
import socket as pysocket
|
|
249
|
+
from flashgate.bench_serve import _lock_port, stop_bench
|
|
250
|
+
# an impostor holds the port but does not speak the protocol
|
|
251
|
+
impostor = pysocket.socket()
|
|
252
|
+
impostor.bind(("127.0.0.1", _lock_port(tmp_path)))
|
|
253
|
+
impostor.listen(1)
|
|
254
|
+
try:
|
|
255
|
+
assert stop_bench(tmp_path) is False
|
|
256
|
+
finally:
|
|
257
|
+
impostor.close()
|
|
@@ -281,3 +281,77 @@ class TestUartBuildFailRecord:
|
|
|
281
281
|
assert by_name["console"]["status"] == "passed"
|
|
282
282
|
assert by_name["build"]["status"] == "failed"
|
|
283
283
|
assert by_name["identity"]["status"] == "skipped"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestVerifyJsonFlag:
|
|
287
|
+
def test_json_stdout_pure_record(self, tmp_path, monkeypatch, capsys):
|
|
288
|
+
from flashgate import records as rec
|
|
289
|
+
board = make_board(tmp_path)
|
|
290
|
+
monkeypatch.setattr(cli, "_console_port", lambda b: (None, "no serial"))
|
|
291
|
+
monkeypatch.setattr(cli, "_build", lambda b, j=None: cli.EXIT_BUILD)
|
|
292
|
+
rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
|
|
293
|
+
assert rc == cli.EXIT_BUILD
|
|
294
|
+
out = capsys.readouterr()
|
|
295
|
+
import json as _json
|
|
296
|
+
payload = _json.loads(out.out.strip().splitlines()[-1])
|
|
297
|
+
assert payload["run"]["exit_code"] == 1
|
|
298
|
+
by_name = {c["name"]: c for c in payload["checks"]}
|
|
299
|
+
assert by_name["build"]["status"] == "failed"
|
|
300
|
+
assert by_name["identity"]["status"] == "skipped"
|
|
301
|
+
assert "[verify]" in out.err # human logs on stderr
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class TestVerifyJsonGuard:
|
|
305
|
+
"""Mutation P4: a stale LAST entry from ANOTHER firmware dir must not
|
|
306
|
+
be printed as this run's record."""
|
|
307
|
+
|
|
308
|
+
def test_foreign_last_not_printed(self, tmp_path, monkeypatch, capsys):
|
|
309
|
+
import json as _json
|
|
310
|
+
from flashgate import records as rec
|
|
311
|
+
board = make_board(tmp_path)
|
|
312
|
+
rec.LAST = {"path": None, "fw_dir": "Z:/somewhere-else",
|
|
313
|
+
"record": {"run": {"exit_code": 0}}, "at": 0.0}
|
|
314
|
+
|
|
315
|
+
def no_write(b, names, evidence=None):
|
|
316
|
+
return 7 # fails, writes nothing
|
|
317
|
+
|
|
318
|
+
monkeypatch.setattr(cli, "cmd_verify", no_write)
|
|
319
|
+
rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
|
|
320
|
+
assert rc == 7
|
|
321
|
+
payload = _json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
|
322
|
+
assert payload == {"error": "no record written", "exit_code": 7}
|
|
323
|
+
|
|
324
|
+
def test_stdout_carries_no_human_logs(self, tmp_path, monkeypatch, capsys):
|
|
325
|
+
board = make_board(tmp_path)
|
|
326
|
+
monkeypatch.setattr(cli, "_console_port", lambda b: (None, "no serial"))
|
|
327
|
+
monkeypatch.setattr(cli, "_build", lambda b, j=None: cli.EXIT_BUILD)
|
|
328
|
+
cli.main(["--board", str(board.yaml_path), "verify", "--json"])
|
|
329
|
+
out = capsys.readouterr().out
|
|
330
|
+
assert "[verify]" not in out and "[build]" not in out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class TestJsonEncodingGuard:
|
|
334
|
+
"""Adversarial R1: the JSON must survive a non-UTF-8 consumer pipe —
|
|
335
|
+
U+FFFD from serial noise met cp936 and used to wipe stdout, exit 1."""
|
|
336
|
+
|
|
337
|
+
def test_json_output_is_pure_ascii(self, tmp_path, monkeypatch, capsys):
|
|
338
|
+
import json as _json
|
|
339
|
+
from flashgate import records as rec
|
|
340
|
+
board = make_board(tmp_path)
|
|
341
|
+
|
|
342
|
+
def noisy(b, names, evidence=None):
|
|
343
|
+
j = rec.VerifyJournal(["boot"], mode="uart", probe_names=names)
|
|
344
|
+
j.check("boot", "failed")
|
|
345
|
+
j.add_evidence("console-tail", "COM3",
|
|
346
|
+
"noise \ufffd 拒绝访问 \ufffd")
|
|
347
|
+
r = j.to_record(b, 3, "timed_out", "x")
|
|
348
|
+
rec.write_record(r, b.firmware_dir, fingerprint="ee" * 32)
|
|
349
|
+
return 3
|
|
350
|
+
|
|
351
|
+
monkeypatch.setattr(cli, "cmd_verify", noisy)
|
|
352
|
+
rc = cli.main(["--board", str(board.yaml_path), "verify", "--json"])
|
|
353
|
+
assert rc == 3
|
|
354
|
+
raw = capsys.readouterr().out.strip().splitlines()[-1]
|
|
355
|
+
raw.encode("ascii") # must not raise
|
|
356
|
+
payload = _json.loads(raw)
|
|
357
|
+
assert "\ufffd" in str(payload) # content survives, escaped
|
|
@@ -6,6 +6,7 @@ record-level echo of the gate's core rule.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import hashlib
|
|
9
|
+
import json
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from types import SimpleNamespace
|
|
11
12
|
|
|
@@ -233,3 +234,62 @@ class TestCrashNetAndUniqueness:
|
|
|
233
234
|
# compare by path content, NOT latest_record's mtime order —
|
|
234
235
|
# same-tick writes make that ordering flaky on some filesystems
|
|
235
236
|
assert _json.loads(p2.read_text(encoding="utf-8"))["record_id"] == p2.stem
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class TestRetention:
|
|
240
|
+
"""Architecture-doc open question #2: records must not grow unbounded."""
|
|
241
|
+
|
|
242
|
+
def test_prune_keeps_newest_cap(self, tmp_path, monkeypatch):
|
|
243
|
+
from flashgate import records as rec
|
|
244
|
+
monkeypatch.setattr(rec, "MAX_RECORDS", 5)
|
|
245
|
+
for i in range(8):
|
|
246
|
+
j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
|
|
247
|
+
j.check("build", "passed")
|
|
248
|
+
rec.write_record(j.to_record(_board_stub(), i % 8, "failed", "x"),
|
|
249
|
+
tmp_path, fingerprint=f"{i:064d}")
|
|
250
|
+
files = sorted(rec.records_dir(tmp_path).glob("*.json"))
|
|
251
|
+
assert len(files) == 5
|
|
252
|
+
kept = [json.loads(f.read_text(encoding="utf-8"))["run"]["exit_code"]
|
|
253
|
+
for f in files]
|
|
254
|
+
assert kept == [3, 4, 5, 6, 7] # the five NEWEST by mtime
|
|
255
|
+
|
|
256
|
+
def test_prune_never_breaks_writing(self, tmp_path, monkeypatch):
|
|
257
|
+
import pytest
|
|
258
|
+
from flashgate import records as rec
|
|
259
|
+
monkeypatch.setattr(rec, "MAX_RECORDS", 2)
|
|
260
|
+
for i in range(4):
|
|
261
|
+
j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
|
|
262
|
+
j.check("build", "passed")
|
|
263
|
+
path = rec.write_record(
|
|
264
|
+
j.to_record(_board_stub(), 0, "succeeded", "x"),
|
|
265
|
+
tmp_path, fingerprint=f"{i:064d}")
|
|
266
|
+
assert path.exists()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class TestPruneGuards:
|
|
270
|
+
"""Adversarial-round fixes: the just-written record survives an NTP
|
|
271
|
+
clock rollback (R3); retention stays best-effort, never fatal."""
|
|
272
|
+
|
|
273
|
+
def test_just_written_survives_clock_rollback(self, tmp_path, monkeypatch):
|
|
274
|
+
import os
|
|
275
|
+
from datetime import datetime, timedelta
|
|
276
|
+
from flashgate import records as rec
|
|
277
|
+
monkeypatch.setattr(rec, "MAX_RECORDS", 3)
|
|
278
|
+
# three records whose mtimes are ONE HOUR IN THE FUTURE
|
|
279
|
+
for i in range(3):
|
|
280
|
+
j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
|
|
281
|
+
j.check("build", "passed")
|
|
282
|
+
p = rec.write_record(j.to_record(_board_stub(), 0, "succeeded", "x"),
|
|
283
|
+
tmp_path, fingerprint=f"{i:064d}")
|
|
284
|
+
future = datetime.now() + timedelta(hours=1)
|
|
285
|
+
os.utime(p, (future.timestamp(), future.timestamp()))
|
|
286
|
+
# a fresh write under the rolled-back clock must survive its own prune
|
|
287
|
+
j = rec.VerifyJournal(["build"], mode="swd", probe_names=None)
|
|
288
|
+
j.check("build", "passed")
|
|
289
|
+
fresh = rec.write_record(j.to_record(_board_stub(), 7, "failed", "x"),
|
|
290
|
+
tmp_path, fingerprint="ff" * 32)
|
|
291
|
+
assert fresh.exists(), "the just-written record must never be pruned"
|
|
292
|
+
# (latest_record sorts by mtime, so the future-stamped files win —
|
|
293
|
+
# correct behavior under clock skew; the invariant is survival)
|
|
294
|
+
import json as _json
|
|
295
|
+
assert _json.loads(fresh.read_text(encoding="utf-8"))["record_id"] == fresh.stem
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|