Skip to content

feat(nomadnet): browse pages over Model B (NE-side fetch via IPC)#99

Merged
torlando-tech merged 3 commits into
mainfrom
feat/ios-nomadnet-over-modelb
Jun 21, 2026
Merged

feat(nomadnet): browse pages over Model B (NE-side fetch via IPC)#99
torlando-tech merged 3 commits into
mainfrom
feat/ios-nomadnet-over-modelb

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

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.modelB is hardcoded on, so BackendFactory returns ProxyRnsBackend (the NE owns the lxmf node). But ProxyRnsBackend.fetchNomadNetPage was a hard .notStarted stub ("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 — .notStarted was a misleading status (transport was fine; the fetch just never happened).

How

Wire the fetch across the existing app↔NE ProxyRequest/ProxyResponse IPC 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 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: dispatches .nomadnetFetch to the NE handler.
  • ProxyRnsBackend.fetchNomadNetPage: real round-trip + result mapping.

Safety fix (required)

roundTrip gains an optional bounded deadline (default nil → 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 passes timeout + 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 (.start awaits node bring-up before replying). If the held ~30s sendProviderMessage had 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 a nomadnetwork.node → Browse Site renders the page. Model A (SwiftRNSBackend) path unchanged by the extraction.

🤖 Generated with Claude Code

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-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires NomadNet page browsing across the app↔NE IPC seam for Model B, replacing the hard .notStarted stub in ProxyRnsBackend with a real round-trip. A new shared NomadNetFetch.swift extracts the link/fetch algorithm from SwiftRNSBackend so both architectures share one implementation with no code duplication.

  • NomadNetFetch.swift (new, dual-target): one-shot RNS Link fetch shared by SwiftRNSBackend (Model A) and NEReticulumNode (Model B); extracted verbatim with matching Status raw values so callers map across by rawValue without a lookup table.
  • ProxyIPC: new .nomadnetFetch request case and ProxyNomadNetOutcome DTO follow the established .lxmfSend/ProxySendOutcome pattern; roundTrip gains an optional deadline parameter that activates sendWithDeadline — an unstructured-Task race through a ResumeOnce latch, intentionally chosen over withTaskGroup.
  • ProxyRnsBackend.fetchNomadNetPage: ipcDeadline = 2 * timeout + 30 comfortably exceeds the NE's worst-case budget, providing a 13 s backstop against a truly wedged or jetsammed extension.

Confidence Score: 5/5

Safe 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

Filename Overview
Sources/Shared/NomadNetFetch.swift New shared helper extracted verbatim from SwiftRNSBackend; correct dual-target compilation, clean abstraction with matching Status rawValues
Sources/RNSBackendProxy/ProxyRnsBackend.swift Implements real NomadNet round-trip via IPC; sendWithDeadline correctly races unstructured Tasks through ResumeOnce latch; abandoned send Task on timeout is intentional but accumulates if fetches repeatedly time out
Sources/ColumbaNetworkExtension/NEReticulumNode.swift fetchNomadNetPageForIPC correctly delegates to NomadNetFetch and maps all errors to Foundation-only ProxyNomadNetOutcome without throwing across the IPC seam
Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift Cleanly dispatches the new .nomadnetFetch case to the NE handler, mirroring the existing .lxmfSend pattern
Sources/Shared/ProxyIPC.swift New .nomadnetFetch case and ProxyNomadNetOutcome DTO correctly follow the established discriminated-union Codable pattern; CodingKeys are non-conflicting
Sources/RNSBackendSwift/SwiftRNSBackend.swift fetchNomadNetPage cleanly delegates to NomadNetFetch.fetch; Model A behavior is unchanged by the extraction
Columba.xcodeproj/project.pbxproj NomadNetFetch.swift correctly added to both app and NE extension targets (NMF1B, NMF2B) under the Shared group

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)
Loading
%%{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)
Loading

Reviews (3): Last reviewed commit: "fix(nomadnet): widen IPC deadline past t..." | Re-trigger Greptile

Comment thread Sources/RNSBackendProxy/ProxyRnsBackend.swift Outdated
Comment thread Sources/ColumbaNetworkExtension/NEReticulumNode.swift
…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

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

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>
@torlando-tech torlando-tech merged commit 6ae92e2 into main Jun 21, 2026
3 checks passed
@torlando-tech torlando-tech deleted the feat/ios-nomadnet-over-modelb branch June 21, 2026 16:59
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.

1 participant