@@ -76,6 +76,15 @@ enum DiagLog {
7676@Observable
7777@MainActor
7878public final class AppServices {
79+
80+ /// Host:port pair identifying a TCP interface's destination. Used to
81+ /// detect whether a `connectTCPInterface` call would change the
82+ /// interface's configuration or just re-apply the same one.
83+ public struct TCPEndpoint : Equatable , Hashable , Sendable {
84+ public let host : String
85+ public let port : UInt16
86+ }
87+
7988 // MARK: - Components
8089
8190 /// Local Reticulum identity for signing and encryption.
@@ -93,6 +102,15 @@ public final class AppServices {
93102 /// TCP interfaces keyed by entity ID. Multiple concurrent connections are supported.
94103 public private( set) var tcpInterfaces : [ String : TCPInterface ] = [ : ]
95104
105+ /// Last-applied host:port per TCP entity. Used by `connectTCPInterface`
106+ /// to short-circuit when the caller is re-applying an already-running
107+ /// config (e.g. `InterfaceManagementViewModel.applyChanges` loops over
108+ /// every enabled TCP entity on every toggle, so an unchanged interface
109+ /// would otherwise be torn down and recreated alongside the genuinely-
110+ /// changed one — triggering the relay to redeliver its full announce
111+ /// table per reconnect).
112+ public private( set) var tcpEndpoints : [ String : TCPEndpoint ] = [ : ]
113+
96114 /// Convenience accessor for the first TCP interface (backward compat).
97115 public var tcpInterface : TCPInterface ? { tcpInterfaces. values. first }
98116
@@ -450,7 +468,21 @@ public final class AppServices {
450468 let newInterface = try TCPInterface ( config: config)
451469 tcpInterfaces [ " tcp-server " ] = newInterface
452470 try await newTransport. addInterface ( newInterface)
471+ // Record the applied endpoint only after the interface
472+ // has been successfully attached. See the matching catch
473+ // block below for why this ordering matters.
474+ tcpEndpoints [ " tcp-server " ] = TCPEndpoint ( host: host, port: port)
453475 } catch {
476+ // Initialization is "non-fatal" with respect to TCP — the
477+ // rest of init proceeds without it, and the user can
478+ // retry via reconnectTCPOnly. But that retry routes
479+ // through connectTCPInterface, whose new idempotency
480+ // guard would silently no-op if a stale tcpEndpoints
481+ // entry survived this catch. Roll back any partial
482+ // dictionary writes so a same-address retry isn't
483+ // stuck.
484+ tcpInterfaces. removeValue ( forKey: " tcp-server " )
485+ tcpEndpoints. removeValue ( forKey: " tcp-server " )
454486 logger. warning ( " TCP interface failed (non-fatal): \( error. localizedDescription, privacy: . public) " )
455487 }
456488 }
@@ -565,7 +597,19 @@ public final class AppServices {
565597 let newInterface = try TCPInterface ( config: config)
566598 tcpInterfaces [ " tcp-server " ] = newInterface
567599 try await newTransport. addInterface ( newInterface)
600+ // Record the applied endpoint only after the interface
601+ // has been successfully attached. See the matching catch
602+ // block below — same rationale as the first overload.
603+ tcpEndpoints [ " tcp-server " ] = TCPEndpoint ( host: host, port: port)
568604 } catch {
605+ // Non-fatal: init proceeds without TCP. But roll back
606+ // any partial dictionary writes so a later
607+ // reconnectTCPOnly retry with the same address doesn't
608+ // hit a stuck idempotency guard in connectTCPInterface
609+ // and silently no-op. See the first initialize overload
610+ // for the full rationale.
611+ tcpInterfaces. removeValue ( forKey: " tcp-server " )
612+ tcpEndpoints. removeValue ( forKey: " tcp-server " )
569613 logger. warning ( " TCP interface failed (non-fatal): \( error. localizedDescription, privacy: . public) " )
570614 }
571615 }
@@ -1247,12 +1291,22 @@ public final class AppServices {
12471291 /// Connect a TCP interface by entity ID, replacing any existing one with the same ID.
12481292 ///
12491293 /// Multiple concurrent TCP interfaces are supported — each entity ID is independent.
1294+ /// Idempotent: if an interface is already running for `entityId` with the same
1295+ /// `host:port`, returns without disturbing it.
12501296 public func connectTCPInterface( entityId: String , host: String , port: UInt16 ) async throws {
1251- // Stop any existing interface with this entity ID
1297+ let endpoint = TCPEndpoint ( host: host, port: port)
1298+
1299+ // Already running with the same endpoint — leave it alone.
1300+ if tcpInterfaces [ entityId] != nil , tcpEndpoints [ entityId] == endpoint {
1301+ return
1302+ }
1303+
1304+ // Stop any existing interface with this entity ID (config changed)
12521305 if let existing = tcpInterfaces [ entityId] {
12531306 await existing. disconnect ( )
12541307 await transport? . removeInterface ( id: entityId)
12551308 tcpInterfaces. removeValue ( forKey: entityId)
1309+ tcpEndpoints. removeValue ( forKey: entityId)
12561310 }
12571311
12581312 // Ensure base stack exists
@@ -1274,7 +1328,23 @@ public final class AppServices {
12741328 )
12751329 let newInterface = try TCPInterface ( config: config)
12761330 tcpInterfaces [ entityId] = newInterface
1277- try await transport. addInterface ( newInterface)
1331+ do {
1332+ try await transport. addInterface ( newInterface)
1333+ } catch {
1334+ // addInterface failed — roll back the dictionary write so a
1335+ // retry with the same endpoint isn't silently no-op'd by the
1336+ // idempotency guard at the top of this function. Without
1337+ // this cleanup, a transient addInterface failure would leave
1338+ // a stuck entry that permanently blocks self-healing
1339+ // reconnects for this entityId until the user edits its
1340+ // host or port.
1341+ tcpInterfaces. removeValue ( forKey: entityId)
1342+ throw error
1343+ }
1344+ // Only record the applied endpoint after the interface has been
1345+ // successfully attached to the transport — see the catch block
1346+ // above for the reasoning.
1347+ tcpEndpoints [ entityId] = endpoint
12781348
12791349 if let dest = deliveryDestination {
12801350 await transport. registerDestination ( dest)
@@ -1301,6 +1371,7 @@ public final class AppServices {
13011371 await interface. disconnect ( )
13021372 await transport? . removeInterface ( id: entityId)
13031373 tcpInterfaces. removeValue ( forKey: entityId)
1374+ tcpEndpoints. removeValue ( forKey: entityId)
13041375 }
13051376
13061377 /// Stop all TCP interfaces.
@@ -1310,6 +1381,7 @@ public final class AppServices {
13101381 await transport? . removeInterface ( id: entityId)
13111382 }
13121383 tcpInterfaces. removeAll ( )
1384+ tcpEndpoints. removeAll ( )
13131385 isConnected = false
13141386 }
13151387
@@ -1395,7 +1467,20 @@ public final class AppServices {
13951467 tcpInterfaces [ " tcp-server " ] = newInterface
13961468
13971469 // Add interface to transport (connects it)
1398- try await newTransport. addInterface ( newInterface)
1470+ do {
1471+ try await newTransport. addInterface ( newInterface)
1472+ } catch {
1473+ // addInterface failed — roll back the dictionary write so a
1474+ // retry via reconnectTCPOnly with the same address isn't
1475+ // silently no-op'd by connectTCPInterface's idempotency
1476+ // guard. See connectTCPInterface's catch block for the full
1477+ // rationale.
1478+ tcpInterfaces. removeValue ( forKey: " tcp-server " )
1479+ throw error
1480+ }
1481+ // Only record the applied endpoint after the interface has been
1482+ // successfully attached to the transport.
1483+ tcpEndpoints [ " tcp-server " ] = TCPEndpoint ( host: host, port: port)
13991484
14001485 // Set transport on router and re-register delivery destination
14011486 if let router = router {
0 commit comments