Skip to content

Commit 7e82132

Browse files
Merge pull request #61 from torlando-tech/fix/tcp-interface-hot-swap
fix: hot-swap TCP interfaces without disturbing the others
2 parents 9ce7fe2 + 2e4cda2 commit 7e82132

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

Sources/ColumbaApp/Services/AppServices.swift

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ enum DiagLog {
7676
@Observable
7777
@MainActor
7878
public 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 {

Sources/ColumbaApp/ViewModels/InterfaceManagementViewModel.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,20 @@ public final class InterfaceManagementViewModel {
298298
let enabledTCPs = enabledInterfaces.filter { $0.type == .tcpClient }
299299
let enabledTCPIds = Set(enabledTCPs.map { $0.id })
300300

301-
// Connect/reconnect each enabled TCP interface
301+
// Connect/reconnect each enabled TCP interface, skipping ones that
302+
// are already running with the same host:port. Without the skip,
303+
// toggling or editing any single interface caused this loop to
304+
// tear down every other healthy TCP connection alongside the one
305+
// the user actually changed — and reconnecting prompted the relay
306+
// to redeliver its full announce table per interface, swamping
307+
// the app for ~90s per change.
302308
for tcpIf in enabledTCPs {
303309
if case .tcpClient(let config) = tcpIf.config {
310+
let desired = AppServices.TCPEndpoint(host: config.targetHost, port: config.targetPort)
311+
if appServices.tcpInterfaces[tcpIf.id] != nil,
312+
appServices.tcpEndpoints[tcpIf.id] == desired {
313+
continue
314+
}
304315
logger.info("Applying TCP[\(tcpIf.id)]: \(config.targetHost):\(config.targetPort)")
305316
interfaceStatus[tcpIf.id] = .connecting
306317
do {

0 commit comments

Comments
 (0)