Skip to content

feat(bridge): add Python bridge runtime (hub, interface, host runner) - #3696

Open
ryanmelt wants to merge 4 commits into
pr2-bridge-modelsfrom
pr3-bridge-runtime
Open

feat(bridge): add Python bridge runtime (hub, interface, host runner)#3696
ryanmelt wants to merge 4 commits into
pr2-bridge-modelsfrom
pr3-bridge-runtime

Conversation

@ryanmelt

Copy link
Copy Markdown
Member

Stacked PR 3 of 5 — base: pr2-bridge-models (#3695). Part of the #3688 split. Needs PR 2.

The runtime "meat" of the bridge:

  • bridge_microservice.py — the Iroh hub (~900 lines)
  • bridge_interface.py — the COSMOS-side interface
  • host_interface_microservice.py — the host runner
  • cmd_response_protocol.py
  • compose.yaml UDP port range (7799–7814) + compose.override.yaml relay docs

Notes

  • No tests here yet — worth adding before merge.
  • If reviewers want it smaller, it can be split hub (bridge_microservice.py) vs client (bridge_interface.py + host_interface_microservice.py).

Review after #3695.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.66%. Comparing base (c364862) to head (d8a74a0).

Additional details and impacted files
@@                  Coverage Diff                  @@
##           pr2-bridge-models    #3696      +/-   ##
=====================================================
- Coverage              79.30%   78.66%   -0.64%     
=====================================================
  Files                    885      890       +5     
  Lines                  65367    66343     +976     
  Branches                2537     2591      +54     
=====================================================
+ Hits                   51838    52192     +354     
- Misses                 12856    13482     +626     
+ Partials                 673      669       -4     
Flag Coverage Δ
frontend 63.72% <ø> (+0.12%) ⬆️
python 79.60% <ø> (-1.93%) ⬇️
ruby-api 82.13% <ø> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ryanmelt and others added 2 commits August 10, 2026 13:50
When a host interface fails to open its device (e.g. a missing USB HID
device: "HIDAPI Device ... Not Found"), the host parks without ever
reporting CONNECTED, so the CONNECTED-gated detection never fired and the
COSMOS bridge_interface stayed connected. The host now reports its `desired`
state (pushed immediately on change), and bridge_interface returns nil on a
desired True->False transition, so COSMOS disconnects and reconnects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iroh's IrohError keeps its detail behind .message() (str()/traceback show only
the class name), so bridge crash/error logs were opaque. Add _iroh_error_detail
and route all iroh-error logs in the hub, COSMOS interface, and host runner
through it.

Also downgrade the hub's inbound accept/negotiation failures from warn to debug:
non-iroh QUIC clients probing the open UDP port fail ALPN/TLS negotiation
(NoApplicationProtocol / WebPKI UnknownIssuer) and spammed the log. Legit peers
pin the node key via the ticket and never hit this; unauthorized peers that do
negotiate still log at warn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread openc3/python/openc3/interfaces/bridge_interface.py Fixed
Comment thread openc3/python/openc3/interfaces/bridge_interface.py Fixed
Comment thread openc3/python/openc3/microservices/bridge_microservice.py Fixed
Comment thread openc3/python/openc3/microservices/bridge_microservice.py Fixed
Comment thread openc3/python/openc3/microservices/host_interface_microservice.py Fixed
Comment thread openc3/python/openc3/microservices/host_interface_microservice.py Fixed
Comment thread openc3/python/openc3/microservices/host_interface_microservice.py Fixed
Comment thread openc3/python/openc3/interfaces/bridge_interface.py Fixed
Comment thread openc3/python/openc3/microservices/bridge_microservice.py Fixed
Comment thread openc3/python/openc3/microservices/host_interface_microservice.py Fixed

@mcosgriff mcosgriff left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probable good to get tests in there

if channel.startswith(CTRL_CHANNEL_PREFIX):
ctrl_name = channel[len(CTRL_CHANNEL_PREFIX) :].decode("utf-8", "replace")

partner = self._waiting.pop(channel, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rendezvous pairs any two legs, no side check, no liveness check

_waiting keyed only by (both stream/NAME and host/NAME map to b"NAME"). is_host stored at line 576 but never used for validation — only for ctrl tap direction. Three failures fall out:

  • Parked entry never removed when its connection dies. COSMOS _establish_data times out at 30s and calls _close_data(), but hub's parked task blocks in wait_for(future, 300) — nothing watches conn.closed(). Entry rots in _waiting up to 300s.
  • COSMOS reconnect (~5s InterfaceMicroservice retry) pops that dead COSMOS entry and pairs COSMOS↔COSMOS. Pump instantly EOFs, so the fresh leg never parks to wait for the real host. Steady-state flapping when host is down.
  • Line 576 overwrites an existing _waiting[channel] without closing it — orphaned connection + task blocked for PAIR_TIMEOUT.
  • except asyncio.TimeoutError (579) doesn't catch CancelledError, so shutdown leaves entries too. Pop in finally.

Fix: key on channel, require partner.is_host != is_host, pop in finally, and race the parked wait_for against conn.closed().

Comment on lines +164 to +170
if not self._started:
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
asyncio.run_coroutine_threadsafe(self._startup(), self._loop).result(self.connect_timeout)
self._ctrl_task = asyncio.run_coroutine_threadsafe(self._start_control(), self._loop).result(5)
self._started = True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thread + event-loop leak per failed connect

if not self._started:
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True); self._thread.start()
asyncio.run_coroutine_threadsafe(self._startup(), self._loop).result(self.connect_timeout)
...
self._started = True
_startup raises (iroh bind failure, Redis error in BridgeInterfaceModel.create) or .result() times out ⇒ _started stays False but thread already spawned running run_forever. Next retry builds a new loop+thread and orphans the old. InterfaceMicroservice retries forever ⇒ unbounded thread growth. Tear down loop/thread in an except before re-raising.

# reconnect logic retries. The +5 lets the inner handshake timeout fire
# (and clean up the tunnel) before this outer wait gives up.
self._want_connected = True
self._send_command("connect")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vs :214 — _host_attempt_seen reset happens after the connect command

connect() sends "connect" (177), then _establish_data clears _host_attempt_seen = False (214). Host's desired=True status can land in between and get erased. Host then parks (device open fails) ⇒ desired=False arrives with _host_attempt_seen False ⇒ detection never fires, and subsequent heartbeats stay False so it never fires later either. That's the exact case commit 9f6b23c added. Only the data-leg EOF saves it — the path that commit says isn't reliable. Move the reset above _send_command("connect").

Comment on lines +379 to +381
self.streams = streams
with contextlib.suppress(Exception):
endpoint.set_alpns(self._build_alpns())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

failed set_alpns becomes permanent

self.streams = streams
with contextlib.suppress(Exception):
    endpoint.set_alpns(self._build_alpns())

State committed before the call. If set_alpns throws, next iteration sees streams == self.streams and returns early forever — new bridged interfaces silently unreachable until hub restart, no log. Assign self.streams only after success, and log the exception.

Comment on lines +902 to +930
def _ensure_port(self, model):
"""Return this bridge's fixed UDP port, assigning one from the published
range if it doesn't have one yet. The port is persisted on the model and
reused across restarts so the host can always reach the hub at
127.0.0.1:<port>. Ports must be unique across every bridge sharing this
operator container, so the lowest port not claimed by any other bridge
(in any scope) is chosen and persisted immediately."""
if getattr(model, "port", None):
return int(model.port)

used = set()
for scope in ScopeModel.names():
for name in BridgeModel.names(scope):
if scope == self.scope and name == self.bridge_name:
continue
other = BridgeModel.get_model(name, scope=scope)
if other and getattr(other, "port", None):
used.add(int(other.port))

for candidate in range(BRIDGE_PORT_BASE, BRIDGE_PORT_BASE + BRIDGE_PORT_COUNT):
if candidate not in used:
model.port = candidate
model.create(force=True)
return candidate

raise RuntimeError(
f"No free bridge port in {BRIDGE_PORT_BASE}-{BRIDGE_PORT_BASE + BRIDGE_PORT_COUNT - 1}; "
"increase OPENC3_BRIDGE_PORT_COUNT and the published range in compose.yaml"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ensure_port TOCTOU

Two hubs starting concurrently both scan used, both pick the lowest free port, both persist it, second Endpoint.bind fails. No lock, no retry-on-bind-failure. Bind first then persist, or retry the next candidate on AddrInUse.

Comment on lines +458 to +470
async def _read_request(self, recv):
"""Read a full request from a bi-stream until the peer finishes writing.
A single `read()` can return only a partial payload (especially over a
relay, where data arrives in smaller chunks), so loop to EOF to avoid
truncated/undecodable JSON."""
data = b""
with contextlib.suppress(Exception):
while not self.cancel_thread:
chunk = await recv.read(PUMP_CHUNK_BYTES)
if not chunk:
break
data += bytes(chunk)
return data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_read_request has no timeout

Loops to EOF with only cancel_thread as an exit. api/enroll is deliberately pre-auth (bootstrap), so any peer that can negotiate it holds a handler task and connection open indefinitely. Wrap in asyncio.wait_for and cap len(data).

@sonarqubecloud

Copy link
Copy Markdown

return f"{type(error).__name__}: {detail}"
except Exception:
# Error-detail extraction is best effort; use str(error) below.
detail = None
return f"{type(error).__name__}: {detail}"
except Exception:
# Error-detail extraction is best effort; use str(error) below.
detail = None
return f"{type(error).__name__}: {detail}"
except Exception:
# Error-detail extraction is best effort; use str(error) below.
detail = None

# 4. Now connect the device and start pumping raw bytes.
interface = self.build_interface()
interface.connect()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interface.connect() executes arbitrary device/plugin code synchronously on the event loop responsible for control commands, status updates, and shutdown. A blocking serial/TCP/device open prevents disconnects and shutdown indefinitely; interface.disconnect() at line 434 has the same issue. Run both in an executor/thread with an appropriate timeout.

if self.interface is not None:
interface_name = self.interface.name
self.handle_error(f"{interface_name}: Timeout waiting for response")
break

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good bug fix for infinite loop when a command response times out and raise_exceptions=False

BridgeInterfaceModel(name=self.name, scope=self._scope, public_key=public_key).create(force=True)
self._endpoint = await iroh.Endpoint.bind(
iroh.EndpointOptions(preset=iroh.preset_n0(), secret_key=bytes.fromhex(self._secret_key_hex))
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When OPENC3_BRIDGE_RELAY is set to a custom or regional relay, this endpoint still uses preset_n0(), unlike the hub and host runtime. Iroh only uses relays configured on the endpoint, so remote bridges using that relay can fail. Mirror the relay-mode branch used by the other endpoints; see the Iroh relay configuration guidance: https://docs.iroh.computer/about/faq

See the implementation in host_interface_microservice.py 235-243

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants