feat(nomadnet): browse pages over Model B (NE-side fetch via IPC)#99
Conversation
NomadNet browsing was dead on the Swift/Model-B build: the app runs as ProxyRnsBackend (the NE owns the lxmf node), and ProxyRnsBackend.fetchNomadNetPage was a hard `.notStarted` stub — so every "Browse Site" returned "request failed: not-started" even though transport was healthy. The NE had no page-fetch handler at all. Wire the fetch across the existing app<->NE IPC seam: - NomadNetFetch.swift (new, Sources/Shared, dual-target): the one-shot RNS Link page fetch extracted verbatim from SwiftRNSBackend so BOTH the foreground Swift backend (Model A) and the NE node (Model B) share one implementation. Foundation + ReticulumSwift only, so it compiles into the extension target. - SwiftRNSBackend.fetchNomadNetPage now delegates to the shared helper (Model A behavior unchanged; 3 private statics moved out). - ProxyIPC: new `.nomadnetFetch` request case + `ProxyNomadNetOutcome` reply DTO, mirroring the `.lxmfSend` / `ProxySendOutcome` pattern. - NEReticulumNode.fetchNomadNetPageForIPC: runs the shared fetch with the NE's transport/identity/pathTable; never throws across the seam. - PacketTunnelProvider: dispatch `.nomadnetFetch` to the NE handler. - ProxyRnsBackend.fetchNomadNetPage: real round-trip + result mapping. Plus the required safety fix: roundTrip gains an optional bounded `deadline` (default nil = existing callers unchanged). The seam had NO app-side timeout, so a dropped/jetsammed NE reply would hang the continuation forever. The NomadNet call passes `timeout + 8s`; the NE self-bounds every step and always replies, with the deadline as backstop (degrades to `.timeout`). Synchronous round-trip (not async/poll) chosen because browsing is an interactive request the user blocks on, and the seam already proves long-async-after-await replies (`.start` awaits node bring-up). If on-device testing shows the held ~30s sendProviderMessage is unreliable, fall back to an async Darwin-notification design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR wires NomadNet page browsing across the app↔NE IPC seam for Model B, replacing the hard
Confidence Score: 5/5Safe to merge; IPC wiring is correct, ResumeOnce latch prevents double-resume, and NE failures degrade gracefully to .timeout on the app side. The core fetch logic is a verbatim extraction of already-working code, the IPC serialization follows established patterns, and sendWithDeadline correctly prevents the continuation from hanging indefinitely. Both flagged observations are acknowledged design trade-offs documented in inline comments with no correctness bugs introduced. No files require special attention; ProxyRnsBackend.swift has the most novel logic (sendWithDeadline + ResumeOnce) but it is well-commented and the design rationale is sound. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant UI as App UI
participant PRB as ProxyRnsBackend
participant IPC as ProxyIPC seam
participant PTP as PacketTunnelProvider
participant NERN as NEReticulumNode
participant RNS as ReticulumSwift (NE)
UI->>PRB: fetchNomadNetPage(destHashHex, path, timeout)
PRB->>PRB: "ipcDeadline = 2*timeout + 30"
PRB->>PRB: sendWithDeadline(wire, deadline)
note over PRB: Race two unstructured Tasks through ResumeOnce<br/>to guarantee caller unblocks at deadline even if<br/>sendProviderMessage callback never fires
PRB-->>IPC: .nomadnetFetch(destHashHex, path, timeoutSeconds, formFields)
IPC-->>PTP: ProxyRequest decoded
PTP->>NERN: fetchNomadNetPageForIPC(destHashHex, path, timeoutSeconds, formFields)
NERN->>RNS: NomadNetFetch.fetch(transport, identity, pathTable, destHash, path, timeout)
note over RNS: awaitPath(15s)<br/>initiateLink → awaitLinkEstablished(timeout)<br/>link.request → awaitResponse(timeout + 2s)
RNS-->>NERN: NomadNetFetch.Result
NERN-->>PTP: ProxyNomadNetOutcome (never throws)
PTP-->>IPC: .ok(JSON payload)
IPC-->>PRB: ProxyResponse.ok
PRB-->>UI: NomadNetFetchResult(.ok, data, contentType)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant UI as App UI
participant PRB as ProxyRnsBackend
participant IPC as ProxyIPC seam
participant PTP as PacketTunnelProvider
participant NERN as NEReticulumNode
participant RNS as ReticulumSwift (NE)
UI->>PRB: fetchNomadNetPage(destHashHex, path, timeout)
PRB->>PRB: "ipcDeadline = 2*timeout + 30"
PRB->>PRB: sendWithDeadline(wire, deadline)
note over PRB: Race two unstructured Tasks through ResumeOnce<br/>to guarantee caller unblocks at deadline even if<br/>sendProviderMessage callback never fires
PRB-->>IPC: .nomadnetFetch(destHashHex, path, timeoutSeconds, formFields)
IPC-->>PTP: ProxyRequest decoded
PTP->>NERN: fetchNomadNetPageForIPC(destHashHex, path, timeoutSeconds, formFields)
NERN->>RNS: NomadNetFetch.fetch(transport, identity, pathTable, destHash, path, timeout)
note over RNS: awaitPath(15s)<br/>initiateLink → awaitLinkEstablished(timeout)<br/>link.request → awaitResponse(timeout + 2s)
RNS-->>NERN: NomadNetFetch.Result
NERN-->>PTP: ProxyNomadNetOutcome (never throws)
PTP-->>IPC: .ok(JSON payload)
IPC-->>PRB: ProxyResponse.ok
PRB-->>UI: NomadNetFetchResult(.ok, data, contentType)
Reviews (3): Last reviewed commit: "fix(nomadnet): widen IPC deadline past t..." | Re-trigger Greptile |
…ile #99 iter 1) Greptile P1: withTaskGroup awaits ALL children before returning and cancelAll() is only cooperative, so a non-cancellable sendProviderMessage continuation would keep the group suspended past the deadline — defeating the safety fix in exactly the jetsam scenario it guards against. - Replace the withTaskGroup race with an unstructured two-task race through a single continuation + a one-shot ResumeOnce latch. The caller returns the moment either the send or the deadline resolves; the loser is abandoned (its later resolution is gated to a no-op), so the wait is truly bounded. - P2: the NE fetch handler's catch maps to "request-failed" (NomadNetFetch only throws from link.request; all other failures return a Result), not the vaguer "unknown". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… (greptile #99 iter 2) Greptile 4/5: the app-side deadline was timeout+8s (38s/68s for the 30s/60s calls), but the NE self-bounds a fetch at ~awaitPath(15) + link(timeout) + response(timeout+2) ≈ 2*timeout+17s (77s/137s). On a slow mesh path the app would fire a premature .timeout while the NE was still legitimately fetching and about to reply with a real page. Set the deadline to 2*timeout+30 so it comfortably exceeds the NE's self-bound — it now only fires when the NE has truly wedged/died, never pre-empting a slow-path fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
NomadNet site browsing now works on the Swift / Model B build. Previously every "Browse Site" returned "request failed: not-started" even though transport was healthy.
Why
On the Swift build,
BackendPreference.modelBis hardcoded on, soBackendFactoryreturnsProxyRnsBackend(the NE owns thelxmfnode). ButProxyRnsBackend.fetchNomadNetPagewas a hard.notStartedstub ("Model B: runs NE-side / not proxied yet") and the NE had no page-fetch handler at all. So NomadNet was simply unimplemented on this architecture —.notStartedwas a misleading status (transport was fine; the fetch just never happened).How
Wire the fetch across the existing app↔NE
ProxyRequest/ProxyResponseIPC seam, reusing the fetch logic that already works in Model A:Sources/Shared/NomadNetFetch.swift(new, dual-target): the one-shot RNS-Link page fetch extracted verbatim fromSwiftRNSBackendso both the foreground Swift backend (Model A) and the NE node (Model B) share one implementation. Foundation + ReticulumSwift only, so it compiles into the extension target.SwiftRNSBackend.fetchNomadNetPagenow delegates to the shared helper (Model A behavior unchanged; 3 private statics moved out).ProxyIPC: new.nomadnetFetchrequest case +ProxyNomadNetOutcomereply DTO, mirroring the.lxmfSend/ProxySendOutcomepattern.NEReticulumNode.fetchNomadNetPageForIPC: runs the shared fetch with the NE'stransport/identity/pathTable; never throws across the seam.PacketTunnelProvider: dispatches.nomadnetFetchto the NE handler.ProxyRnsBackend.fetchNomadNetPage: real round-trip + result mapping.Safety fix (required)
roundTripgains an optional boundeddeadline(defaultnil→ every existing caller byte-identical). The seam had no app-side timeout, so a dropped/jetsammed NE reply would hang the continuation forever. The NomadNet call passestimeout + 8s; the NE self-bounds every step (path 15s, link/request/response timeouts) and always replies, with the deadline as a backstop (degrades to.timeout).Design note
Synchronous round-trip chosen over async/poll: browsing is an interactive request the user blocks on, and the seam already proves long-async-after-await replies (
.startawaits node bring-up before replying). If the held ~30ssendProviderMessagehad proven unreliable on-device, the fallback was an async Darwin-notification design — but on-device testing shows it works.Verification
Builds clean for both targets (
Columba-Swift, app + NE extension). Verified on a physical device: Background Transport up → Contacts → Network Announces → tap anomadnetwork.node→ Browse Site renders the page. Model A (SwiftRNSBackend) path unchanged by the extraction.🤖 Generated with Claude Code