@@ -112,20 +112,70 @@ public final class ProxyRnsBackend: RnsBackend, @unchecked Sendable {
112112 /// response comes back; returns the `ProxyResponse` (including `.error` /
113113 /// `.unsupported`) otherwise so callers can map those onto their own return
114114 /// shapes.
115- private func roundTrip( _ request: ProxyRequest , op: String ) async throws -> ProxyResponse {
115+ private func roundTrip( _ request: ProxyRequest , op: String , deadline : TimeInterval ? = nil ) async throws -> ProxyResponse {
116116 let wire : Data
117117 do {
118118 wire = try ProxyIPC . encodeRequest ( request)
119119 } catch {
120120 throw BackendError . ipcFailed ( operation: op)
121121 }
122- let reply = await send ( wire)
122+ // The seam imposes no timeout of its own — `send` (proxySend) awaits the
123+ // NE's completionHandler indefinitely. For long ops, bound the wait so a
124+ // dropped / wedged / jetsammed NE reply degrades to `ipcFailed` instead
125+ // of hanging forever. Default `nil` keeps every existing caller
126+ // byte-identical.
127+ let reply : Data ?
128+ if let deadline {
129+ reply = await sendWithDeadline ( wire, deadline: deadline)
130+ } else {
131+ reply = await send ( wire)
132+ }
123133 guard let response = ProxyIPC . decodeResponse ( reply) else {
124134 throw BackendError . ipcFailed ( operation: op)
125135 }
126136 return response
127137 }
128138
139+ /// Send `wire` and await the NE reply, returning `nil` once `deadline`
140+ /// elapses **even if** the underlying `sendProviderMessage` continuation is
141+ /// still suspended (e.g. the NE was jetsammed and iOS hasn't delivered the
142+ /// failure callback yet).
143+ ///
144+ /// `withTaskGroup` can't give this guarantee: it awaits ALL child tasks
145+ /// before returning, and `cancelAll()` is only cooperative — a
146+ /// non-cancellable `send` continuation would keep the group suspended past
147+ /// the deadline, defeating the whole point. So this races two unstructured
148+ /// tasks through one continuation and abandons the loser (its later
149+ /// resolution is gated to a no-op). The caller is never blocked past
150+ /// `deadline`.
151+ private func sendWithDeadline( _ wire: Data , deadline: TimeInterval ) async -> Data ? {
152+ let sendClosure = send
153+ let gate = ResumeOnce ( )
154+ return await withCheckedContinuation { ( cont: CheckedContinuation < Data ? , Never > ) in
155+ Task {
156+ let reply = await sendClosure ( wire)
157+ if gate. claim ( ) { cont. resume ( returning: reply) }
158+ }
159+ Task {
160+ try ? await Task . sleep ( nanoseconds: UInt64 ( max ( 1 , deadline) * 1_000_000_000 ) )
161+ if gate. claim ( ) { cont. resume ( returning: nil ) }
162+ }
163+ }
164+ }
165+
166+ /// One-shot latch so exactly one racer resumes the continuation (resuming a
167+ /// `CheckedContinuation` twice traps).
168+ private final class ResumeOnce : @unchecked Sendable {
169+ private let lock = NSLock ( )
170+ private var fired = false
171+ func claim( ) -> Bool {
172+ lock. lock ( ) ; defer { lock. unlock ( ) }
173+ if fired { return false }
174+ fired = true
175+ return true
176+ }
177+ }
178+
129179 // MARK: - RnsCore
130180
131181 public var localInfo : LocalInfo ? {
@@ -456,8 +506,49 @@ public final class ProxyRnsBackend: RnsBackend, @unchecked Sendable {
456506 timeout: TimeInterval ,
457507 formFields: [ String : String ] ?
458508 ) async throws -> NomadNetFetchResult {
459- // Model B: runs NE-side / not proxied yet.
460- NomadNetFetchResult ( ok: false , status: . notStarted, data: Data ( ) , contentType: " " )
509+ // Model B: the NE owns transport/identity/pathTable, so the fetch runs
510+ // NE-side via the shared `NomadNetFetch` helper and the result is
511+ // marshaled back over the seam. The NE self-bounds every step (path 15s,
512+ // link/request/response timeouts) so it always replies; we add an IPC
513+ // deadline of `timeout + slack` as a backstop against a never-arriving
514+ // reply (the seam has no timeout of its own).
515+ func fail( _ s: NomadNetFetchResult . Status ) -> NomadNetFetchResult {
516+ NomadNetFetchResult ( ok: false , status: s, data: Data ( ) , contentType: " " )
517+ }
518+ // The NE self-bounds the fetch at roughly awaitPath(15) + link(timeout)
519+ // + response(timeout + 2) ≈ 2·timeout + 17s, and it ALWAYS replies. The
520+ // app deadline must comfortably exceed that so it only fires when the NE
521+ // has truly wedged/died — never pre-empting a legitimately slow mesh-path
522+ // fetch (which would surface a false "timeout"). Slack added on top.
523+ let ipcDeadline = 2 * timeout + 30
524+ let response : ProxyResponse
525+ do {
526+ response = try await roundTrip (
527+ . nomadnetFetch( destHashHex: destHashHex, path: path, timeoutSeconds: timeout, formFields: formFields) ,
528+ op: " nomadnetFetch " ,
529+ deadline: ipcDeadline
530+ )
531+ } catch {
532+ // IPC failure / deadline hit — the user was actively waiting on this
533+ // page, so surface a timeout rather than a misleading "not started".
534+ return fail ( . timeout)
535+ }
536+ switch response {
537+ case . ok( let payload) :
538+ guard let payload,
539+ let outcome = try ? JSONDecoder ( ) . decode ( ProxyNomadNetOutcome . self, from: payload) else {
540+ return fail ( . unknown)
541+ }
542+ return NomadNetFetchResult (
543+ ok: outcome. ok,
544+ status: NomadNetFetchResult . Status ( rawValue: outcome. status) ?? . unknown,
545+ data: outcome. data,
546+ contentType: outcome. contentType
547+ )
548+ case . error, . unsupported:
549+ // NE node not running / op not handled — degrade like a stopped backend.
550+ return fail ( . notStarted)
551+ }
461552 }
462553
463554 // MARK: - RnsTelephony
0 commit comments