feat: cross-device interop — land #338/#336/#346 + #340 harness (device-validated, consolidated) - #350
Merged
Merged
Conversation
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.
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>
…' into momics-ios-foreground-restart-336
…' into momics-fix-android-serve-empty-body
…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>
…ody' into momics-interop-device-integration
…6' into momics-interop-device-integration
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>
…ody' into momics-interop-device-integration
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>
… into momics-interop-device-integration
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>
fix(discovery): remediate PR #350 transport and adapter findings
momics
marked this pull request as ready for review
July 16, 2026 19:24
This was referenced Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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:
crates/iroh-http-core/**—lifecycle.rs,http/server/{handle,accept,mod}.rs,transport/pool.rs,client.rs,addr.rs,endpoint/bind.rs,endpoint/observe.rscrates/iroh-http-discovery/**packages/iroh-http-shared/src/{serve,discovery}.ts,IrohNode.tspackages/iroh-http-tauri/src/commands.rs,guest-js/{index,lifecycle}.tsIrohHttpPlugin.swift(iOS) +IrohHttpPlugin.kt(Android)Takeaways for review:
npm run cicovers Rust + TS only), so it's the highest-risk-to-reviewer surface.Included work
serve()returned empty bodies → bufferres.bodywhen the WebView yields a falsy body; base64 body-chunk transport:0addrs, surfaceaddressTXTinvalid socket address syntaxHarness-accuracy fix found mid-pass: the mobile Test tab now honors the corpus
skipfield (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_DIAGtemporary logging that had leaked in via an out-of-order merge, and applieddeno fmtacross 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) reconcilesip_addrs()against the realbound_sockets()port and filters to the first routable IP — reusing the #346 reconciliation. Surfaced to JS asdiscoveryInfo(): { nodeId, directAddress, relayUrl }across all adapters (shared, node, deno, tauri) per the architecture rule. 6 newdialableunit tests.Harness W1 gap closed: testing mode previously advertised via the generic path with a hardcoded
port:1and no dialable address, so the Test-tab interop silently ran over relay. It now callsdiscoveryInfo()and publishes the realdirectAddressas theaddressTXT (same key/format as the native advertiser), soasIrohPeer()resolves a realip:portand 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.mjsheadless 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):
transport=direct; Android↔Desktop direct-dial passes once the macOS firewall allows the desktop dev binary inbound (relay fallback otherwise — the assertion correctly caught it). Desktop↔mobile and mobile↔mobile direct dial confirmed.Follow-up filed: #358 — remove
advertisePeer/browsePeersin favor of the genericadvertise/browse+discoveryInfoutility (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(6dialabletests).Tauri guest-js (vitest):
lifecycle.test.ts,serve-body.test.ts,android-body-encoding.test.ts,discovery.test.ts,adapter.test.ts.Shared:
asIrohPeeraddress-TXT / bare-addr-drop regression.Every device bug in this pass was reproduced as a failing test first, then fixed.
Validation
npm run ci(deno fmt, clippy-D warnings,cargo test, tauri manifest tests, guest-js vitest, cargo-deny, cargo-audit, npm audit, interop).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):
User-Agentnot preserved between mobile WebView peers (the one real remaining compliance gap; forbidden-header behavior, needs headless-peer isolation).advertisePeer/browsePeers, migrate fully to genericadvertise/browse+discoveryInfo.Closes #336
Closes #338
Closes #340
Closes #346
Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com