Skip to content

Commit 49b6435

Browse files
committed
PWA: scope RPC responses to the requesting tab, not every tab
SidecarRelay now rewrites each request's id to a server-assigned one on the way to the sidecar and back on the way out, tracked against the originating WebSocket. A result/error only ever reaches the tab that made the matching request; notify events (repl_data and friends) still broadcast to every connected tab, since those are board-initiated, not a reply to anyone. Fixes the cross-wire case #20 reports: two tabs each starting their own id counter at 1 could otherwise have a response meant for one land in the other. Closes #20.
1 parent c3b5f9b commit 49b6435

2 files changed

Lines changed: 125 additions & 20 deletions

File tree

cli/src/mpftp/pwa.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,15 @@
55
66
Architecture: this process owns one long-lived ``mpftp.sidecar`` subprocess
77
(the same JSON-lines protocol the CLI and the VS Code extension speak) and
8-
relays it to the browser over a hand-rolled WebSocket — every line the
9-
sidecar writes to stdout is broadcast to connected browser tabs verbatim,
10-
and every WS text frame from a tab is written to the sidecar's stdin
11-
verbatim. The server implements no RPC semantics of its own; the sidecar's
8+
relays it to the browser over a hand-rolled WebSocket. The sidecar's
129
existing method surface (``connect``, ``fs_*``, ``repl_start``/``repl_data``,
13-
...) is unchanged and reused as-is.
10+
...) is unchanged and reused as-is — the relay only touches request/response
11+
``id`` fields, rewriting each to a server-assigned id so two browser tabs
12+
independently starting their own id counter at 1 can never collide on the
13+
one shared sidecar process (mpftp#20); a ``result``/``error`` is routed back
14+
only to the tab that made the matching request, while unsolicited
15+
``notify`` events (``repl_data`` and friends) still broadcast to every
16+
connected tab, since those are board-initiated, not a reply to anyone.
1417
1518
This HTTP/WS port is a separate, explicitly launched service — distinct from
1619
the VS Code extension's agent RPC port (ephemeral, closed until a board
@@ -26,6 +29,7 @@
2629
import base64
2730
import hashlib
2831
import http.server
32+
import json
2933
import os
3034
import socket
3135
import socketserver
@@ -34,7 +38,7 @@
3438
import threading
3539
import webbrowser
3640
from pathlib import Path
37-
from typing import Optional
41+
from typing import Any, Optional
3842

3943
DEFAULT_PORT = 8317
4044

@@ -156,7 +160,15 @@ def close(self) -> None:
156160

157161

158162
class SidecarRelay:
159-
"""One long-lived ``mpftp.sidecar`` subprocess, broadcast to every connected tab."""
163+
"""One long-lived ``mpftp.sidecar`` subprocess, shared by every connected tab.
164+
165+
Each tab's own request ``id`` is rewritten to a server-assigned one on
166+
the way to the sidecar (and rewritten back on the way out), so two tabs
167+
independently starting their own counter at 1 can never collide on the
168+
single shared sidecar (mpftp#20) — a ``result``/``error`` only ever goes
169+
back to the tab that made the matching request. ``notify`` events (and
170+
anything without a recognized pending id) still broadcast to everyone.
171+
"""
160172

161173
def __init__(self, python: str) -> None:
162174
from .cli import _wslenv_forwarded_env
@@ -173,6 +185,8 @@ def __init__(self, python: str) -> None:
173185
)
174186
self._lock = threading.Lock()
175187
self._subscribers: list[WebSocket] = []
188+
self._next_id = 1
189+
self._pending: dict[int, tuple[WebSocket, Any]] = {}
176190
self._reader = threading.Thread(target=self._pump, daemon=True)
177191
self._reader.start()
178192

@@ -181,7 +195,25 @@ def _pump(self) -> None:
181195
for line in self.proc.stdout:
182196
line = line.rstrip("\n")
183197
if line:
184-
self._broadcast(line)
198+
self._route(line)
199+
200+
def _route(self, line: str) -> None:
201+
try:
202+
msg = json.loads(line)
203+
except json.JSONDecodeError:
204+
msg = None
205+
if isinstance(msg, dict) and msg.get("type") in ("result", "error") and "id" in msg:
206+
with self._lock:
207+
entry = self._pending.pop(msg["id"], None)
208+
if entry is not None:
209+
ws, client_id = entry
210+
msg["id"] = client_id
211+
try:
212+
ws.send_text(json.dumps(msg))
213+
except OSError:
214+
self.unsubscribe(ws)
215+
return
216+
self._broadcast(line)
185217

186218
def _broadcast(self, line: str) -> None:
187219
with self._lock:
@@ -200,9 +232,23 @@ def unsubscribe(self, ws: WebSocket) -> None:
200232
with self._lock:
201233
if ws in self._subscribers:
202234
self._subscribers.remove(ws)
235+
stale = [rid for rid, (pending_ws, _) in self._pending.items() if pending_ws is ws]
236+
for rid in stale:
237+
del self._pending[rid]
203238

204-
def send(self, line: str) -> None:
239+
def send(self, line: str, ws: WebSocket) -> None:
205240
assert self.proc.stdin
241+
try:
242+
msg = json.loads(line)
243+
except json.JSONDecodeError:
244+
msg = None
245+
if isinstance(msg, dict) and "id" in msg:
246+
with self._lock:
247+
server_id = self._next_id
248+
self._next_id += 1
249+
self._pending[server_id] = (ws, msg["id"])
250+
msg["id"] = server_id
251+
line = json.dumps(msg)
206252
self.proc.stdin.write(line + "\n")
207253
self.proc.stdin.flush()
208254

@@ -271,7 +317,7 @@ def _handle_ws_upgrade(self) -> None:
271317
text = ws.recv_text()
272318
if text is None:
273319
break
274-
relay.send(text)
320+
relay.send(text, ws)
275321
except OSError:
276322
pass
277323
finally:

cli/tests/test_pwa.py

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,23 +172,74 @@ def _relay_with_fake_proc(self, lines):
172172
relay._reader.join(timeout=2)
173173
return relay, proc
174174

175-
def test_broadcasts_every_sidecar_line_to_subscribed_sockets(self):
176-
relay, proc = self._relay_with_fake_proc(
177-
['{"type":"notify","method":"repl_data"}\n', '{"type":"result","id":1}\n']
178-
)
175+
def test_broadcasts_notify_events_to_every_subscribed_socket(self):
176+
relay, proc = self._relay_with_fake_proc([])
179177
ws = mock.Mock()
180178
relay.subscribe(ws)
181-
# The reader thread already drained proc.stdout by the time it joined above,
182-
# so re-broadcast is exercised directly for a deterministic assertion.
183-
relay._broadcast('{"type":"notify","method":"repl_data"}')
179+
relay._route('{"type":"notify","method":"repl_data"}')
184180
ws.send_text.assert_called_with('{"type":"notify","method":"repl_data"}')
185181

186-
def test_send_writes_a_newline_terminated_line_to_stdin(self):
182+
def test_send_writes_a_newline_terminated_line_with_a_rewritten_id(self):
187183
relay, proc = self._relay_with_fake_proc([])
188-
relay.send('{"method":"ping"}')
189-
proc.stdin.write.assert_called_once_with('{"method":"ping"}\n')
184+
ws = mock.Mock()
185+
relay.send('{"id": 1, "method": "ping"}', ws)
186+
written = proc.stdin.write.call_args[0][0]
187+
self.assertTrue(written.endswith("\n"))
188+
sent = self.mod.json.loads(written)
189+
self.assertEqual(sent["method"], "ping")
190+
# The id sent to the sidecar is the relay's own counter value, tracked
191+
# in _pending against the tab's original id (1) -- not asserting it
192+
# merely differs from 1, which the first request could coincide with.
193+
self.assertEqual(relay._pending, {sent["id"]: (ws, 1)})
190194
proc.stdin.flush.assert_called_once()
191195

196+
def test_a_result_routes_back_only_to_the_requesting_socket_with_its_own_id(self):
197+
relay, proc = self._relay_with_fake_proc([])
198+
ws_a, ws_b = mock.Mock(), mock.Mock()
199+
relay.subscribe(ws_a)
200+
relay.subscribe(ws_b)
201+
relay.send('{"id": 1, "method": "eval"}', ws_a)
202+
server_id = self.mod.json.loads(proc.stdin.write.call_args[0][0])["id"]
203+
204+
relay._route(self.mod.json.dumps({"type": "result", "id": server_id, "result": {"value": "2"}}))
205+
206+
ws_a.send_text.assert_called_once()
207+
reply = self.mod.json.loads(ws_a.send_text.call_args[0][0])
208+
self.assertEqual(reply["id"], 1) # rewritten back to ws_a's own id
209+
ws_b.send_text.assert_not_called()
210+
211+
def test_two_tabs_reusing_the_same_client_id_do_not_cross_wire(self):
212+
"""The bug mpftp#20 reports: independent per-tab id counters can collide."""
213+
relay, proc = self._relay_with_fake_proc([])
214+
ws_a, ws_b = mock.Mock(), mock.Mock()
215+
relay.subscribe(ws_a)
216+
relay.subscribe(ws_b)
217+
218+
relay.send('{"id": 1, "method": "connect"}', ws_a)
219+
server_id_a = self.mod.json.loads(proc.stdin.write.call_args[0][0])["id"]
220+
relay.send('{"id": 1, "method": "list_ports"}', ws_b)
221+
server_id_b = self.mod.json.loads(proc.stdin.write.call_args[0][0])["id"]
222+
self.assertNotEqual(server_id_a, server_id_b)
223+
224+
# B's response arrives first; A must not receive it even though both
225+
# tabs used client id 1.
226+
relay._route(self.mod.json.dumps({"type": "result", "id": server_id_b, "result": []}))
227+
ws_a.send_text.assert_not_called()
228+
reply_b = self.mod.json.loads(ws_b.send_text.call_args[0][0])
229+
self.assertEqual(reply_b["id"], 1)
230+
231+
relay._route(self.mod.json.dumps({"type": "result", "id": server_id_a, "result": {"ok": True}}))
232+
reply_a = self.mod.json.loads(ws_a.send_text.call_args[0][0])
233+
self.assertEqual(reply_a["id"], 1)
234+
self.assertEqual(ws_b.send_text.call_count, 1) # unchanged since its own reply
235+
236+
def test_a_result_with_no_matching_pending_id_falls_back_to_broadcast(self):
237+
relay, proc = self._relay_with_fake_proc([])
238+
ws = mock.Mock()
239+
relay.subscribe(ws)
240+
relay._route('{"type":"result","id":999,"result":{}}')
241+
ws.send_text.assert_called_once_with('{"type":"result","id":999,"result":{}}')
242+
192243
def test_a_dead_subscriber_is_dropped_after_a_failed_send(self):
193244
relay, proc = self._relay_with_fake_proc([])
194245
ws = mock.Mock()
@@ -205,6 +256,14 @@ def test_unsubscribe_stops_further_broadcasts(self):
205256
relay._broadcast("line")
206257
ws.send_text.assert_not_called()
207258

259+
def test_unsubscribe_drops_that_sockets_pending_requests(self):
260+
relay, proc = self._relay_with_fake_proc([])
261+
ws = mock.Mock()
262+
relay.subscribe(ws)
263+
relay.send('{"id": 1, "method": "eval"}', ws)
264+
relay.unsubscribe(ws)
265+
self.assertEqual(relay._pending, {})
266+
208267

209268
if __name__ == "__main__":
210269
unittest.main()

0 commit comments

Comments
 (0)