From b10a32b642ae0426f8dc5bfe16953d268c9ee433 Mon Sep 17 00:00:00 2001 From: defia Date: Fri, 11 Sep 2026 15:17:44 +0800 Subject: [PATCH] fix(web-remote): recover unavailable listen interfaces --- Glint/Chrome/SettingsView.swift | 19 +- Glint/Resources/Localizable.xcstrings | 15 +- Glint/WebRemote/WebRemoteServer.swift | 142 ++++++---- Glint/Workspace/WorkspaceStore.swift | 4 +- .../WebRemoteServerIntegrationTests.swift | 243 ++++++++++++++++-- 5 files changed, 350 insertions(+), 73 deletions(-) diff --git a/Glint/Chrome/SettingsView.swift b/Glint/Chrome/SettingsView.swift index cc80c9b4..6d0f4f8c 100644 --- a/Glint/Chrome/SettingsView.swift +++ b/Glint/Chrome/SettingsView.swift @@ -1147,11 +1147,20 @@ private struct TerminalPane: View { ForEach(store.webRemoteInterfaceOptions) { iface in Text(verbatim: "\(iface.name) (\(iface.address))").tag(iface.name) } + if store.webRemoteListenInterface != WebRemoteListenTarget.loopback, + store.webRemoteListenInterface != WebRemoteListenTarget.any, + !store.webRemoteInterfaceOptions.contains(where: { $0.name == store.webRemoteListenInterface }) { + Text(verbatim: store.webRemoteListenInterface) + .tag(store.webRemoteListenInterface) + } Text("All interfaces (less secure)").tag(WebRemoteListenTarget.any) } .pickerStyle(.menu) .labelsHidden() - Button(action: { store.refreshWebRemoteInterfaces() }) { + Button(action: { + store.refreshWebRemoteInterfaces() + WebRemoteServer.shared.refreshListenAddress() + }) { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) @@ -1238,6 +1247,7 @@ private struct TerminalPane: View { Text("Connected browsers will be disconnected. A new key and ports will be generated, so old links will stop working.") } .onAppear { + store.refreshWebRemoteInterfaces() presentWebRemoteAlert(for: store.webRemoteStatus) } .onChange(of: store.webRemoteStatus) { _, status in @@ -1286,6 +1296,11 @@ private struct TerminalPane: View { return String(localized: "Off — no network ports are bound.") case .starting: return String(localized: "Starting the local web server…") + case let .waitingForInterface(name): + return String( + format: String(localized: "Waiting for %@ to become available. Reconnects automatically."), + name + ) case .ready: return String(localized: "Ready — copy a session link to another browser.") case let .portConflict(port): @@ -1336,7 +1351,7 @@ private struct TerminalPane: View { if let iface = store.webRemoteInterfaceOptions.first(where: { $0.name == store.webRemoteListenInterface }) { return iface.address } - return String(localized: "Selected interface is no longer available. Pick another or use All interfaces.") + return String(localized: "Selected interface is temporarily unavailable. Reconnects automatically when it returns, or choose another interface.") } } diff --git a/Glint/Resources/Localizable.xcstrings b/Glint/Resources/Localizable.xcstrings index af81f56d..6212cc88 100644 --- a/Glint/Resources/Localizable.xcstrings +++ b/Glint/Resources/Localizable.xcstrings @@ -4457,13 +4457,24 @@ } } }, - "Selected interface is no longer available. Pick another or use All interfaces.": { + "Waiting for %@ to become available. Reconnects automatically.": { "extractionState": "manual", "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "所选网卡已不可用。请另选一个或使用“全部网卡”。" + "value": "正在等待 %@ 恢复可用,恢复后会自动重连。" + } + } + } + }, + "Selected interface is temporarily unavailable. Reconnects automatically when it returns, or choose another interface.": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所选网卡暂时不可用,恢复后会自动重连,也可选择其他网卡。" } } } diff --git a/Glint/WebRemote/WebRemoteServer.swift b/Glint/WebRemote/WebRemoteServer.swift index d0300cb9..bd2e57f3 100644 --- a/Glint/WebRemote/WebRemoteServer.swift +++ b/Glint/WebRemote/WebRemoteServer.swift @@ -8,6 +8,7 @@ private let webRemoteLogger = Logger(subsystem: "app.glint", category: "WebRemot enum WebRemoteStatus: Equatable { case stopped case starting + case waitingForInterface(name: String) case ready(urls: [String]) case portConflict(port: UInt16) case failed(message: String) @@ -237,17 +238,30 @@ final class WebRemoteServer: @unchecked Sendable { /// Raw 32-byte key material decoded from `token`; empty until the server /// starts with a valid token. Drives the challenge-response handshake. private var tokenKey = Data() - private var ports = WebRemotePortStore.loadOrCreate() + private let defaults: UserDefaults + private let resolveBindAddress: (String) -> String? + private let interfaceRetryInterval: TimeInterval + private var ports: WebRemotePorts private var listenInterface = WebRemoteListenTarget.loopback private var lastBoundAddress: String? private var pathMonitor: NWPathMonitor? private var pathRestartScheduled = false + private var interfaceRetryTimer: DispatchSourceTimer? private var terminalSizeRevision: UInt64 = 0 private var assetCache: [String: Data] = [:] private var statusHandler: ((WebRemoteStatus) -> Void)? private var secretStorage: WebRemoteSecretStorage = WebRemoteKeychainStorage.shared - private init() {} + init( + defaults: UserDefaults = .standard, + interfaceRetryInterval: TimeInterval = 5, + resolveBindAddress: @escaping (String) -> String? = WebRemoteListenTarget.bindAddress + ) { + self.defaults = defaults + self.interfaceRetryInterval = interfaceRetryInterval + self.resolveBindAddress = resolveBindAddress + ports = WebRemotePortStore.loadOrCreate(defaults: defaults) + } /// Swap the access-token backing store. Staged like `setListenInterface`: /// it takes effect on the next `start()`. @@ -297,6 +311,13 @@ final class WebRemoteServer: @unchecked Sendable { } } + func refreshListenAddress() { + queue.async { [weak self] in + guard let self, let runID = self.runID else { return } + self.handlePathUpdate(runID: runID) + } + } + func refreshAppearance() { queue.async { [weak self] in guard let self else { return } @@ -380,19 +401,17 @@ final class WebRemoteServer: @unchecked Sendable { emit(.starting) token = WebRemoteAccessKeyStore.loadOrCreate(storage: secretStorage) tokenKey = WebRemoteCrypto.tokenKey(from: token) ?? Data() - ports = WebRemotePortStore.loadOrCreate() + ports = WebRemotePortStore.loadOrCreate(defaults: defaults) let currentRun = UUID() runID = currentRun do { let isExplicitWildcard = listenInterface == WebRemoteListenTarget.any - let bindAddress = WebRemoteListenTarget.bindAddress(for: listenInterface) - // A user-chosen NIC that currently has no IPv4 must fail loudly. - // bindAddress returns nil both for "explicit All interfaces" and - // for "selected NIC vanished"; conflating them would silently widen - // exposure to 0.0.0.0 — the opposite of picking a NIC. + let bindAddress = resolveBindAddress(listenInterface) + // A selected NIC may temporarily lose IPv4 during DHCP or wake. + // Keep waiting for that NIC; nil must never become a wildcard bind. guard isExplicitWildcard || bindAddress != nil else { - failLocked("Selected interface is no longer available.") + waitForInterfaceLocked() return } lastBoundAddress = bindAddress @@ -439,22 +458,66 @@ final class WebRemoteServer: @unchecked Sendable { webSocket.start(queue: queue) startPathMonitorLocked(currentRun) } catch { - failLocked(error.localizedDescription) + if shouldWaitForInterface(after: error) { + waitForInterfaceLocked() + } else { + failLocked(error.localizedDescription) + } + } + } + + private var usesNamedInterface: Bool { + listenInterface != WebRemoteListenTarget.loopback + && listenInterface != WebRemoteListenTarget.any + } + + private func shouldWaitForInterface(after error: Error) -> Bool { + guard usesNamedInterface else { return false } + if case let .posix(code) = error as? NWError { + if code == .EADDRINUSE { return false } + if code == .EADDRNOTAVAIL || code == .ENETDOWN || code == .ENETUNREACH { return true } + } + return resolveBindAddress(listenInterface) == nil + } + + private func waitForInterfaceLocked() { + stopLocked(emitStatus: false) + let currentRun = UUID() + runID = currentRun + // Remember this snapshot so the monitor's initial callback does not + // repeatedly retry an address that still cannot be bound. + lastBoundAddress = resolveBindAddress(listenInterface) + emit(.waitingForInterface(name: listenInterface)) + startPathMonitorLocked(currentRun) + + // Path updates need not accompany every IPv4 assignment. Poll only + // while waiting, with a modest cadence and leeway; ready/stopped + // servers have no retry timer. + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule( + deadline: .now() + interfaceRetryInterval, + repeating: interfaceRetryInterval, + leeway: .milliseconds(250) + ) + timer.setEventHandler { [weak self] in + guard let self, self.runID == currentRun, + self.resolveBindAddress(self.listenInterface) != nil + else { return } + self.startLocked() } + interfaceRetryTimer = timer + timer.resume() } /// Watch for the bound NIC's IPv4 changing while the server runs (DHCP /// renewal, Wi-Fi switch, sleep/wake). loopback and explicit-wildcard are /// address-stable / address-agnostic and skip the monitor. On a real /// change we coalesce into a single rebind (stop+start re-resolves the - /// current IPv4 and refreshes the access URLs); a vanished NIC rebinds and - /// then fails loudly via the guard in `startLocked`. + /// current IPv4 and refreshes the access URLs). Keep watching while a NIC + /// is unavailable so its return can recover without toggling the server. private func startPathMonitorLocked(_ runID: UUID) { pathMonitor?.cancel() - guard listenInterface != WebRemoteListenTarget.loopback, - listenInterface != WebRemoteListenTarget.any, - lastBoundAddress != nil - else { + guard usesNamedInterface else { pathMonitor = nil return } @@ -467,16 +530,18 @@ final class WebRemoteServer: @unchecked Sendable { } private func handlePathUpdate(runID: UUID) { - guard runID == self.runID, !pathRestartScheduled else { return } - let current = WebRemoteAddressResolver.currentIPv4(forInterface: listenInterface) + guard runID == self.runID, usesNamedInterface, !pathRestartScheduled else { return } + let current = resolveBindAddress(listenInterface) guard current != lastBoundAddress else { return } // NWPathMonitor can fire a burst of updates on a topology change; wait // for things to settle, then rebind once. pathRestartScheduled = true queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self, self.pathRestartScheduled else { return } + guard let self, self.runID == runID, self.pathRestartScheduled else { return } self.pathRestartScheduled = false - guard self.runID == runID else { return } + // The address may already have returned during the debounce. + // Preserve healthy listeners and their browser sessions in that case. + guard self.resolveBindAddress(self.listenInterface) != self.lastBoundAddress else { return } webRemoteLogger.info("Web remote interface address changed; rebinding") self.startLocked() } @@ -496,9 +561,15 @@ final class WebRemoteServer: @unchecked Sendable { let port = kind == .http ? self.ports.http : self.ports.webSocket if case .posix(.EADDRINUSE) = error { self.failLocked(status: .portConflict(port: port)) + } else if self.shouldWaitForInterface(after: error) { + self.waitForInterfaceLocked() } else { self.failLocked(error.localizedDescription) } + case let .waiting(error): + if self.shouldWaitForInterface(after: error) { + self.waitForInterfaceLocked() + } default: break } @@ -507,7 +578,7 @@ final class WebRemoteServer: @unchecked Sendable { private func resetCredentialsLocked() { WebRemoteAccessKeyStore.reset(storage: secretStorage) - WebRemotePortStore.reset() + WebRemotePortStore.reset(defaults: defaults) startLocked() } @@ -520,11 +591,9 @@ final class WebRemoteServer: @unchecked Sendable { let addresses = WebRemoteAddressResolver.localIPv4Addresses() hosts = addresses.isEmpty ? ["127.0.0.1"] : addresses default: - // Named interface: show its current IPv4. If the interface has gone - // away, fall back to loopback for display — the bind itself will - // fail and surface `.failed`. - let address = WebRemoteAddressResolver.currentIPv4(forInterface: listenInterface) - hosts = [address ?? "127.0.0.1"] + // Advertise the address actually bound, not a newer resolver + // snapshot that the listeners have not moved to yet. + hosts = lastBoundAddress.map { [$0] } ?? [] } return hosts.map { "http://\($0):\(ports.http)/#token=\(token)" } } @@ -545,6 +614,8 @@ final class WebRemoteServer: @unchecked Sendable { pathMonitor?.cancel() pathMonitor = nil pathRestartScheduled = false + interfaceRetryTimer?.cancel() + interfaceRetryTimer = nil lastBoundAddress = nil updateSubscribedPanesLocked() releaseTerminalSizes(controlledPanes) @@ -559,24 +630,7 @@ final class WebRemoteServer: @unchecked Sendable { } private func failLocked(status: WebRemoteStatus) { - let controlledPanes = controlledPanesLocked() - runID = nil - httpListener?.cancel() - webSocketListener?.cancel() - httpListener = nil - webSocketListener = nil - clients.values.forEach { $0.cancel() } - clients.removeAll() - authThrottle.removeAll() - readyListeners.removeAll() - token = "" - tokenKey = Data() - pathMonitor?.cancel() - pathMonitor = nil - pathRestartScheduled = false - lastBoundAddress = nil - updateSubscribedPanesLocked() - releaseTerminalSizes(controlledPanes) + stopLocked(emitStatus: false) webRemoteLogger.error("Web remote failed: \(String(describing: status), privacy: .public)") emit(status) } diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index 9caa338f..99b5b4ee 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -1174,8 +1174,7 @@ final class WorkspaceStore: ObservableObject { } /// Bind targets currently available on this Mac, for the "Listen on" menu. - /// Snapshotted at init; call `refreshWebRemoteInterfaces()` to rescan after - /// networks change (e.g. joining a different Wi-Fi). + /// Refreshed on server status changes and when opening network settings. @Published private(set) var webRemoteInterfaceOptions: [WebRemoteInterface] = WebRemoteAddressResolver.interfaces() func refreshWebRemoteInterfaces() { @@ -1833,6 +1832,7 @@ final class WorkspaceStore: ObservableObject { else { ControlBridge.shared.reapStale() } WebRemoteServer.shared.setStatusHandler { [weak self] status in self?.webRemoteStatus = status + self?.refreshWebRemoteInterfaces() } WebRemoteServer.shared.setListenInterface(webRemoteListenInterface) if webRemoteEnabled { WebRemoteServer.shared.start() } diff --git a/GlintTests/WebRemoteServerIntegrationTests.swift b/GlintTests/WebRemoteServerIntegrationTests.swift index e4c0dbf7..3c568ac8 100644 --- a/GlintTests/WebRemoteServerIntegrationTests.swift +++ b/GlintTests/WebRemoteServerIntegrationTests.swift @@ -3,17 +3,18 @@ import Network import XCTest @testable import Glint -/// End-to-end tests that drive `WebRemoteServer.shared` over real sockets on the +/// End-to-end tests that drive `WebRemoteServer` over real sockets on the /// loopback interface. They cover the HTTP asset layer (including the allowlist /// and HEAD handling) and the WebSocket authentication flow (including the /// exponential backoff that throttles online token guessing). /// -/// These tests bind the project's persisted HTTP/WebSocket port pair on -/// 127.0.0.1, so they will conflict with a concurrently running Glint whose web -/// remote is enabled. The access token is served from an in-memory store, so -/// the run never reads or writes the user's Keychain. The server is started fresh in `setUp` and stopped in -/// `tearDown`; test methods run serially within the class. +/// Each test uses a private defaults domain and an available loopback port pair, +/// plus in-memory credentials, leaving running Glint sessions and Keychain alone. final class WebRemoteServerIntegrationTests: XCTestCase { + private var server: WebRemoteServer! + private var testDefaults: UserDefaults! + private var defaultsName: String! + private let interfaceAddress = TestInterfaceAddress() private var urlSession: URLSession! private var readyToken: String? private var httpOrigin: String? @@ -21,13 +22,20 @@ final class WebRemoteServerIntegrationTests: XCTestCase { override func setUp() async throws { try await super.setUp() + defaultsName = "app.glint.WebRemoteTests.\(UUID().uuidString)" + testDefaults = try XCTUnwrap(UserDefaults(suiteName: defaultsName)) + testDefaults.set(Int(try availableHTTPPort()), forKey: "glint.webRemoteHTTPPort") + interfaceAddress.set(nil) + server = WebRemoteServer(defaults: testDefaults, interfaceRetryInterval: 0.2) { [interfaceAddress] key in + key == "test-interface" ? interfaceAddress.get() : WebRemoteListenTarget.bindAddress(for: key) + } urlSession = URLSession(configuration: .ephemeral) readyToken = nil httpOrigin = nil webSocketURL = nil let ready = expectation(description: "WebRemoteServer reports .ready") - WebRemoteServer.shared.setStatusHandler { [weak self] status in + server.setStatusHandler { [weak self] status in guard case let .ready(urls) = status, let url = urls.first, let token = WebRemoteAccessURL.token(from: url), @@ -41,12 +49,12 @@ final class WebRemoteServerIntegrationTests: XCTestCase { ready.fulfill() } // Bind to loopback only — never touch a real NIC from tests. - WebRemoteServer.shared.setListenInterface(WebRemoteListenTarget.loopback) + server.setListenInterface(WebRemoteListenTarget.loopback) // Keep the access token in memory. Against the real Keychain this // prompts for the login password (the test binary is not the signed // identity the item's ACL trusts) and `start()` then times out. - WebRemoteServer.shared.setSecretStorage(WebRemoteEphemeralSecretStorage()) - WebRemoteServer.shared.start() + server.setSecretStorage(WebRemoteEphemeralSecretStorage()) + server.start() try await fulfillment(of: [ready], timeout: 10) XCTAssertNotNil(readyToken, "Server should expose an access token in its ready URL") XCTAssertNotNil(httpOrigin) @@ -54,11 +62,15 @@ final class WebRemoteServerIntegrationTests: XCTestCase { } override func tearDown() async throws { - WebRemoteServer.shared.stop() + server.setStatusHandler { _ in } + server.stop() // stop() is asynchronous on the server queue; let NWListener cancel. try? await Task.sleep(nanoseconds: 400_000_000) urlSession.invalidateAndCancel() urlSession = nil + server = nil + testDefaults.removePersistentDomain(forName: defaultsName) + testDefaults = nil try await super.tearDown() } @@ -309,24 +321,192 @@ final class WebRemoteServerIntegrationTests: XCTestCase { ) } - func testVanishedSelectedInterfaceFailsLoudly() async throws { - // A user-chosen NIC that no longer exists must surface `.failed` — never - // silently fall back to a 0.0.0.0 wildcard bind (that would widen - // exposure, the opposite of picking a NIC). - WebRemoteServer.shared.stop() - try? await Task.sleep(nanoseconds: 400_000_000) + func testVanishedSelectedInterfaceWaitsWithoutWildcardBind() async throws { + let waiting = expectation(description: "vanished interface reports waiting") + let listening = expectation(description: "missing NIC must not fall back to wildcard") + listening.isInverted = true + server.setStatusHandler { status in + if case .waitingForInterface(name: "glint-definitely-not-an-interface") = status { + waiting.fulfill() + } + if case .ready = status { listening.fulfill() } + if case .failed = status { XCTFail("A missing NIC should wait for recovery") } + } + server.setListenInterface("glint-definitely-not-an-interface") + server.start() + await fulfillment(of: [waiting], timeout: 3) + await fulfillment(of: [listening], timeout: 0.7) + do { + _ = try await request("/") + XCTFail("No HTTP listener should be bound while the selected NIC is missing") + } catch is URLError { } + } + + func testUnavailableInterfaceRecoversWhenAddressReturns() async throws { + try await assertUnavailableInterfaceRecovers(sendPathUpdate: true) + } + + func testUnavailableInterfaceRecoversWithoutPathUpdate() async throws { + try await assertUnavailableInterfaceRecovers(sendPathUpdate: false) + } + + private func assertUnavailableInterfaceRecovers(sendPathUpdate: Bool) async throws { + let waiting = expectation(description: "selected interface unavailable") + let recovered = expectation(description: "selected interface recovers automatically") + server.setStatusHandler { status in + if case .waitingForInterface = status { waiting.fulfill() } + if case .failed = status { XCTFail("A temporarily missing address must remain recoverable") } + if case .ready = status { recovered.fulfill() } + } + server.setListenInterface("test-interface") + server.start() + await fulfillment(of: [waiting], timeout: 3) + interfaceAddress.set("127.0.0.1") + if sendPathUpdate { server.refreshListenAddress() } + await fulfillment(of: [recovered], timeout: 3) + let (_, response) = try await request("/") + XCTAssertEqual(response.statusCode, 200) + let socket = makeWebSocket() + defer { socket.cancel(with: .goingAway, reason: nil) } + _ = try await receiveAuthChallenge(socket) + } + + func testRunningInterfaceRecoversAfterSustainedAddressLoss() async throws { + try await startNamedInterface() + let waiting = expectation(description: "lost address enters waiting") + let recovered = expectation(description: "running interface recovers") + server.setStatusHandler { status in + if case .waitingForInterface = status { waiting.fulfill() } + if case .ready = status { recovered.fulfill() } + if case .failed = status { XCTFail("Network loss must not disable recovery") } + } + interfaceAddress.set(nil) + server.refreshListenAddress() + await fulfillment(of: [waiting], timeout: 3) + interfaceAddress.set("127.0.0.1") + await fulfillment(of: [recovered], timeout: 3) + let (_, response) = try await request("/") + XCTAssertEqual(response.statusCode, 200) + } + + func testStopWhileWaitingCancelsRecovery() async throws { + let waiting = expectation(description: "waiting before stop") + server.setStatusHandler { if case .waitingForInterface = $0 { waiting.fulfill() } } + server.setListenInterface("test-interface") + server.start() + await fulfillment(of: [waiting], timeout: 3) + + let stopped = expectation(description: "explicitly stopped") + server.setStatusHandler { if case .stopped = $0 { stopped.fulfill() } } + server.stop() + await fulfillment(of: [stopped], timeout: 3) + let restarted = expectation(description: "stopped server must not recover") + restarted.isInverted = true + server.setStatusHandler { _ in restarted.fulfill() } + interfaceAddress.set("127.0.0.1") + server.refreshListenAddress() + await fulfillment(of: [restarted], timeout: 0.8) + } + + func testSwitchToLoopbackCancelsPendingInterfaceRestart() async throws { + try await startNamedInterface() + interfaceAddress.set(nil) + server.refreshListenAddress() + try await Task.sleep(nanoseconds: 100_000_000) + + let ready = expectation(description: "new loopback selection ready") + server.setStatusHandler { if case .ready = $0 { ready.fulfill() } } + server.setListenInterface(WebRemoteListenTarget.loopback) + server.start() + await fulfillment(of: [ready], timeout: 3) + let restarted = expectation(description: "old path callback must not restart new selection") + restarted.isInverted = true + server.setStatusHandler { _ in restarted.fulfill() } + interfaceAddress.set("127.0.0.1") + server.refreshListenAddress() + await fulfillment(of: [restarted], timeout: 0.8) + let (_, response) = try await request("/") + XCTAssertEqual(response.statusCode, 200) + } - let failed = expectation(description: "vanished interface reports .failed") - WebRemoteServer.shared.setStatusHandler { status in - if case .failed = status { failed.fulfill() } + func testPortConflictDoesNotBecomeInterfaceRecovery() async throws { + interfaceAddress.set("127.0.0.1") + let conflictingServer = WebRemoteServer(defaults: testDefaults) { [interfaceAddress] key in + key == "test-interface" ? interfaceAddress.get() : WebRemoteListenTarget.bindAddress(for: key) + } + conflictingServer.setSecretStorage(WebRemoteEphemeralSecretStorage()) + defer { + conflictingServer.setStatusHandler { _ in } + conflictingServer.stop() } - WebRemoteServer.shared.setListenInterface("glint-definitely-not-an-interface") - WebRemoteServer.shared.start() - try await fulfillment(of: [failed], timeout: 10) + let conflict = expectation(description: "occupied port remains an explicit conflict") + conflictingServer.setStatusHandler { status in + if case .portConflict = status { conflict.fulfill() } + if case .waitingForInterface = status { XCTFail("A port conflict cannot recover by waiting for the NIC") } + if case .ready = status { XCTFail("The test server already occupies these ports") } + } + conflictingServer.setListenInterface("test-interface") + conflictingServer.start() + await fulfillment(of: [conflict], timeout: 3) + } + + private func startNamedInterface() async throws { + interfaceAddress.set("127.0.0.1") + let ready = expectation(description: "named interface ready") + server.setStatusHandler { if case .ready = $0 { ready.fulfill() } } + server.setListenInterface("test-interface") + server.start() + await fulfillment(of: [ready], timeout: 3) + } + + func testBriefAddressLossDoesNotRestartHealthyListeners() async throws { + try await startNamedInterface() + + let restarted = expectation(description: "healthy listener should survive a brief address loss") + restarted.isInverted = true + server.setStatusHandler { if case .starting = $0 { restarted.fulfill() } } + interfaceAddress.set(nil) + server.refreshListenAddress() + try await Task.sleep(nanoseconds: 100_000_000) + interfaceAddress.set("127.0.0.1") + server.refreshListenAddress() + await fulfillment(of: [restarted], timeout: 0.8) + let (_, response) = try await request("/") + XCTAssertEqual(response.statusCode, 200) } // MARK: - Helpers + /// Reserve both sockets together before choosing the pair. NWListener binds + /// them after these probes close; no persisted production ports are reused. + private func availableHTTPPort() throws -> UInt16 { + for _ in 0..<100 { + let port = UInt16.random(in: 49152...65533) + let http = socket(AF_INET, SOCK_STREAM, 0) + let webSocket = socket(AF_INET, SOCK_STREAM, 0) + guard http >= 0, webSocket >= 0 else { + if http >= 0 { close(http) } + if webSocket >= 0 { close(webSocket) } + throw POSIXError(.EMFILE) + } + defer { close(http); close(webSocket) } + func bindLoopback(_ fd: Int32, _ port: UInt16) -> Bool { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + return withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) == 0 + } + } + } + if bindLoopback(http, port), bindLoopback(webSocket, port + 1) { return port } + } + throw POSIXError(.EADDRINUSE) + } + private func request(_ path: String, method: String = "GET") async throws -> (Data, HTTPURLResponse) { let origin = try XCTUnwrap(httpOrigin) var req = URLRequest(url: try XCTUnwrap(URL(string: origin + path))) @@ -408,3 +588,20 @@ final class WebRemoteServerIntegrationTests: XCTestCase { return try XCTUnwrap(JSONSerialization.jsonObject(with: plaintext) as? [String: Any]) } } + +private final class TestInterfaceAddress: @unchecked Sendable { + private let lock = NSLock() + private var address: String? + + func get() -> String? { + lock.lock() + defer { lock.unlock() } + return address + } + + func set(_ value: String?) { + lock.lock() + defer { lock.unlock() } + address = value + } +}