Skip to content

Commit 06fa4c8

Browse files
W-MaiFASTSHIFT
authored andcommitted
fix(cli,mdns): address Copilot review
* MdnsAdvertiser.update_device_state() rebuilt TXT without the 'id' field, breaking the Discovery.md contract after the first state update. Cache the persisted id on register and route both register and update through a shared _build_txt(state) helper. New unit test pins all TXT keys (including 'id') across both call sites. * Resolver-level ambiguity now raises AmbiguousServerError, a FPBCLIError subclass with exit_code=2, and main()'s handler exits via e.exit_code. Previously every FPBCLIError exited 1, so scripts couldn't distinguish 'needs disambiguation' from runtime failures — contradicting the documented ladder. * The two 'pass --server-url to choose:' prompts (the new resolver and the legacy resolve_server_url) now point users at '-s <handle>' instead. --server-url is hidden from --help and deprecated; the suggestion has to match. * Discovery.md said the mDNS instance name was 'FPBInject on <hostname>', but the advertiser actually emits 'FPBInject on <hostname>:<port>' (the port suffix is what makes the client-side 'handle' value possible and lets multiple servers per host coexist). Spec updated. * main() now passes legacy kwargs (port / baudrate / direct / server_url / token) alongside plan= so downstream wrappers that monkeypatch FPBCLI construction see compatible call args. The plan kwarg is the actual source of truth; legacy kwargs are ignored when plan is present. Tests: 92/92 PASS on new test files; test_cli_coexistence 43/43 PASS; run_tests.py --coverage --target 85 -> 85.5%.
1 parent 68a48ac commit 06fa4c8

7 files changed

Lines changed: 69 additions & 41 deletions

File tree

Tools/WebServer/Docs/Discovery.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This document specifies the mDNS / DNS-SD service that FPBInject WebServer insta
1313
| Field | Value |
1414
|---|---|
1515
| Service type | `_fpbinject._tcp.local.` |
16-
| Instance name | `FPBInject on <hostname>` (RFC 6763 §4.1.1 user-visible name) |
16+
| Instance name | `FPBInject on <hostname>:<port>` (RFC 6763 §4.1.1 user-visible name; the port suffix lets multiple servers on one host coexist and is the source of the client-side ``handle`` value). |
1717
| Port | The TCP port the WebServer is listening on (default `5500`, follows `--port`). |
1818
| Address | All interfaces zeroconf binds to (IPv4 only by default). |
1919
| Server hostname | `<hostname>.local.` |

Tools/WebServer/cli/fpb_cli.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,20 @@
6464

6565

6666
class FPBCLIError(Exception):
67-
"""CLI specific errors"""
67+
"""CLI-specific error. ``exit_code`` defaults to 1.
6868
69-
pass
69+
Raise the ``AmbiguousServerError`` subclass when more than one server
70+
matches a discovery handle so main() can exit ``2`` (the documented
71+
ladder code for "needs disambiguation").
72+
"""
73+
74+
exit_code = 1
75+
76+
77+
class AmbiguousServerError(FPBCLIError):
78+
"""Multi-match on discovery handle / mDNS browse; exits ``2``."""
79+
80+
exit_code = 2
7081

7182

7283
class DeviceState(DeviceStateBase):
@@ -1378,7 +1389,7 @@ def _resolve_handle_to_url(value: str, *, source: str) -> str:
13781389
for s in matches:
13791390
msg.append(f" {s.handle} {s.url}")
13801391
msg.append("Be more specific (use 'host:port' form).")
1381-
raise FPBCLIError("\n".join(msg))
1392+
raise AmbiguousServerError("\n".join(msg))
13821393

13831394
chosen = matches[0]
13841395
if kind == "host_port":
@@ -1546,16 +1557,14 @@ def resolve_connection_plan(args) -> ConnectionPlan:
15461557
_classify_url(s.url, token=token, source="mdns"), port, baudrate
15471558
)
15481559

1549-
print(
1550-
"Multiple FPBInject servers discovered; pass --server-url to choose:",
1551-
file=sys.stderr,
1552-
)
1560+
lines = [
1561+
"Multiple FPBInject servers discovered; pass -s <handle> to choose:",
1562+
]
15531563
for s in servers:
1554-
print(
1555-
f" {s.url} version={s.version} auth={s.auth} device={s.device}",
1556-
file=sys.stderr,
1564+
lines.append(
1565+
f" -s {s.handle} version={s.version} auth={s.auth} device={s.device}"
15571566
)
1558-
sys.exit(2)
1567+
raise AmbiguousServerError("\n".join(lines))
15591568

15601569

15611570
def resolve_server_url(args):
@@ -1596,12 +1605,12 @@ def resolve_server_url(args):
15961605
)
15971606
return s.url
15981607
print(
1599-
"Multiple FPBInject servers discovered; pass --server-url to choose:",
1608+
"Multiple FPBInject servers discovered; pass -s <handle> to choose:",
16001609
file=sys.stderr,
16011610
)
16021611
for s in servers:
16031612
print(
1604-
f" {s.url} version={s.version} auth={s.auth} device={s.device}",
1613+
f" -s {s.handle} version={s.version} auth={s.auth} device={s.device}",
16051614
file=sys.stderr,
16061615
)
16071616
sys.exit(2)
@@ -2062,16 +2071,21 @@ def main():
20622071
plan = resolve_connection_plan(args)
20632072
cli = FPBCLI(
20642073
verbose=args.verbose,
2074+
port=args.port,
2075+
baudrate=args.baudrate,
20652076
elf_path=elf_path,
20662077
compile_commands=args.compile_commands,
20672078
tx_chunk_size=args.tx_chunk_size,
20682079
tx_chunk_delay=args.tx_chunk_delay,
20692080
max_retries=args.max_retries,
2081+
direct=args.direct,
2082+
server_url=plan.server_url,
2083+
token=args.token,
20702084
plan=plan,
20712085
)
20722086
except FPBCLIError as e:
20732087
print(f"Error: {e}", file=sys.stderr)
2074-
sys.exit(1)
2088+
sys.exit(e.exit_code)
20752089
args.server_url = plan.server_url
20762090

20772091
try:
@@ -2139,7 +2153,7 @@ def main():
21392153
cli.server_stop(port)
21402154
except FPBCLIError as e:
21412155
cli.output_error(str(e))
2142-
sys.exit(1)
2156+
sys.exit(e.exit_code)
21432157
except KeyboardInterrupt:
21442158
print("\nInterrupted by user", file=sys.stderr)
21452159
sys.exit(130)

Tools/WebServer/services/mdns_advertiser.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,23 @@ def __init__(
9999
self._registered = False
100100
self._prev_sigint = None
101101
self._prev_sigterm = None
102+
self._server_id = ""
102103

103-
def _build_info(self) -> ServiceInfo:
104-
hostname = socket.gethostname() or "fpbinject"
105-
instance = f"FPBInject on {hostname}:{self._port}"
106-
properties = {
104+
def _build_txt(self, device_state: str) -> dict:
105+
return {
107106
"txtvers": TXT_SCHEMA_VERSION,
108107
"version": self._version,
109108
"auth": self._auth_mode,
110-
"device": "none",
109+
"device": device_state,
111110
"path": self._path,
112-
"id": _load_or_create_server_id(),
111+
"id": self._server_id,
113112
}
113+
114+
def _build_info(self) -> ServiceInfo:
115+
hostname = socket.gethostname() or "fpbinject"
116+
instance = f"FPBInject on {hostname}:{self._port}"
117+
self._server_id = _load_or_create_server_id()
118+
properties = self._build_txt("none")
114119
return ServiceInfo(
115120
type_=SERVICE_TYPE,
116121
name=f"{instance}.{SERVICE_TYPE}",
@@ -190,13 +195,7 @@ def update_device_state(self, state: str) -> None:
190195
return
191196
if state not in ("none", "connected"):
192197
raise ValueError(f"state must be 'none' or 'connected', got {state!r}")
193-
new_props = {
194-
"txtvers": TXT_SCHEMA_VERSION,
195-
"version": self._version,
196-
"auth": self._auth_mode,
197-
"device": state,
198-
"path": self._path,
199-
}
198+
new_props = self._build_txt(state)
200199
try:
201200
self._info._set_properties(new_props)
202201
self._zc.update_service(self._info)

Tools/WebServer/tests/test_handle_resolution.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def test_host_only_unique_resolves(self):
141141

142142
@patch.dict(os.environ, {}, clear=True)
143143
def test_host_only_ambiguous_raises(self):
144-
from cli.fpb_cli import FPBCLIError
144+
from cli.fpb_cli import AmbiguousServerError
145145

146146
resolve = _import_resolver()
147147
with patch(
@@ -151,9 +151,10 @@ def test_host_only_ambiguous_raises(self):
151151
_server("127.0.0.1", 5501, handle="bench:5501"),
152152
],
153153
):
154-
with self.assertRaises(FPBCLIError) as cm:
154+
with self.assertRaises(AmbiguousServerError) as cm:
155155
resolve(_ns(server="bench"))
156156
self.assertIn("ambiguous", str(cm.exception))
157+
self.assertEqual(cm.exception.exit_code, 2)
157158

158159
@patch.dict(os.environ, {}, clear=True)
159160
def test_handle_no_match_raises(self):

Tools/WebServer/tests/test_mdns_advertiser.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,18 @@ def test_update_device_state_calls_update_service(self, MockZeroconf):
145145
adv.update_device_state("connected")
146146
self.assertTrue(zc.update_service.called)
147147

148+
@patch("services.mdns_advertiser.Zeroconf")
149+
def test_update_device_state_preserves_id_and_all_txt_keys(self, MockZeroconf):
150+
adv = _make_advertiser()
151+
adv.register()
152+
register_keys = set(adv._build_txt("none").keys())
153+
154+
connected_props = adv._build_txt("connected")
155+
self.assertEqual(set(connected_props.keys()), register_keys)
156+
self.assertEqual(connected_props["id"], adv._server_id)
157+
self.assertEqual(connected_props["device"], "connected")
158+
self.assertNotEqual(connected_props["id"], "")
159+
148160

149161
class TestMdnsAdvertiserIdempotentUnregister(unittest.TestCase):
150162
"""unregister() is safe to call repeatedly."""

Tools/WebServer/tests/test_mdns_discovery.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,9 @@ def test_two_results_lists_and_exits_2(self):
304304
with self.assertRaises(SystemExit) as cm:
305305
resolve_server_url(_ns(server_url=None, requires_server=True))
306306
self.assertEqual(cm.exception.code, 2)
307-
self.assertIn("10.0.0.10", err.getvalue())
308-
self.assertIn("10.0.0.11", err.getvalue())
307+
self.assertIn("10.0.0.10:5500", err.getvalue())
308+
self.assertIn("10.0.0.11:5500", err.getvalue())
309+
self.assertIn("-s ", err.getvalue())
309310

310311

311312
# ---- cmd_discover() JSON output (S8) ----------------------------------------

Tools/WebServer/tests/test_resolve_connection_plan.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ def test_one_remote_result_returns_remote_proxy(self):
240240
self.assertEqual(plan.server_url, "http://192.168.1.20:5500")
241241

242242
@patch.dict(os.environ, {}, clear=True)
243-
def test_two_results_lists_and_exits_2(self):
243+
def test_two_results_raises_ambiguous_server_error(self):
244+
from cli.fpb_cli import AmbiguousServerError
245+
244246
resolve = _import_resolver()
245247
servers = [
246248
_fake_server(host="10.0.0.10", port=5500),
@@ -249,13 +251,12 @@ def test_two_results_lists_and_exits_2(self):
249251
with patch("cli.fpb_cli.list_cli_servers", return_value=[]), patch(
250252
"cli.fpb_cli._localhost_status_ok", return_value=False
251253
), patch("cli.fpb_cli.discover_sync", return_value=servers):
252-
err = io.StringIO()
253-
with redirect_stderr(err):
254-
with self.assertRaises(SystemExit) as cm:
255-
resolve(_ns())
256-
self.assertEqual(cm.exception.code, 2)
257-
self.assertIn("10.0.0.10", err.getvalue())
258-
self.assertIn("10.0.0.11", err.getvalue())
254+
with self.assertRaises(AmbiguousServerError) as cm:
255+
resolve(_ns())
256+
self.assertEqual(cm.exception.exit_code, 2)
257+
self.assertIn("10.0.0.10:5500", str(cm.exception))
258+
self.assertIn("10.0.0.11:5500", str(cm.exception))
259+
self.assertIn("-s ", str(cm.exception))
259260

260261

261262
class TestPlanProperties(unittest.TestCase):

0 commit comments

Comments
 (0)