Skip to content

feat: cross-device interop — land #338/#336/#346 + #340 harness (device-validated, consolidated) - #350

Merged
momics merged 131 commits into
mainfrom
momics-interop-device-integration
Jul 16, 2026
Merged

feat: cross-device interop — land #338/#336/#346 + #340 harness (device-validated, consolidated)#350
momics merged 131 commits into
mainfrom
momics-interop-device-integration

Conversation

@momics

@momics momics commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Consolidated cross-device interop landing

One reviewable PR for the entire cross-device interop effort, built from the exact branch that was deployed to and validated on physical hardware (iPhone 12 Pro / iOS 26.5.2 + Nokia 7.2 / Android). "What we tested == what we ship." It folds every issue track plus the fixes and cleanups surfaced during the on-device pass.

Reviewer note (why this PR exists): the work happened across several stacked sessions/PRs and got sprawling. This PR is the single, cleaned-up, CI-green surface for you + Copilot to review. The per-issue PRs are being closed in favor of this one (they remain as the regression-first commit record).


How to review this (where the fixes actually live)

A key question for this pass was "are the fixes per-platform or platform-agnostic?" Here's the honest map:

Layer Files Platform scope
Rust core (source of truth) crates/iroh-http-core/**lifecycle.rs, http/server/{handle,accept,mod}.rs, transport/pool.rs, client.rs, addr.rs, endpoint/bind.rs, endpoint/observe.rs Agnostic — one implementation, all adapters inherit it
Discovery crates/iroh-http-discovery/** Agnostic
Shared TS packages/iroh-http-shared/src/{serve,discovery}.ts, IrohNode.ts Agnostic — used by Node/Deno/Tauri alike
Tauri adapter packages/iroh-http-tauri/src/commands.rs, guest-js/{index,lifecycle}.ts Tauri-wide (iOS + Android share it)
Native bridges IrohHttpPlugin.swift (iOS) + IrohHttpPlugin.kt (Android) Per-OS — the ONLY genuinely platform-specific code

Takeaways for review:


Included work

Issue Fix Layer On-device
#340 Cross-device interop Test-tab harness (shared compliance corpus, peer-to-peer) shared + tauri + native ✅ it's the harness this pass runs on
#338 Android WebView serve() returned empty bodies → buffer res.body when the WebView yields a falsy body; base64 body-chunk transport shared TS (agnostic) + tauri ✅ self-loopback + interop bodies
#336 iOS foreground-restart "half-live serve" (blanket-200) → serve loop is authoritative on replace and graceful stop (drain-then-close active connections); guest-js serve/wait ordering Rust core (agnostic) + tauri guest-js ✅ bg→fg holds 101/0/3; stop actually stops
#346 iOS advertised a port-less direct address → reconcile bound QUIC port, drop bare/:0 addrs, surface address TXT Rust core + discovery (agnostic) + native advertise ✅ Android→iOS 100%, no invalid socket address syntax
#334 On-device DNS-SD verification runbook + Test-tab checks tests + tauri ◑ criterion 1 validated; 2–5 tracked on #334

Harness-accuracy fix found mid-pass: the mobile Test tab now honors the corpus skip field (c.id && !c.skip) like every other runner and surfaces a skipped count, so documented known-limitation cases (header-empty-value, header-long-value, path-dot-segments) are no longer mis-counted as device failures.

Cleanups folded in (the "mess" from the multi-session pass): removed leftover IROH338_DIAG/IROH346_DIAG temporary logging that had leaked in via an out-of-order merge, and applied deno fmt across the harness/guest-js files.


Update — harness-robustness rebuild + device pass 3 (2026-07-13)

The Test-tab harness was hardened and the direct-dial path is now genuinely exercised on-device. Folded into this branch (tip 4e0603e).

New core primitive (agnostic): IrohEndpoint::dialable_direct_address() (endpoint/observe.rs) reconciles ip_addrs() against the real bound_sockets() port and filters to the first routable IP — reusing the #346 reconciliation. Surfaced to JS as discoveryInfo(): { nodeId, directAddress, relayUrl } across all adapters (shared, node, deno, tauri) per the architecture rule. 6 new dialable unit tests.

Harness W1 gap closed: testing mode previously advertised via the generic path with a hardcoded port:1 and no dialable address, so the Test-tab interop silently ran over relay. It now calls discoveryInfo() and publishes the real directAddress as the address TXT (same key/format as the native advertiser), so asIrohPeer() resolves a real ip:port and direct dial is actually tested.

Harness UX/robustness: replaced the disconnected Test-tab cards with a structured suite-runner UI (grouped: discovery / direct-dial / relay-fallback / http-compliance / serve-stop; per-test {ok, detail, latencyMs, transport?, skip?}), a shared in-memory peer registry + reusable peer-picker wired across the HTTP/Files/Peer/Sessions/Test tabs, and a cross-runtime interop suite (tests/interop/suite.mjs + run-node.mjs headless node runner) so the same corpus can drive node/deno in future.

Device pass 3 (embedded builds, both phones + desktop node, all on one LAN):

Follow-up filed: #358 — remove advertisePeer/browsePeers in favor of the generic advertise/browse + discoveryInfo utility (deferred to its own review + device pass to avoid destabilizing the just-landed #346 native DNS-SD fixes).


Tests (regression-first, device-free)

Rust core: serve_restart.rs (5 — replace, routed-status survival, multi-cycle stop/serve, stale-done-signal, stop-closes-active-connections), lifecycle_recovery.rs, endpoint.rs, http_pool.rs, ticket.rs, endpoint/observe.rs (6 dialable tests).
Tauri guest-js (vitest): lifecycle.test.ts, serve-body.test.ts, android-body-encoding.test.ts, discovery.test.ts, adapter.test.ts.
Shared: asIrohPeer address-TXT / bare-addr-drop regression.

Every device bug in this pass was reproduced as a failing test first, then fixed.

Validation

  • Local CI green on the merged tree: npm run ci (deno fmt, clippy -D warnings, cargo test, tauri manifest tests, guest-js vitest, cargo-deny, cargo-audit, npm audit, interop).
  • On device (pass 2): rebuilt both phones from this branch. Android→iOS = 101 passed / 0 failed / 3 skipped (100% of the real corpus), holding across an iOS background→foreground cycle (iOS foreground restart can leave HTTP server half-live and unreachable #336 scenario), and a stopped server now correctly refuses subsequent requests (the stop-close bug found on-device).
  • On device (pass 3, harness rebuild): direct-dial now genuinely exercised — Android↔iOS transport=direct, Android↔Desktop direct-dial confirmed (firewall-gated), iOS foreground restart can leave HTTP server half-live and unreachable #336 bg→fg + serve lifecycle re-confirmed.

Not blocking / follow-ups

Tracked under the mobile interop hardening epic → #361 (single durable tracker for the items below):

Closes #336
Closes #338
Closes #340
Closes #346

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

momics and others added 30 commits July 11, 2026 10:37
Three fixes that together let a Tauri Android node be discovered, dialed,
and served over iroh:

- DNS resolver: iroh's hickory resolver cannot read Android's system DNS
  (no /etc/resolv.conf; servers live in LinkProperties via JNI, which Tauri
  does not initialise ndk_context for). Add DiscoveryOptions.dns_nameservers
  to core; on mobile, read the active network's DNS servers natively
  (ConnectivityManager) via a new get_dns_servers plugin command and build an
  explicit iroh DnsResolver from them. Without this, relay/pkarr/DNS-discovery
  all time out and node-id fetch never connects. Requires ACCESS_NETWORK_STATE.
- Advertise real port: the Android NSD advertiser published a placeholder
  SRV port (1), so LAN peers resolved an undialable <ip>:1. Publish the
  endpoint's real UDP port (NsdManager only advertises the number).
- Build fix: correct a non-null destructuring error in the NSD resolve queue
  that broke the Android Kotlin compile.

Refs #338 (the separate Android serve() empty-body bug is not addressed here).
- Install a tracing-subscriber in the Tauri example: on Android route iroh's
  logs to logcat via paranoid-android, on desktop to stdout. Makes iroh
  connectivity/relay/discovery diagnosable with adb logcat / the dev console.
- Add a visualViewport-based handler so the Android soft keyboard pushes
  content up instead of covering focused inputs (e.g. the peer node-id field),
  which previously made pasting a key impossible. No-op where visualViewport
  is unavailable; also improves iOS.
…ment (#338, #340)

Salvage from fix/android-tauri-example for the interop test harness base:
- Android peer reachability + buildable example
- tracing -> logcat wiring, keyboard-aware viewport

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reproduces the bug described in #338. On the Android System WebView a
body-carrying `new Response("hello")` returns a falsy `res.body`, so the
shared serve pipe's `res.body ?? emptyStream()` silently dropped the body
(send_chunk never fired, only finish_body) and remote peers saw an empty
body with correct status + headers.

Drives the real `makeServe` from @momics/iroh-http-shared with a mock
adapter that records sendChunk/finishBody, simulating the WebView with a
Response whose `body` getter is null while `arrayBuffer()` still yields the
bytes. This test fails on the current code and will pass once the fix is
applied.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Seed the iOS foreground-restart recovery work (#336) with the
transport-side building blocks:

- core: ConnectionPool::evict_and_close removes and closes a cached
  QUIC connection, and fetch_request evicts on Timeout/ConnectionFailed
  so a half-live peer no longer keeps future fetches stuck on a dead
  path past the configured timeout.
- tauri: replace_endpoint_for_node_id makes a freshly bound endpoint the
  owner for its stable node id and force-closes any prior endpoint for
  the same identity (webview reload / recreate with the same key).
- tauri guest-js: withLifecycle state machine + tests as the reconnect
  policy scaffold.

Refs #336

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On the Android System WebView a body-carrying Response (e.g.
`new Response("hello")`) returns a falsy `res.body` — no usable
ReadableStream. The serve pipe used `res.body ?? emptyStream()`, which
silently dropped the body: remote peers received the correct status and
headers but zero bytes (send_chunk never fired, only finish_body).

When `res.body` is absent, buffer the bytes via `res.arrayBuffer()` into a
one-shot stream (empty for a genuine 204/304). When `res.body` is present
it is used directly, so streaming responses on desktop/iOS/Node/Deno are
never forced to fully buffer — no regression. Removes the now-unused
emptyStream() helper.

Closes #338

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…GE (#338)

Temporary, clearly-tagged diagnostics for the on-device Android pass. Logs a
single greppable `[IROH338_DIAG]` line inside the serve response-body path
recording which branch runs, `typeof res.body` / truthiness, and — in the
falsy-body (Android) branch — the `res.arrayBuffer()` byteLength.

The byteLength is read from `buffered`, which IS the body used for the real
response, so nothing extra is consumed and the response still sends. The
streaming branch (present res.body) only logs truthiness/type and never
consumes the body.

This directly rules out the issue's caveat that `res.arrayBuffer()` might
also be empty on that WebView. Revert this commit before marking PR #342
ready.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add IrohEndpoint::transport_alive(), the primitive mobile foreground
recovery needs. A registry handle can keep resolving after the transport
behind it is torn down or after iOS invalidates the socket during
suspension; keying recovery off handle existence made a half-live node
look healthy, so restarting serve() ran on a dead endpoint and remote
peers hung until a long timeout.

- core: transport_alive() = not closed AND has bound sockets.
- tauri: the `ping` command now returns real transport health instead of
  "the handle exists".
- tauri guest-js: installForegroundHealthCheck() runs the health probe on
  the foreground event and triggers recovery when the transport is dead;
  the mobile lifecycle listener is rebuilt on top of it. A false result
  or a throw both mean unhealthy.

Refs #336

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add an integration test for the close→recreate→serve recovery sequence:
a serving endpoint is force-closed and rebuilt with the same supplied
key, and a client that still holds stale pooled connection state for the
(stable) node id must reach the recreated node again — bounded by a
configured timeout via the pool's close-reason reconnect and the new
timeout eviction, never an unbounded hang.

Refs #336

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
#336)

Compose installForegroundHealthCheck with the withLifecycle state
machine to characterise the mobile reconnect policy: when the foreground
transport health probe fails, the running serve task is torn down (old
run cleaned up, its abort signal fired) and restarted, leaving no
half-running serve state or dangling lifecycle promise.

Refs #336

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Phase 1 tracer-bullet of the cross-device interop harness: an opt-in,
LAN-scoped "Testing mode" in the Tauri example app that runs the shared
HTTP compliance suite peer-to-peer against a discovered peer.

While testing mode is on, the device both serves the compliance handler
and advertises a `test=1` + `platform` intent over the existing generic
DNS-SD surface (alongside its public key), and browses for other test
peers. Pressing Run executes the suite one-way (this device = client)
against a selected peer and renders a per-case grid (pass/fail, latency,
status, error) plus a structured JSON log emitted to the console
(reaches logcat/stdout on device). Case 0 is a self/loopback baseline
(ADR-015) to isolate transport from platform.

Testing mode is off by default, shows a persistent warning banner and a
live tab indicator while on, and is force-disabled on page teardown so
it can never be left silently serving to the LAN.

To keep one source of truth for case execution, the per-case loop is
extracted from run-tauri.ts into a shared, transport-agnostic
`harness.mjs` (`runCases`) reused by the in-process Tauri runner and the
on-device Test tab. `runner.ts` gains a backward-compatible
`runCasesAgainstPeer` remote path. The Node/Deno headless runners are
unchanged and still green.

Adds a static `/hello` route and a `response-body-static-hello` case
that directly covers #338 (Android drops constant response bodies): it
passes on desktop/Node and fails when the responder is Android.

Refs #340

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add MobileAddressLookup::clear() and call it when create_endpoint
replaces an existing endpoint for the same node id (foreground recovery
/ webview reload). Addresses discovered before the previous endpoint
died can be stale after iOS suspension; clearing them lets a fresh
browse repopulate the lookup instead of leaving the dialer to try dead
paths.

Refs #336

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The salvaged Android reachability work added a get_dns_servers @command
(Kotlin) + run_mobile_plugin("get_dns_servers") call with no iOS
counterpart, breaking the ffi_contract Swift↔Kotlin parity test. iOS has
no equivalent DNS gap (iroh's default resolver reads the system config),
and the public SDK doesn't expose active resolvers, so the iOS handler
returns an empty list — which commands.rs already treats as "use iroh's
default resolver". Restores command-surface parity.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…hecks

The mobile-native DNS-SD plugin changes from #330 are compiled/run by no CI
job. This adds the tooling to verify them on real iOS/Android hardware.

Runbook (docs/internals/dns-sd-device-verification.md) maps each #334
acceptance criterion to concrete Test-tab steps and exact greppable pass/fail
log signatures, plus a device/OS results matrix ready to paste into the issue
and follow-up-issue filing guidance.

Test-tab affordances (opt-in, dev-only, torn down on teardown), all emitting a
stable IROH_DNSSD_CHECK prefix:
- browsePeers() isActive transition watch (criterion 2)
- multi-service burst advertise/browse for the Android resolve queue
  (criterion 3)
- live TXT/port mutate control for the iOS re-emit dedup (criterion 4)
- greppable port/host logging on the generic browse loop so iOS metadata-only
  (port=0, host=undefined) is directly confirmable (criterion 5)

Native additive log lines with the same prefix trace the two defect fixes:
Kotlin drainResolveQueue (dequeue/resolved/failed with queue depth) and the
Swift re-emit branch (new vs reemit with rev).

Note: iOS dedups on a TXT+addrs snapshot (port is always 0), so the re-emit
trigger must be a TXT change, not port — documented in the runbook and driven
by the mutate control.

Refs #334

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The #340 interop Test tab advertises/browses serviceName "iroh-http-test"
(-> _iroh-http-test._udp), but Info.ios.plist NSBonjourServices only listed
_iroh-http._udp and _demo-printer._tcp. iOS silently denies NWBrowser/NWListener
for undeclared service types, so the iPhone never advertised/discovered the test
service and no Local Network prompt appeared. Add the test service type.

Surfaced by the on-device interop pass (iPhone 12 Pro <-> Nokia 7.2).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Device-pass follow-up: on the Nokia 7.2 Android System WebView the first
#338 fix (falsy res.body guard) is not sufficient — res.body is truthy and
the body then throws when piped over the Tauri channel:

  {"code":"INVALID_INPUT","message":"expected raw binary body or base64 string"}

on BOTH the request (fetch) and response (serve) paths, which share the
single TauriAdapter.sendChunk. The Android WebView does not transmit a raw
Uint8Array invoke payload as InvokeBody::Raw — it arrives as JSON — so the
Rust send_chunk command rejects it.

Drives the real adapter's sendChunk through the shared pipeToWriter (the
exact code path serve and fetch use), mocking IPC to simulate each
platform's send_chunk: Android (rejects raw, accepts base64), desktop/iOS
(accepts raw). Adds a test-only _createAdapterForTesting export. These
Android cases fail on the current raw-only sendChunk and will pass once the
base64 fallback is added.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
)

On the Android System WebView a raw `Uint8Array` invoke payload is not
delivered as `InvokeBody::Raw` — it arrives as JSON, so the Rust
`send_chunk` command rejects it with INVALID_INPUT ("expected raw binary
body or base64 string"). Because both request (fetch) and response (serve)
bodies funnel through `TauriAdapter.sendChunk`, the entire body was dropped
and remote peers saw an empty body (self-loopback returned status 200 with
bodyExact "" instead of "hello").

`sendChunk` now starts on the fast raw-binary path and, the first time a
platform rejects the raw payload as non-binary, permanently switches this
adapter to base64 — the command's existing JSON compatibility path (read via
`val.as_str()`). Desktop and iOS (WKWebView) accept raw binary and never
trigger the fallback, so their wire format is unchanged. `pipeToWriter`
awaits one `sendChunk` at a time per body, so the mid-stream switch cannot
reorder chunks. Non-encoding errors are rethrown, not masked by the fallback.

Refs #338

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reproduces the bug described in #346: an iOS node advertises a direct
address whose port is 0 (the QUIC socket is really bound to a port),
so peers reject it with "invalid socket address syntax".

Covers the port-reconciliation helper, the port-0 guard in
parse_direct_addrs, the address-lookup skip (discovery + mobile), and a
node_addr() integration check. These fail on the current code and will
pass once the fix is applied.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…#346)

On iOS the local interface address is enumerated with the port stripped to
0, even though the QUIC socket is bound to a real port. The port-less
address was advertised verbatim, so peers rejected it at parse time with
"invalid socket address syntax" and iOS nodes were undialable over the LAN.

Reconcile derived direct-address ports against the real bound sockets, and
reject port-0 addresses loudly before they can reach the dialer:

- core: add reconcile_direct_addr_ports() and route node_addr() (and the
  new direct_socket_addrs()) through it, substituting the bound port for any
  port-0 candidate and dropping ones with no bound port to borrow.
- core: reject port-0 direct addresses in parse_direct_addrs and
  parse_node_addr instead of handing iroh a useless ":0".
- discovery/tauri: skip port-0 direct addresses in build_endpoint_data so a
  bad re-emit can never feed the dialer a port-0 TransportAddr::Ip.
- discovery: honour an `address` TXT (TXT_ADDRESS) carrying a dialable
  ip:port, for advertisers whose SRV port is not the QUIC port (iOS).
- tauri: mobile advertise_peer now derives a reconciled primary ip:port and
  publishes it as an `address` TXT; the iOS/Android native advertise/browse
  publish and read that TXT.

Closes #346

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… into momics-interop-device-integration

# Conflicts:
#	crates/iroh-http-core/src/endpoint/observe.rs
)

An iOS advertiser was undialable because the browsing peer surfaced a bare,
port-less A-record host (e.g. "192.168.50.227") in ServiceRecord.addrs, which
fails to parse as a socket address and poisons the whole direct-address list —
while the dialable `address` TXT entry was ignored. These asIrohPeer tests fail
against the current pass-through behaviour.

Refs #346

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rtise (#346)

The first fix reconciled port-0 direct addresses, but the on-device pass still
failed: the dialer received a BARE, port-less IP ("192.168.50.227"), not
":0" — so the port-0 guard never saw it. Three compounding defects:

- shared asIrohPeer returned ServiceRecord.addrs verbatim and ignored the
  `address` TXT. The Android generic browse resolves the mDNS A-record and
  reports the bare host IP with no port, which fails parse_direct_addrs and
  poisons the whole direct-address list — while the dialable `address` TXT
  (real bound QUIC port) was dropped on the floor.
- Android generic browse (Kotlin) put `resolved.host.hostAddress` (a bare IP)
  into record.addrs instead of a well-formed `host:port`.
- iOS advertise derived its primary address only from the endpoint's
  enumerated `ip_addrs`, which yield nothing routable at advertise time, so
  no `address` TXT was published at all.

Fixes:
- shared: asIrohPeer surfaces the `address` TXT first, then keeps only
  well-formed ip:port socket addresses (new isDialableSocketAddr), dropping
  bare/port-less/`:0` entries so they can never reach the dialer. Export
  TXT_KEY_ADDRESS + isDialableSocketAddr.
- tauri: mobile advertise now falls back to a routable local IP (OS routing
  table probe via a connected, packet-less UDP socket) combined with the real
  bound QUIC port when no reconciled routable address exists. Pure,
  unit-tested select_primary_direct_addr.
- android: generic browse emits `host:port` (bracketed IPv6), never a bare IP.
- core: add direct_addr_candidates() for diagnostics.

Also adds temporary IROH346_DIAG diagnostics (iOS advertise, Android dial
parse, Android browse) to pinpoint the on-device path in a single rebuild.

Refs #346

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On-device the advertised address carried a placeholder port ("192.168.50.227:1")
instead of the real bound QUIC port. iroh's `ip_addrs()` reports `:1` on iOS
while the real port lives in `bound_sockets()`; the reconcile only substitutes
port 0, so `:1` passed through. This selector test fails against the current
pass-through behaviour.

Refs #346

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…er (#346)

iOS enumerates `ip_addrs()` with a placeholder port (`:1`) while the real QUIC
port lives in `bound_sockets()`. The reconcile only substitutes port 0, so the
placeholder passed through and the node advertised "<ip>:1" — dialable enough
that on-LAN interop reconciled the true path via node-id/holepunch, but a
bogus port that would not survive a stricter (relay-only) path.

`select_primary_direct_addr` now pairs the routable IP with the real bound QUIC
port, using the reconciled port only as a last resort so a working
advertisement is never dropped when no bound port is available.

Closes #346

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants