From a3e3fcf2103bc572765cbcdf51f05b2d9c66b37d Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:46:41 -0400 Subject: [PATCH 1/5] Stop reconnecting when the PC answers but sends no picture PCs on 0.3.1 and 0.3.2 in Second screen mode can take longer than the iPad's 10 s first-frame timeout to send a picture. The iPad gave up, and over USB the PC connected the cable again a second later, so the attempts never ended and the PC's screen blinked with each one. - The no-picture card now says the PC's second screen didn't start and to choose Mirror my screen on the PC, then try again. - After two no-picture timeouts in a row at the same PC, the iPad ends the reconnect cycle and stops taking the cable until Try again. Try again starts a new count; a first frame clears it. - A PC that answered earlier in the series and is silent while it restarts keeps the no-picture card instead of "didn't answer". - The timeout says goodbye with the error reason instead of the user one, and the timeout and first-frame lines carry session_id and the time since the attempt started and since the PC accepted it. --- SETUP.md | 2 +- .../App/ConnectionManager.swift | 52 ++++++- .../App/ConnectionProblem.swift | 52 ++++++- .../ConnectionProblemTests.swift | 56 +++++++ ios/EternalMonitorTests/FakeUDPHost.swift | 15 +- .../NoPictureTimeoutTests.swift | 143 ++++++++++++++++++ .../USBLinkFailureTests.swift | 65 ++++++++ scripts/QUICKSTART.txt | 5 +- 8 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 ios/EternalMonitorTests/NoPictureTimeoutTests.swift diff --git a/SETUP.md b/SETUP.md index d3983f91..fd8b991d 100644 --- a/SETUP.md +++ b/SETUP.md @@ -236,7 +236,7 @@ try fewer words. If your problem isn't here, see [Known issues](https://eternalm | **Couldn't get its address. Type the Address shown on the PC.** under a found PC | The iPad saw the PC's name but not its address. | Type the **Address** shown in the PC window, then tap **Connect to PC**. | | **Your PC didn't answer** | Nothing came back from the PC. It may be closed, asleep, on another network, or blocked by a firewall. | Check that EternalMonitor is open on the PC and says **Ready for your iPad**, that the PC is awake, and that both are on the same Wi-Fi, not a guest network. If all of that is right, Windows Firewall or security software is probably blocking EternalMonitor: run EternalMonitor-Setup.exe again, or use a USB cable. Then tap **Try again**. | | **Your PC didn't answer over the cable** | The cable is connected, but EternalMonitor on the PC didn't reply through it. | Check that EternalMonitor and Apple Devices are both open on the PC, then unplug the cable and plug it in again. | -| **Your PC answered, but no picture arrived** | The connection worked, but the PC couldn't send its screen. | Look at EternalMonitor on the PC and follow any message it shows. Click **Restart** there, then tap **Try again** on the iPad. | +| **Your PC answered, but no picture arrived** | The connection worked, but the PC's second screen didn't start, or the PC couldn't send its screen. | On the PC, choose **Mirror my screen** (**Main display (mirror)** in version 0.3.1), then tap **Try again** on the iPad. If the PC already mirrors, click **Restart** there first. After two attempts in a row end this way, the iPad stops reconnecting by itself until you tap **Try again**. | | **That looks like the pairing code, not the PC's address** | A six-digit number was typed where the address goes. | Type the **Address** from the PC window, for example 192.168.1.20. The iPad asks for the code after it connects. | | **That looks like your router's address, not your PC's** | Nothing answered at an address ending in .1, which is usually the Wi-Fi router. | Type the **Address** shown in the PC window. | | **That address doesn't look right** | The iPad couldn't find anything by that name. | Type the **Address** exactly as the PC window shows it. | diff --git a/ios/EternalMonitor/App/ConnectionManager.swift b/ios/EternalMonitor/App/ConnectionManager.swift index ac9a5200..e8b56e83 100644 --- a/ios/EternalMonitor/App/ConnectionManager.swift +++ b/ios/EternalMonitor/App/ConnectionManager.swift @@ -149,6 +149,13 @@ final class ConnectionManager: ObservableObject { // Whether we've already granted the one-time timeout extension that fires when datagrams // start arriving (so a slow/jittery network gets a fresh window to finish reassembly+decode). private var didExtendTimeout = false + /// No-picture timeouts in a row at one PC; at the limit, attempts that + /// start by themselves stop. + private var noPictureStreak = NoPictureStreak() + /// System uptime when the current attempt started, and when its PC + /// accepted the session, for the support log's timings. + private var attemptStartedAt: TimeInterval = 0 + private var sessionAcceptedAt: TimeInterval? let frameSlot = FrameSlot() /// Lets hot-path closures reach the CURRENT control channel without @@ -232,6 +239,7 @@ final class ConnectionManager: ObservableObject { } func resumeUSBConnections() { + noPictureStreak.restartCount() usbPaused = false refreshUSBAvailability() } @@ -345,6 +353,8 @@ final class ConnectionManager: ObservableObject { prevCounters = FrameAssembler.Counters() diagnostics.removeAll() didExtendTimeout = false + attemptStartedAt = ProcessInfo.processInfo.systemUptime + sessionAcceptedAt = nil videoSize = .zero let drawing = DrawingProfile(enabled: UserDefaults.standard.bool(forKey: "drawingMode"), isUSB: isUSB) sessionDrawingMode = drawing.enabled @@ -392,8 +402,9 @@ final class ConnectionManager: ObservableObject { self.debugState.decodedFrames += 1 self.debugState.lastDecodedTimestampUs = ts if self.debugState.decodedFrames == 1 { - self.record(.info, "decode", "First frame decoded successfully") + self.record(.info, "decode", "First frame decoded successfully" + self.attemptTimings) self.timeoutTask?.cancel() + self.noPictureStreak = NoPictureStreak() self.state = .connected self.streamingSince = Date() // A reconnect attempt that streams ends its cycle. @@ -503,8 +514,10 @@ final class ConnectionManager: ObservableObject { assembler?.repairEnabled = !isUSB && info.hostCaps & HelloAck.hostCapNack != 0 assembler?.framePeriodUs = 1_000_000 / UInt64(max(1, info.streamConfig.fps)) receiver?.setAcceptedSessionId(info.sessionId) + let acceptedAt = ProcessInfo.processInfo.systemUptime Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } + self.sessionAcceptedAt = acceptedAt self.livenessTimeoutUs = UInt64(info.livenessTimeoutMs) * 1000 self.hostInfo = info if self.awaitingPairing { @@ -902,6 +915,17 @@ final class ConnectionManager: ObservableObject { reconnectAttempt = 0 } + /// The session and how long the current attempt has taken, appended to + /// the support log's first-frame and timeout lines. + private var attemptTimings: String { + let now = ProcessInfo.processInfo.systemUptime + var text = "" + if let session = hostInfo?.sessionId { text += " session_id=\(session)" } + text += " ms_since_start=\(Int((now - attemptStartedAt) * 1000))" + if let accepted = sessionAcceptedAt { text += " ms_since_accept=\(Int((now - accepted) * 1000))" } + return text + } + /// (Re)arm the connect-timeout watchdog. Cancels any existing timer and starts a fresh one; /// if we're still `.connecting` when it fires, surface a diagnostic and disconnect. private func armConnectTimeout(seconds: Double) { @@ -911,16 +935,33 @@ final class ConnectionManager: ObservableObject { if !Task.isCancelled && self.state == .connecting { let debug = self.debugState let handshakeDone = self.hostInfo != nil - self.record(.error, "timeout", debug.timeoutSummary(handshakeDone: handshakeDone)) - self.problem = .timeout( + let isUSB = self.transportMode == "USB" + self.record(.error, "timeout", debug.timeoutSummary(handshakeDone: handshakeDone) + self.attemptTimings) + self.problem = self.noPictureStreak.timedOut(.timeout( listenerReady: debug.listenerReadyPort != nil, helloAttempts: debug.helloAttempts, handshakeDone: handshakeDone, datagramsReceived: debug.datagramsReceived, - isUSB: self.transportMode == "USB", host: debug.host) - self.disconnect() + isUSB: isUSB, host: debug.host), target: "\(debug.host):\(debug.port)") + // The app gave up, not the user: the PC's log tells them apart. + self.disconnect(reason: .error) + if self.noPictureStreak.stopsReconnecting { self.stopAutomaticAttempts(isUSB: isUSB) } } } } + /// The PC answers but never sends a picture: stop the attempts that + /// start by themselves, the reconnect cycle and the cable the PC + /// connects again, and leave the problem card with Try again up. + private func stopAutomaticAttempts(isUSB: Bool) { + record(.warning, "connection", "Stopped reconnecting after \(noPictureStreak.timeouts) no-picture timeouts in a row") + endReconnectCycle() + if isUSB { + usbPaused = true + usbListener?.stop() + usbListener = nil + usbStatus = "USB: disconnected" + } + } + func cancel() { if mediaLink?.isUSB == true { usbPaused = true @@ -940,6 +981,7 @@ final class ConnectionManager: ObservableObject { guard state == .disconnected else { return } let cable = transportMode == "USB" || problem == .usbFailed endReconnectCycle() + noPictureStreak.restartCount() problem = nil connectionError = nil if cable { diff --git a/ios/EternalMonitor/App/ConnectionProblem.swift b/ios/EternalMonitor/App/ConnectionProblem.swift index c7560e5c..35127f78 100644 --- a/ios/EternalMonitor/App/ConnectionProblem.swift +++ b/ios/EternalMonitor/App/ConnectionProblem.swift @@ -88,8 +88,8 @@ enum ConnectionProblem: Equatable { case .noReply(usb: true): return "Check that EternalMonitor and Apple Devices are both open on the PC, then unplug the cable and plug it in again." case .noPicture: - return "EternalMonitor on the PC couldn't send its screen. Look there for a message and follow it, click Restart, then try again. " - + "If it keeps happening, send us Settings, Diagnostics from this iPad and Performance, Copy logs from the PC." + return "The PC's second screen didn't start. On the PC, choose Mirror my screen (called Main display (mirror) in version 0.3.1), " + + "then try again. If it already mirrors, click Restart on the PC first." case .receiverNotReady: return "Close EternalMonitor on this iPad and open it again." case .badAddress(let host): @@ -246,6 +246,54 @@ enum ConnectionProblem: Equatable { private static let offline: Set = [.ENETDOWN] } +/// Connect attempts in a row that timed out at one PC without a picture. +/// A PC on 0.3.1 or 0.3.2 in Second screen mode answers, then can take +/// longer than this iPad waits to send its first picture, and every new +/// attempt restarts its second screen. Over the cable the PC connects again +/// by itself a second later, so without a limit the attempts never end and +/// the PC's screen blinks with each one. +struct NoPictureStreak: Equatable { + /// Attempts that start by themselves stop at this many no-picture + /// timeouts. One more attempt lets a PC that was only slow the first + /// time (a busy PC, a second screen that starts once) show its picture; + /// a second timeout means it fails the same way every time. + static let limit = 2 + + /// The PC as "address:port", or "USB:9877" for the cable. + private(set) var target: String? + private(set) var timeouts = 0 + /// The PC answered at least once in this series. + private(set) var answered = false + + var stopsReconnecting: Bool { timeouts >= Self.limit } + + /// Counts an attempt at `target` that timed out with `problem`, and + /// returns the problem to show. A PC that answered earlier in the series + /// and is silent now is most likely restarting EternalMonitor, so it + /// keeps the advice about the picture. + mutating func timedOut(_ problem: ConnectionProblem, target: String) -> ConnectionProblem { + if target != self.target { + self = NoPictureStreak() + self.target = target + } + switch problem { + case .noPicture: + answered = true + timeouts += 1 + return problem + case .noReply, .routerAddress: + return answered ? .noPicture : problem + default: + return problem + } + } + + /// Try again: the user starts a new count. The PC still answered before. + mutating func restartCount() { + timeouts = 0 + } +} + /// What the USB cable card says for each listener status. enum USBStatusText { static func describe(_ status: String) -> String { diff --git a/ios/EternalMonitorTests/ConnectionProblemTests.swift b/ios/EternalMonitorTests/ConnectionProblemTests.swift index 1dcc2970..4e21ec6b 100644 --- a/ios/EternalMonitorTests/ConnectionProblemTests.swift +++ b/ios/EternalMonitorTests/ConnectionProblemTests.swift @@ -14,6 +14,62 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertEqual(ConnectionProblem.timeout(listenerReady: true, helloAttempts: 1, handshakeDone: true, datagramsReceived: 0, isUSB: true), .noPicture) } + /// PCs on 0.3.1 and 0.3.2 answer but send no picture in Second screen + /// mode. The card says to mirror, naming the choice as both versions do. + func testNoPictureSaysToMirrorTheScreen() { + let detail = ConnectionProblem.noPicture.detail + XCTAssertTrue(detail.hasPrefix("The PC's second screen didn't start."), detail) + XCTAssertTrue(detail.contains("choose Mirror my screen") && detail.contains("Main display (mirror)"), detail) + XCTAssertTrue(detail.contains("try again"), detail) + XCTAssertEqual(ConnectionProblem.noPicture.actions, [.retry]) + XCTAssertTrue(ConnectionProblem.noPicture.canReport) + } + + func testNoPictureTimeoutsCountPerPCUpToTheLimit() { + var streak = NoPictureStreak() + XCTAssertEqual(NoPictureStreak.limit, 2) + XCTAssertEqual(streak.timedOut(.noPicture, target: "192.168.1.20:9876"), .noPicture) + XCTAssertFalse(streak.stopsReconnecting, "one timeout gets another attempt") + // Other failures don't count. + for other: ConnectionProblem in [.receiverNotReady, .noReply(usb: false), .couldNotConnect] { + _ = streak.timedOut(other, target: "192.168.1.20:9876") + XCTAssertEqual(streak.timeouts, 1, "\(other)") + } + _ = streak.timedOut(.noPicture, target: "192.168.1.20:9876") + XCTAssertTrue(streak.stopsReconnecting) + // Try again starts a new count. + streak.restartCount() + XCTAssertFalse(streak.stopsReconnecting) + _ = streak.timedOut(.noPicture, target: "192.168.1.20:9876") + XCTAssertFalse(streak.stopsReconnecting) + // Another PC, or the cable, starts its own count. + _ = streak.timedOut(.noPicture, target: "USB:9877") + XCTAssertEqual(streak.timeouts, 1) + XCTAssertEqual(streak.target, "USB:9877") + } + + func testAPCThatAnsweredInTheSeriesKeepsTheNoPictureMessage() { + var streak = NoPictureStreak() + // Nothing answered yet: the usual messages. + XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noReply(usb: false)) + XCTAssertEqual(streak.timedOut(.routerAddress, target: "192.168.1.1:9876"), .routerAddress) + _ = streak.timedOut(.noPicture, target: "192.168.1.1:9876") + // Silent while EternalMonitor restarts on the PC that answered. + XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noPicture) + XCTAssertEqual(streak.timedOut(.routerAddress, target: "192.168.1.1:9876"), .noPicture) + XCTAssertEqual(streak.timeouts, 1, "a silent attempt isn't a no-picture timeout") + // Try again keeps it: the PC still answered before. + streak.restartCount() + XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noPicture) + // Something the user must fix still shows as itself. + XCTAssertEqual(streak.timedOut(.receiverNotReady, target: "192.168.1.1:9876"), .receiverNotReady) + // Another PC never answered. + XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.30:9876"), .noReply(usb: false)) + // The cable, after it answered. + _ = streak.timedOut(.noPicture, target: "USB:9877") + XCTAssertEqual(streak.timedOut(.noReply(usb: true), target: "USB:9877"), .noPicture) + } + func testAPairingCodeTypedAsTheAddressIsCaughtBeforeConnecting() { for typed in ["390756", "390 756", " 390756 "] { XCTAssertEqual(ConnectionProblem.typedAddress(typed), .pairingCodeAsAddress, typed) diff --git a/ios/EternalMonitorTests/FakeUDPHost.swift b/ios/EternalMonitorTests/FakeUDPHost.swift index b4112e63..eeab954c 100644 --- a/ios/EternalMonitorTests/FakeUDPHost.swift +++ b/ios/EternalMonitorTests/FakeUDPHost.swift @@ -52,17 +52,22 @@ final class FakeUDPHost: @unchecked Sendable { guard let (data, from, length) = receive() else { continue } guard case .hello2(let hello) = Wire.parseControl(data)?.message, answered.insert(hello.clientNonce).inserted else { continue } - let ack = Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( - status: status, acceptedVersion: acceptedVersion, clientNonce: hello.clientNonce, sessionId: status == .ok ? 41 : 0, - heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, - streamConfig: StreamConfig(), hostName: "Test PC", authToken: Data(17...32), hostCaps: 0))) client = (from, length) - send(ack) + send(Self.ack(nonce: hello.clientNonce, status: status, acceptedVersion: acceptedVersion)) return true } return false } + /// The HELLO_ACK for the attempt with `nonce`: session 41, unless + /// `status` refuses it. + static func ack(nonce: UInt32, status: HelloStatus = .ok, acceptedVersion: UInt8 = 2) -> Data { + Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( + status: status, acceptedVersion: acceptedVersion, clientNonce: nonce, sessionId: status == .ok ? 41 : 0, + heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, + streamConfig: StreamConfig(), hostName: "Test PC", authToken: Data(17...32), hostCaps: 0))) + } + private func send(_ datagram: Data) { guard var (to, length) = client else { return } _ = datagram.withUnsafeBytes { bytes in diff --git a/ios/EternalMonitorTests/NoPictureTimeoutTests.swift b/ios/EternalMonitorTests/NoPictureTimeoutTests.swift new file mode 100644 index 00000000..74fc80c8 --- /dev/null +++ b/ios/EternalMonitorTests/NoPictureTimeoutTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import EternalMonitor + +/// A PC that answers but sends no picture before the connect timeout, as +/// PCs on 0.3.1 and 0.3.2 do while their second screen starts. +@MainActor +final class NoPictureTimeoutTests: XCTestCase { + private let fast = ConnectionManager.Timing(connectTimeoutSeconds: 0.5, reconnectGraceSeconds: 0.1, + reconnectBackoffSeconds: { _ in 0.05 }) + + override func setUp() async throws { + let previous = UserDefaults.standard.object(forKey: "autoReconnect") + UserDefaults.standard.set(true, forKey: "autoReconnect") + addTeardownBlock { UserDefaults.standard.set(previous, forKey: "autoReconnect") } + } + + private func until(_ description: String, seconds: Double = 10, _ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(seconds) + while !condition(), Date() < deadline { try await Task.sleep(nanoseconds: 10_000_000) } + XCTAssertTrue(condition(), description) + } + + /// One manual attempt at `host` that it answers, ending in its timeout. + private func timedOutAttempt(_ manager: ConnectionManager, at host: FakeUDPHost) async throws { + manager.connect(host: "127.0.0.1", port: host.port) + XCTAssertEqual(manager.state, .connecting) + let accepted = await Task.detached { host.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + try await until("timed out") { manager.state == .disconnected } + } + + /// A session on `host` that streams. + private func streaming(_ host: FakeUDPHost) async throws -> ConnectionManager { + let manager = ConnectionManager(timing: ConnectionManager.Timing(connectTimeoutSeconds: 1, + reconnectGraceSeconds: 0.1, reconnectBackoffSeconds: { _ in 0.05 })) + manager.connect(host: "127.0.0.1", port: host.port) + let accepted = await Task.detached { host.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + try await until("handshake") { manager.hostInfo != nil } + host.sendKeyframe(try FakeUDPHost.keyframe()) + try await until("streaming") { manager.state == .connected } + return manager + } + + private func logged(_ manager: ConnectionManager, _ prefix: String) -> String? { + manager.diagnostics.first { $0.message.hasPrefix(prefix) }?.message + } + + /// The app gave up, not the user: the PC hears the error reason, and the + /// support log names the session and how long the attempt waited. + func testATimeoutSaysGoodbyeWithTheErrorReason() async throws { + let host = FakeUDPHost() + let manager = ConnectionManager(timing: fast) + defer { manager.cancel() } + try await timedOutAttempt(manager, at: host) + let reasons = await Task.detached { host.goodbyes() }.value + XCTAssertEqual(reasons, Array(repeating: .error, count: ControlChannel.byeCopies)) + XCTAssertEqual(manager.problem, .noPicture) + let timeout = logged(manager, "Session accepted by 127.0.0.1:") ?? "" + XCTAssertTrue(timeout.contains("no video datagrams arrived. session_id=41 ms_since_start="), timeout) + XCTAssertTrue(timeout.contains(" ms_since_accept="), timeout) + XCTAssertNotNil(logged(manager, "Disconnect state=connecting reason=error")) + XCTAssertNil(logged(manager, "Disconnect state=connecting reason=userDisconnect")) + } + + func testTheFirstFrameLineNamesTheSessionAndTheWait() async throws { + let host = FakeUDPHost() + let manager = try await streaming(host) + defer { manager.cancel() } + let line = logged(manager, "First frame decoded successfully") ?? "" + XCTAssertTrue(line.contains(" session_id=41 ms_since_start="), line) + XCTAssertTrue(line.contains(" ms_since_accept="), line) + } + + /// The PC restarts its stream and then answers every attempt without a + /// picture: the cycle stops at the limit and leaves Try again up. Try + /// again starts a new count, so its own timeout stops nothing. + func testRepeatedNoPictureTimeoutsStopReconnectingUntilTryAgain() async throws { + let host = FakeUDPHost() + let manager = try await streaming(host) + defer { manager.cancel() } + host.sendBye(.error) + try await until("reconnecting") { manager.reconnectAttempt == 1 } + let answered = await Task.detached { (0.. Bool { + var timeout = timeval(tv_sec: 0, tv_usec: 300_000) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var framer = EMLinkFramer(expectsPreamble: false) + var buffer = [UInt8](repeating: 0, count: 4096) + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + let count = recv(fd, &buffer, buffer.count, 0) + guard count > 0, let datagrams = try? framer.append(Data(buffer.prefix(count))) else { continue } + for datagram in datagrams { + guard case .hello2(let hello) = Wire.parseControl(datagram)?.message, + let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce)) else { continue } + return framed.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } == framed.count + } + } + return false + } + + /// The loop that blinked the PC's screen: every timeout closed the cable, + /// the PC connected it again, and the iPad took it. At the limit the + /// iPad stops taking the cable until Try again. + func testRepeatedNoPictureOverTheCablePausesItUntilTryAgain() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Try again must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() } } + let (manager, first) = try await connectOverCable(timing: fast) + defer { manager.cancel() } + cables.append(first) + for attempt in 1...NoPictureStreak.limit { + if attempt > 1 { + let cable = try await openCable() + cables.append(cable) + manager.acceptUSBLink(cable) + XCTAssertEqual(manager.state, .connecting, "attempt \(attempt) takes the cable") + } + let fd = peer + let accepted = await Task.detached { Self.acceptSession(on: fd) }.value + XCTAssertTrue(accepted, "attempt \(attempt) never sent HELLO2") + try await until("attempt \(attempt) times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture, "attempt \(attempt)") + } + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused") + let refused = try await openCable() + cables.append(refused) + manager.acceptUSBLink(refused) + XCTAssertEqual(manager.state, .disconnected, "the PC's next cable connection is not taken") + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.problem?.actions, [.retry]) + + manager.retry() + XCTAssertNil(manager.problem) + let again = try await openCable() + cables.append(again) + manager.acceptUSBLink(again) + XCTAssertEqual(manager.state, .connecting, "Try again takes the cable again") + } + // MARK: Answers from the PC during the fallback. Busy and a restart pass; // the cycle keeps retrying. diff --git a/scripts/QUICKSTART.txt b/scripts/QUICKSTART.txt index 5bb026d8..b2c76b37 100644 --- a/scripts/QUICKSTART.txt +++ b/scripts/QUICKSTART.txt @@ -107,8 +107,9 @@ What the iPad says, and what to do: iPad", the PC is awake, and both are on the same Wi-Fi, not a guest network. If so, a firewall is probably blocking EternalMonitor: run EternalMonitor-Setup.exe again, or use a USB cable. -- "Your PC answered, but no picture arrived": follow any message in this - window, click Restart, then tap Try again on the iPad. +- "Your PC answered, but no picture arrived": choose Mirror my screen at + the top of the Stream page, then tap Try again on the iPad. If it + already mirrors, click Restart first. - "Lost the connection to your PC": wake the PC, check EternalMonitor is open, then tap Try again. - "That looks like the pairing code, not the PC's address": type the From d086e3db9dfafe0f7b609901f86deb734a3e5887 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 19:46:48 -0400 Subject: [PATCH 2/5] Show the diagnostics upload ID as a Support ID Settings > Diagnostics shows the installation_id that automatic uploads use, labelled Support ID, with a Copy button. The diagnostics export and the feedback email put it near the top, so a message can be matched to the iPad's uploaded diagnostics. The export's Launch id is a different id and stays as it is. --- SETUP.md | 1 + ios/EternalMonitor/App/DiagnosticsView.swift | 21 +++++++++++++++++++ .../Core/DiagnosticsUpload.swift | 3 ++- ios/EternalMonitor/Core/Feedback.swift | 8 ++++++- .../Core/SupportDiagnostics.swift | 2 +- ios/EternalMonitorTests/FeedbackTests.swift | 14 +++++++++++++ .../SupportDiagnosticsTests.swift | 4 ++++ 7 files changed, 50 insertions(+), 3 deletions(-) diff --git a/SETUP.md b/SETUP.md index fd8b991d..643b1024 100644 --- a/SETUP.md +++ b/SETUP.md @@ -317,6 +317,7 @@ or an email to [hello@sideband.studio](mailto:hello@sideband.studio). Say what you tried, what you saw, your iPad model, and whether you used Wi-Fi or USB. On iPad, open **Settings → Support → Diagnostics**, then **Export diagnostics** for a text file or **Copy diagnostics**. Include both reports when possible. +If you can't attach the report, copy the **Support ID** from the same screen into your message. Reports include Windows build, CPU, GPU and driver details; iPad hardware, iPadOS and app build; encoder/decoder setup, connection events and periodic diff --git a/ios/EternalMonitor/App/DiagnosticsView.swift b/ios/EternalMonitor/App/DiagnosticsView.swift index ec4c8027..38b51fce 100644 --- a/ios/EternalMonitor/App/DiagnosticsView.swift +++ b/ios/EternalMonitor/App/DiagnosticsView.swift @@ -18,12 +18,33 @@ struct DiagnosticsView: View { @State private var preparing = false @State private var error: String? @State private var copied = false + @State private var supportIDCopied = false + private let supportID = DiagnosticsUpload.shared.installationID @AppStorage(DiagnosticsUpload.enabledKey) private var uploadEnabled = true @State private var uploadStatus = "Checking automatic upload" var body: some View { Form { + Section { + LabeledContent("Support ID") { + Text(supportID) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + .accessibilityIdentifier("diagnostics.supportID") + Button { + UIPasteboard.general.string = supportID + supportIDCopied = true + } label: { + Label(supportIDCopied ? "Support ID copied" : "Copy Support ID", systemImage: "doc.on.doc") + } + .accessibilityIdentifier("diagnostics.copySupportID") + } footer: { + Text("Include this ID when you write to us, so we can find the diagnostics this iPad shared.") + } + .listRowBackground(Theme.surfaceRaised) + Section { Toggle("Automatically share diagnostics", isOn: $uploadEnabled) .accessibilityIdentifier("diagnostics.automaticUpload") diff --git a/ios/EternalMonitor/Core/DiagnosticsUpload.swift b/ios/EternalMonitor/Core/DiagnosticsUpload.swift index 8c2f9edf..22ab2ab2 100644 --- a/ios/EternalMonitor/Core/DiagnosticsUpload.swift +++ b/ios/EternalMonitor/Core/DiagnosticsUpload.swift @@ -50,7 +50,8 @@ final class DiagnosticsUpload: NSObject, URLSessionTaskDelegate, @unchecked Send private var nextAttempt = Date.distantPast private var backoff = 5.0 private var statusText = "Automatic upload is not configured" - private let installationID: String + /// Shown to the user as the Support ID: support finds this iPad's uploaded rows by it. + let installationID: String private let launchID = UUID().uuidString private var dropped = 0 private let lossLock = NSLock() diff --git a/ios/EternalMonitor/Core/Feedback.swift b/ios/EternalMonitor/Core/Feedback.swift index 5f5bb093..8e30a4fa 100644 --- a/ios/EternalMonitor/Core/Feedback.swift +++ b/ios/EternalMonitor/Core/Feedback.swift @@ -32,6 +32,8 @@ enum Feedback { var pcVersion: String? /// "USB" or "Wi-Fi" while connected. var connection: String? + /// The diagnostics upload ID, which finds this iPad's rows. + var supportID: String? = nil @MainActor static func current(pcVersion: String?, connection: String?) -> Context { let info = Bundle.main.infoDictionary @@ -41,7 +43,8 @@ enum Feedback { systemVersion: UIDevice.current.systemVersion, model: modelIdentifier(), pcVersion: pcVersion, - connection: connection + connection: connection, + supportID: DiagnosticsUpload.shared.installationID ) } @@ -73,6 +76,9 @@ enum Feedback { /// report attached, the email says so first. static func body(_ kind: Kind, _ context: Context, attachesDiagnostics: Bool = false) -> String { var lines: [String] = [] + if let supportID = context.supportID { + lines.append("Support ID: \(supportID)") + } if case .problem(let title) = kind { lines.append("Problem: \(title)") } diff --git a/ios/EternalMonitor/Core/SupportDiagnostics.swift b/ios/EternalMonitor/Core/SupportDiagnostics.swift index 24dd7bb8..32980a0b 100644 --- a/ios/EternalMonitor/Core/SupportDiagnostics.swift +++ b/ios/EternalMonitor/Core/SupportDiagnostics.swift @@ -99,7 +99,7 @@ final class SupportDiagnostics: @unchecked Sendable { return await withCheckedContinuation { continuation in queue.async { self.lossLock.lock(); let dropped = self.dropped; self.lossLock.unlock() - var report = "EternalMonitor iPad support report, schema 1\nLaunch: \(self.launchID)\nAutomatic upload: \(uploadStatus)\nOutput queue dropped events: \(dropped)\nStorage error: \(self.storageError ?? "none")\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n\(self.startup.joined())" + var report = "EternalMonitor iPad support report, schema 1\nSupport ID: \(DiagnosticsUpload.shared.installationID)\nLaunch: \(self.launchID)\nAutomatic upload: \(uploadStatus)\nOutput queue dropped events: \(dropped)\nStorage error: \(self.storageError ?? "none")\nLogs may contain PC/device names and local network addresses.\n\n--- Startup evidence ---\n\(self.startup.joined())" for suffix in [".2", ".1", ""] { if let text = try? String(contentsOf: self.url(suffix), encoding: .utf8) { report += "\n--- Log\(suffix) ---\n\(text)" diff --git a/ios/EternalMonitorTests/FeedbackTests.swift b/ios/EternalMonitorTests/FeedbackTests.swift index edc77648..252d2e32 100644 --- a/ios/EternalMonitorTests/FeedbackTests.swift +++ b/ios/EternalMonitorTests/FeedbackTests.swift @@ -39,6 +39,20 @@ final class FeedbackTests: XCTestCase { XCTAssertFalse(text.contains("Problem:"), text) } + func testTheSupportIDComesFirstInTheDetails() { + var withID = context + withID.supportID = "5A1E0C3B-7D2F-4E8A-9B6C-0F1D2E3A4B5C" + let body = Feedback.body(.problem("Your PC didn't answer"), withID) + XCTAssertTrue(body.contains("--\nSupport ID: 5A1E0C3B-7D2F-4E8A-9B6C-0F1D2E3A4B5C\nProblem: Your PC didn't answer\n"), body) + } + + @MainActor + func testTheSupportIDIsTheDiagnosticsUploadID() { + let current = Feedback.Context.current(pcVersion: nil, connection: nil) + XCTAssertEqual(current.supportID, DiagnosticsUpload.shared.installationID) + XCTAssertEqual(UUID(uuidString: current.supportID ?? "")?.uuidString, current.supportID) + } + func testAProblemCardNamesItsProblem() { let body = Feedback.body(.problem(ConnectionProblem.noReply(usb: false).title), context) XCTAssertTrue(body.contains("--\nProblem: Your PC didn't answer\nApp:"), body) diff --git a/ios/EternalMonitorTests/SupportDiagnosticsTests.swift b/ios/EternalMonitorTests/SupportDiagnosticsTests.swift index 43068518..1ee2a0c1 100644 --- a/ios/EternalMonitorTests/SupportDiagnosticsTests.swift +++ b/ios/EternalMonitorTests/SupportDiagnosticsTests.swift @@ -25,6 +25,10 @@ final class SupportDiagnosticsTests: XCTestCase { XCTAssertTrue(report.contains("VTDecodeFrame status=-12909")) XCTAssertTrue(report.contains("new launch")) XCTAssertTrue(report.contains("Storage error: none")) + // The Support ID is the upload's installation_id, near the top. + let header = report.components(separatedBy: "\n").prefix(2) + XCTAssertEqual(header.last, "Support ID: \(DiagnosticsUpload.shared.installationID)", report) + XCTAssertTrue(report.contains("installation_id=\(DiagnosticsUpload.shared.installationID);")) let export = try await second.export() XCTAssertTrue(try String(contentsOf: export).contains("VTDecodeFrame status=-12909")) } From c3ea13d082be70780677270e9152bad9c9420153 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 21:20:37 -0400 Subject: [PATCH 3/5] Count cable closes without a picture and keep the stop per PC A PC on 0.3.1 closes the cable a few seconds after it answers, before the connect timeout, so the no-picture count never grew and the loop went on. An attempt the PC answered now counts when it ends without a picture, whether the timeout fires or the PC closes or fails the connection first. The count belongs to the PC by the name it answers with, so two PCs on one cable no longer add up, and a silent cable keeps its own advice. A cable stopped this way stays stopped when the app comes back to the foreground, until Try again or Connect USB. --- .../App/ConnectionManager.swift | 35 ++++- .../App/ConnectionProblem.swift | 68 +++++---- .../ConnectionProblemTests.swift | 80 +++++++---- ios/EternalMonitorTests/FakeUDPHost.swift | 9 +- .../USBLinkFailureTests.swift | 136 +++++++++++++++++- 5 files changed, 263 insertions(+), 65 deletions(-) diff --git a/ios/EternalMonitor/App/ConnectionManager.swift b/ios/EternalMonitor/App/ConnectionManager.swift index e8b56e83..a6db1dd1 100644 --- a/ios/EternalMonitor/App/ConnectionManager.swift +++ b/ios/EternalMonitor/App/ConnectionManager.swift @@ -133,6 +133,10 @@ final class ConnectionManager: ObservableObject { private var usbListener: USBListener? private var isForeground = true private var usbPaused = false + /// The cable stopped after attempts without a picture. Unlike a manual + /// Disconnect, coming back to the app keeps it stopped until Try again + /// or Connect USB. + private var usbStoppedForNoPicture = false private let currentAttempt = OSAllocatedUnfairLock(initialState: nil) private var frameAssembler: FrameAssembler? private var controlChannel: ControlChannel? @@ -240,6 +244,7 @@ final class ConnectionManager: ObservableObject { func resumeUSBConnections() { noPictureStreak.restartCount() + usbStoppedForNoPicture = false usbPaused = false refreshUSBAvailability() } @@ -518,6 +523,7 @@ final class ConnectionManager: ObservableObject { Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } self.sessionAcceptedAt = acceptedAt + self.noPictureStreak.answered(by: info.hostName, at: isUSB ? nil : "\(normalizedHost):\(port)") self.livenessTimeoutUs = UInt64(info.livenessTimeoutMs) * 1000 self.hostInfo = info if self.awaitingPairing { @@ -708,6 +714,7 @@ final class ConnectionManager: ObservableObject { // connectionID, so the close FramedLink sends next is ignored // instead of relabelling this as an unplugged cable. self.usbStatus = "USB: waiting for a cable" + if self.stopsAfterClosingWithoutPicture() { return } self.beginReconnect(.cableUnplugged, failure: failure) } } @@ -716,6 +723,7 @@ final class ConnectionManager: ObservableObject { Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } if isUSB { self.usbStatus = "USB: waiting for a cable" } + if self.stopsAfterClosingWithoutPicture() { return } // An ordinary close is the cable coming out; a close after a // classified failure keeps that failure's advice. self.beginReconnect(.cableUnplugged, failure: self.linkFailure) @@ -937,17 +945,35 @@ final class ConnectionManager: ObservableObject { let handshakeDone = self.hostInfo != nil let isUSB = self.transportMode == "USB" self.record(.error, "timeout", debug.timeoutSummary(handshakeDone: handshakeDone) + self.attemptTimings) - self.problem = self.noPictureStreak.timedOut(.timeout( + // Only an attempt the PC answered counts. + if handshakeDone { self.noPictureStreak.endedWithoutPicture() } + self.problem = self.noPictureStreak.shown(.timeout( listenerReady: debug.listenerReadyPort != nil, helloAttempts: debug.helloAttempts, handshakeDone: handshakeDone, datagramsReceived: debug.datagramsReceived, - isUSB: isUSB, host: debug.host), target: "\(debug.host):\(debug.port)") + isUSB: isUSB, host: debug.host), at: isUSB ? nil : "\(debug.host):\(debug.port)") // The app gave up, not the user: the PC's log tells them apart. self.disconnect(reason: .error) - if self.noPictureStreak.stopsReconnecting { self.stopAutomaticAttempts(isUSB: isUSB) } + if handshakeDone && self.noPictureStreak.stopsReconnecting { self.stopAutomaticAttempts(isUSB: isUSB) } } } } + /// The PC answered this attempt and closed the connection before a + /// picture, as a PC on 0.3.1 does over the cable each time its second + /// screen restarts. That counts like a no-picture timeout; at the limit + /// it ends the attempt here and returns true, instead of reconnecting. + private func stopsAfterClosingWithoutPicture() -> Bool { + guard state == .connecting, hostInfo != nil else { return false } + noPictureStreak.endedWithoutPicture() + guard noPictureStreak.stopsReconnecting else { return false } + let isUSB = transportMode == "USB" + record(.warning, "ctrl", "Connection closed by the PC before a picture" + attemptTimings) + disconnect(reason: .error) + problem = .noPicture + stopAutomaticAttempts(isUSB: isUSB) + return true + } + /// The PC answers but never sends a picture: stop the attempts that /// start by themselves, the reconnect cycle and the cable the PC /// connects again, and leave the problem card with Try again up. @@ -956,6 +982,7 @@ final class ConnectionManager: ObservableObject { endReconnectCycle() if isUSB { usbPaused = true + usbStoppedForNoPicture = true usbListener?.stop() usbListener = nil usbStatus = "USB: disconnected" @@ -1070,7 +1097,7 @@ final class ConnectionManager: ObservableObject { func handleAppForegrounded() { endBackgroundGoodbye() isForeground = true - usbPaused = false + usbPaused = usbStoppedForNoPicture refreshUSBAvailability() guard resumeOnForeground else { return } resumeOnForeground = false diff --git a/ios/EternalMonitor/App/ConnectionProblem.swift b/ios/EternalMonitor/App/ConnectionProblem.swift index 35127f78..eef99225 100644 --- a/ios/EternalMonitor/App/ConnectionProblem.swift +++ b/ios/EternalMonitor/App/ConnectionProblem.swift @@ -246,43 +246,59 @@ enum ConnectionProblem: Equatable { private static let offline: Set = [.ENETDOWN] } -/// Connect attempts in a row that timed out at one PC without a picture. -/// A PC on 0.3.1 or 0.3.2 in Second screen mode answers, then can take -/// longer than this iPad waits to send its first picture, and every new -/// attempt restarts its second screen. Over the cable the PC connects again -/// by itself a second later, so without a limit the attempts never end and -/// the PC's screen blinks with each one. +/// Connect attempts in a row that one PC answered and that ended without a +/// picture. A PC on 0.3.1 or 0.3.2 in Second screen mode answers, then can +/// take longer than this iPad waits to send its first picture, and every new +/// attempt restarts its second screen; on 0.3.1 that restart also closes the +/// cable a few seconds in. Over the cable the PC connects again by itself a +/// second later, so without a limit the attempts never end and the PC's +/// screen blinks with each one. struct NoPictureStreak: Equatable { - /// Attempts that start by themselves stop at this many no-picture - /// timeouts. One more attempt lets a PC that was only slow the first + /// Attempts that start by themselves stop at this many attempts without + /// a picture. One more attempt lets a PC that was only slow the first /// time (a busy PC, a second screen that starts once) show its picture; - /// a second timeout means it fails the same way every time. + /// a second one means it fails the same way every time. static let limit = 2 - /// The PC as "address:port", or "USB:9877" for the cable. - private(set) var target: String? + /// The PC that answered: the name it gave when it answered, or its + /// address if it gave none. + private(set) var pc: String? + /// Where that PC last answered, as "address:port"; nil over the cable, + /// which doesn't say which PC is at its other end. + private(set) var address: String? private(set) var timeouts = 0 - /// The PC answered at least once in this series. - private(set) var answered = false + /// That PC answered and sent no picture in this series. + private(set) var answeredWithoutPicture = false var stopsReconnecting: Bool { timeouts >= Self.limit } - /// Counts an attempt at `target` that timed out with `problem`, and - /// returns the problem to show. A PC that answered earlier in the series - /// and is silent now is most likely restarting EternalMonitor, so it - /// keeps the advice about the picture. - mutating func timedOut(_ problem: ConnectionProblem, target: String) -> ConnectionProblem { - if target != self.target { + /// The PC `name` answered at `address` (nil over the cable). Another PC + /// starts a new series. + mutating func answered(by name: String, at address: String?) { + let pc = name.isEmpty ? address ?? "USB" : name + if pc != self.pc { self = NoPictureStreak() - self.target = target + self.pc = pc } + self.address = address + } + + /// The attempt the PC answered ended without a picture: the timeout + /// fired, or the PC closed the connection first. + mutating func endedWithoutPicture() { + guard pc != nil else { return } + timeouts += 1 + answeredWithoutPicture = true + } + + /// The problem to show for a timeout at `address` (nil over the cable). + /// A PC that answered there earlier in the series and is silent now is + /// most likely restarting EternalMonitor, so it keeps the advice about + /// the picture. + func shown(_ problem: ConnectionProblem, at address: String?) -> ConnectionProblem { switch problem { - case .noPicture: - answered = true - timeouts += 1 - return problem - case .noReply, .routerAddress: - return answered ? .noPicture : problem + case .noReply(usb: false), .routerAddress: + return answeredWithoutPicture && address != nil && address == self.address ? .noPicture : problem default: return problem } diff --git a/ios/EternalMonitorTests/ConnectionProblemTests.swift b/ios/EternalMonitorTests/ConnectionProblemTests.swift index 4e21ec6b..d8447735 100644 --- a/ios/EternalMonitorTests/ConnectionProblemTests.swift +++ b/ios/EternalMonitorTests/ConnectionProblemTests.swift @@ -25,49 +25,75 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertTrue(ConnectionProblem.noPicture.canReport) } - func testNoPictureTimeoutsCountPerPCUpToTheLimit() { + func testNoPictureAttemptsCountPerPCUpToTheLimit() { var streak = NoPictureStreak() XCTAssertEqual(NoPictureStreak.limit, 2) - XCTAssertEqual(streak.timedOut(.noPicture, target: "192.168.1.20:9876"), .noPicture) - XCTAssertFalse(streak.stopsReconnecting, "one timeout gets another attempt") - // Other failures don't count. - for other: ConnectionProblem in [.receiverNotReady, .noReply(usb: false), .couldNotConnect] { - _ = streak.timedOut(other, target: "192.168.1.20:9876") - XCTAssertEqual(streak.timeouts, 1, "\(other)") - } - _ = streak.timedOut(.noPicture, target: "192.168.1.20:9876") + // Nothing answered: nothing to count. + streak.endedWithoutPicture() + XCTAssertEqual(streak.timeouts, 0) + streak.answered(by: "Desk PC", at: "192.168.1.20:9876") + streak.endedWithoutPicture() + XCTAssertFalse(streak.stopsReconnecting, "one attempt without a picture gets another") + streak.answered(by: "Desk PC", at: "192.168.1.20:9876") + streak.endedWithoutPicture() XCTAssertTrue(streak.stopsReconnecting) // Try again starts a new count. streak.restartCount() XCTAssertFalse(streak.stopsReconnecting) - _ = streak.timedOut(.noPicture, target: "192.168.1.20:9876") + streak.endedWithoutPicture() + XCTAssertFalse(streak.stopsReconnecting) + } + + /// The count belongs to the PC by the name it answers with, not to the + /// address or the cable: two PCs on one cable count separately, and one + /// PC at a new address or on the cable counts on. + func testNoPictureAttemptsCountByThePCThatAnswered() { + var streak = NoPictureStreak() + streak.answered(by: "Desk PC", at: nil) + streak.endedWithoutPicture() + streak.answered(by: "Studio PC", at: nil) + XCTAssertEqual(streak.timeouts, 0, "another PC on the cable starts a new count") + streak.endedWithoutPicture() XCTAssertFalse(streak.stopsReconnecting) - // Another PC, or the cable, starts its own count. - _ = streak.timedOut(.noPicture, target: "USB:9877") - XCTAssertEqual(streak.timeouts, 1) - XCTAssertEqual(streak.target, "USB:9877") + streak.answered(by: "Studio PC", at: "192.168.1.40:9876") + streak.endedWithoutPicture() + XCTAssertTrue(streak.stopsReconnecting, "the same PC over Wi-Fi counts on") + // A PC without a name counts by its address. + var unnamed = NoPictureStreak() + unnamed.answered(by: "", at: "192.168.1.20:9876") + unnamed.endedWithoutPicture() + unnamed.answered(by: "", at: "192.168.1.30:9876") + XCTAssertEqual(unnamed.timeouts, 0) } func testAPCThatAnsweredInTheSeriesKeepsTheNoPictureMessage() { var streak = NoPictureStreak() + let address = "192.168.1.1:9876" // Nothing answered yet: the usual messages. - XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noReply(usb: false)) - XCTAssertEqual(streak.timedOut(.routerAddress, target: "192.168.1.1:9876"), .routerAddress) - _ = streak.timedOut(.noPicture, target: "192.168.1.1:9876") + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) + XCTAssertEqual(streak.shown(.routerAddress, at: address), .routerAddress) + streak.answered(by: "Desk PC", at: address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false), + "a PC that answered but hasn't failed yet") + streak.endedWithoutPicture() // Silent while EternalMonitor restarts on the PC that answered. - XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noPicture) - XCTAssertEqual(streak.timedOut(.routerAddress, target: "192.168.1.1:9876"), .noPicture) - XCTAssertEqual(streak.timeouts, 1, "a silent attempt isn't a no-picture timeout") + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) + XCTAssertEqual(streak.shown(.routerAddress, at: address), .noPicture) // Try again keeps it: the PC still answered before. streak.restartCount() - XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.1:9876"), .noPicture) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) // Something the user must fix still shows as itself. - XCTAssertEqual(streak.timedOut(.receiverNotReady, target: "192.168.1.1:9876"), .receiverNotReady) - // Another PC never answered. - XCTAssertEqual(streak.timedOut(.noReply(usb: false), target: "192.168.1.30:9876"), .noReply(usb: false)) - // The cable, after it answered. - _ = streak.timedOut(.noPicture, target: "USB:9877") - XCTAssertEqual(streak.timedOut(.noReply(usb: true), target: "USB:9877"), .noPicture) + XCTAssertEqual(streak.shown(.receiverNotReady, at: address), .receiverNotReady) + // Another address never answered. + XCTAssertEqual(streak.shown(.noReply(usb: false), at: "192.168.1.30:9876"), .noReply(usb: false)) + // The cable doesn't say which PC is on it, so a silent one keeps its own advice. + XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) + streak.answered(by: "Desk PC", at: nil) + streak.endedWithoutPicture() + XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) + // Another PC answering ends the series. + streak.answered(by: "Studio PC", at: address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) } func testAPairingCodeTypedAsTheAddressIsCaughtBeforeConnecting() { diff --git a/ios/EternalMonitorTests/FakeUDPHost.swift b/ios/EternalMonitorTests/FakeUDPHost.swift index eeab954c..a06d4633 100644 --- a/ios/EternalMonitorTests/FakeUDPHost.swift +++ b/ios/EternalMonitorTests/FakeUDPHost.swift @@ -59,13 +59,14 @@ final class FakeUDPHost: @unchecked Sendable { return false } - /// The HELLO_ACK for the attempt with `nonce`: session 41, unless - /// `status` refuses it. - static func ack(nonce: UInt32, status: HelloStatus = .ok, acceptedVersion: UInt8 = 2) -> Data { + /// The HELLO_ACK for the attempt with `nonce` from the PC `hostName`: + /// session 41, unless `status` refuses it. + static func ack(nonce: UInt32, status: HelloStatus = .ok, acceptedVersion: UInt8 = 2, + hostName: String = "Test PC") -> Data { Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( status: status, acceptedVersion: acceptedVersion, clientNonce: nonce, sessionId: status == .ok ? 41 : 0, heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, - streamConfig: StreamConfig(), hostName: "Test PC", authToken: Data(17...32), hostCaps: 0))) + streamConfig: StreamConfig(), hostName: hostName, authToken: Data(17...32), hostCaps: 0))) } private func send(_ datagram: Data) { diff --git a/ios/EternalMonitorTests/USBLinkFailureTests.swift b/ios/EternalMonitorTests/USBLinkFailureTests.swift index 706b78b6..6e0dd6b9 100644 --- a/ios/EternalMonitorTests/USBLinkFailureTests.swift +++ b/ios/EternalMonitorTests/USBLinkFailureTests.swift @@ -365,9 +365,9 @@ final class USBLinkFailureTests: XCTestCase { // MARK: A PC that answers over the cable but sends no picture, then // connects the cable again by itself a second later. - /// Answers the iPad's HELLO2 on the cable's far end `fd`, then sends - /// nothing more. False after five quiet seconds. - nonisolated private static func acceptSession(on fd: Int32) -> Bool { + /// Answers the iPad's HELLO2 on the cable's far end `fd` as the PC + /// `name`, then sends nothing more. False after five quiet seconds. + nonisolated private static func acceptSession(on fd: Int32, name: String = "Test PC") -> Bool { var timeout = timeval(tv_sec: 0, tv_usec: 300_000) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) var framer = EMLinkFramer(expectsPreamble: false) @@ -378,7 +378,7 @@ final class USBLinkFailureTests: XCTestCase { guard count > 0, let datagrams = try? framer.append(Data(buffer.prefix(count))) else { continue } for datagram in datagrams { guard case .hello2(let hello) = Wire.parseControl(datagram)?.message, - let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce)) else { continue } + let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce, hostName: name)) else { continue } return framed.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } == framed.count } } @@ -427,6 +427,134 @@ final class USBLinkFailureTests: XCTestCase { XCTAssertEqual(manager.state, .connecting, "Try again takes the cable again") } + /// The PC connects the cable again, as it does a second after each + /// close, and offers it to the iPad. + private func plugIn(_ manager: ConnectionManager) async throws -> FramedLink { + let cable = try await openCable() + manager.acceptUSBLink(cable) + return cable + } + + /// The PC `name` answers on the newest cable and sends no picture. + private func answer(as name: String = "Test PC") async { + let fd = peer + let accepted = await Task.detached { Self.acceptSession(on: fd, name: name) }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2") + } + + /// A PC on 0.3.1 restarts its stream when its second screen starts, and + /// that closed the cable a few seconds after it answered, before the + /// iPad's timeout; a second later it connected the cable again. Each of + /// those counts as a no-picture attempt, whether the cable closes or + /// fails, so the loop stops at the limit. A cable that ends before the + /// PC answers doesn't count. + func testAPCThatAnswersAndThenEndsTheCableStopsAtTheLimit() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Try again must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + enableWiFiFallback(to: FakeUDPHost()) // the saved Wi-Fi PC, silent + // The cable comes back during the fallback's first wait, long before + // the connect timeout. + let timing = ConnectionManager.Timing(connectTimeoutSeconds: 5, reconnectGraceSeconds: 0.1, + reconnectBackoffSeconds: { _ in 1 }) + for fails in [false, true] { + let manager = ConnectionManager(timing: timing) + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() }; manager.cancel() } + let ending = fails ? "fails" : "closes" + for attempt in 1...NoPictureStreak.limit { + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "unanswered cable \(attempt) is taken, \(ending)") + if fails { injectFramingFailure() } else { XCTAssertEqual(shutdown(peer, SHUT_WR), 0) } + try await until("unanswered cable \(attempt) ends") { manager.state == .disconnected } + } + for attempt in 1...NoPictureStreak.limit { + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "attempt \(attempt) takes the cable, \(ending)") + await answer() + try await until("attempt \(attempt) is accepted") { manager.hostInfo != nil } + if fails { injectFramingFailure() } else { XCTAssertEqual(shutdown(peer, SHUT_WR), 0) } + try await until("attempt \(attempt) ends") { manager.state == .disconnected } + if attempt < NoPictureStreak.limit { + XCTAssertEqual(manager.reconnectAttempt, 1, "one attempt without a picture falls back as before, \(ending)") + } + } + XCTAssertEqual(manager.problem, .noPicture, ending) + XCTAssertEqual(manager.problem?.actions, [.retry]) + XCTAssertEqual(manager.reconnectAttempt, 0, "no fallback after the limit, \(ending)") + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused, \(ending)") + XCTAssertNotNil(manager.diagnostics.first { $0.message.hasPrefix("Stopped reconnecting after 2 no-picture") }) + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .disconnected, "the PC's next cable connection is not taken, \(ending)") + XCTAssertEqual(manager.problem, .noPicture) + } + } + + /// Leaving the app and coming back keeps the cable stopped: only Try + /// again takes it again. + func testTheCableStopOutlastsLeavingTheApp() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + // Foregrounding must not open the app's real USB listener port. + UserDefaults.standard.set(false, forKey: "allowUSB") + let (manager, first) = try await connectOverCable(timing: fast) + var cables = [first] + defer { cables.forEach { $0.stop() }; manager.cancel() } + for attempt in 1...NoPictureStreak.limit { + if attempt > 1 { cables.append(try await plugIn(manager)) } + await answer() + try await until("attempt \(attempt) times out") { manager.state == .disconnected } + } + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "the cable is paused") + manager.handleAppBackgrounded() + manager.handleAppForegrounded() + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .disconnected, "the cable stays stopped after the app comes back") + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.problem?.actions, [.retry]) + + manager.retry() + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "Try again takes the cable again") + } + + /// Two PCs on the cable, each without a picture once, don't add up: the + /// count belongs to the PC that answered, by the name it gives. A cable + /// on which nothing answers gets its own advice, since the cable doesn't + /// say which PC is at its other end, and doesn't end the count. + func testPCsOnTheCableCountSeparately() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + UserDefaults.standard.set(false, forKey: "allowUSB") + let (manager, first) = try await connectOverCable(timing: fast) + var cables = [first] + defer { cables.forEach { $0.stop() }; manager.cancel() } + await answer(as: "Desk PC") + try await until("the first PC times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "another PC takes the cable") + await answer(as: "Studio PC") + try await until("the second PC times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertNil(manager.diagnostics.first { $0.message.hasPrefix("Stopped reconnecting") }, + "one timeout at each of two PCs stops nothing") + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "the cable is still taken") + try await until("nothing answers on the cable") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noReply(usb: true), "a silent cable keeps its own advice") + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting) + await answer(as: "Studio PC") + try await until("the second PC times out again") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + XCTAssertEqual(manager.usbStatus, "USB: disconnected", "its second timeout stops the cable") + } + // MARK: Answers from the PC during the fallback. Busy and a restart pass; // the cycle keeps retrying. From e52fb384c23c10bcdafbbb91b850e8e784d35797 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 21:47:25 -0400 Subject: [PATCH 4/5] Wait for the reconnect cycle to start in the cable close test --- ios/EternalMonitorTests/USBLinkFailureTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/EternalMonitorTests/USBLinkFailureTests.swift b/ios/EternalMonitorTests/USBLinkFailureTests.swift index 6e0dd6b9..6865d764 100644 --- a/ios/EternalMonitorTests/USBLinkFailureTests.swift +++ b/ios/EternalMonitorTests/USBLinkFailureTests.swift @@ -477,7 +477,9 @@ final class USBLinkFailureTests: XCTestCase { if fails { injectFramingFailure() } else { XCTAssertEqual(shutdown(peer, SHUT_WR), 0) } try await until("attempt \(attempt) ends") { manager.state == .disconnected } if attempt < NoPictureStreak.limit { - XCTAssertEqual(manager.reconnectAttempt, 1, "one attempt without a picture falls back as before, \(ending)") + try await until("one attempt without a picture falls back as before, \(ending)") { + manager.reconnectAttempt == 1 + } } } XCTAssertEqual(manager.problem, .noPicture, ending) From 0cfd42e69ca9bdd59afa1ae20bc91c314a4ab4e3 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Mon, 26 Oct 2026 23:05:24 -0400 Subject: [PATCH 5/5] Count no-picture attempts by the PC's pairing token and keep its Wi-Fi address Two PCs with the same name no longer share a count: the streak follows the pairing token the PC gives when it answers, and falls back to the name only when pairing is off. An answer over the cable keeps the address where the same PC last answered over Wi-Fi. --- .../App/ConnectionManager.swift | 3 +- .../App/ConnectionProblem.swift | 28 +++++---- ios/EternalMonitor/Core/PairingStore.swift | 2 +- .../ConnectionProblemTests.swift | 46 +++++++++----- ios/EternalMonitorTests/FakeUDPHost.swift | 8 +-- .../USBLinkFailureTests.swift | 60 +++++++++++++++---- 6 files changed, 102 insertions(+), 45 deletions(-) diff --git a/ios/EternalMonitor/App/ConnectionManager.swift b/ios/EternalMonitor/App/ConnectionManager.swift index a6db1dd1..88032142 100644 --- a/ios/EternalMonitor/App/ConnectionManager.swift +++ b/ios/EternalMonitor/App/ConnectionManager.swift @@ -523,7 +523,8 @@ final class ConnectionManager: ObservableObject { Task { @MainActor in guard let self, self.currentAttempt.withLock({ $0 == connectionID }) else { return } self.sessionAcceptedAt = acceptedAt - self.noPictureStreak.answered(by: info.hostName, at: isUSB ? nil : "\(normalizedHost):\(port)") + self.noPictureStreak.answered(by: info.hostName, pairingToken: info.authToken, + at: isUSB ? nil : "\(normalizedHost):\(port)") self.livenessTimeoutUs = UInt64(info.livenessTimeoutMs) * 1000 self.hostInfo = info if self.awaitingPairing { diff --git a/ios/EternalMonitor/App/ConnectionProblem.swift b/ios/EternalMonitor/App/ConnectionProblem.swift index eef99225..2b5d1e4c 100644 --- a/ios/EternalMonitor/App/ConnectionProblem.swift +++ b/ios/EternalMonitor/App/ConnectionProblem.swift @@ -260,11 +260,18 @@ struct NoPictureStreak: Equatable { /// a second one means it fails the same way every time. static let limit = 2 - /// The PC that answered: the name it gave when it answered, or its - /// address if it gave none. - private(set) var pc: String? - /// Where that PC last answered, as "address:port"; nil over the cable, - /// which doesn't say which PC is at its other end. + /// A PC by its pairing token, which it keeps across restarts and gives + /// over Wi-Fi and the cable alike. With pairing off it gives none, so + /// the name it gave, or its address if it gave none either. + enum PC: Equatable { + case paired(Data) + case named(String) + } + + /// The PC that answered. + private(set) var pc: PC? + /// Where that PC last answered over Wi-Fi, as "address:port". The cable + /// doesn't say where the PC is on Wi-Fi, so an answer over it keeps this. private(set) var address: String? private(set) var timeouts = 0 /// That PC answered and sent no picture in this series. @@ -272,15 +279,16 @@ struct NoPictureStreak: Equatable { var stopsReconnecting: Bool { timeouts >= Self.limit } - /// The PC `name` answered at `address` (nil over the cable). Another PC - /// starts a new series. - mutating func answered(by name: String, at address: String?) { - let pc = name.isEmpty ? address ?? "USB" : name + /// The PC `name` with pairing token `pairingToken` answered at `address` + /// (nil over the cable). Another PC starts a new series. + mutating func answered(by name: String, pairingToken: Data, at address: String?) { + let pc: PC = PairingStore.valid(pairingToken) ? .paired(pairingToken) + : .named(name.isEmpty ? address ?? "USB" : name) if pc != self.pc { self = NoPictureStreak() self.pc = pc } - self.address = address + if let address { self.address = address } } /// The attempt the PC answered ended without a picture: the timeout diff --git a/ios/EternalMonitor/Core/PairingStore.swift b/ios/EternalMonitor/Core/PairingStore.swift index b864da22..22e44ac0 100644 --- a/ios/EternalMonitor/Core/PairingStore.swift +++ b/ios/EternalMonitor/Core/PairingStore.swift @@ -69,7 +69,7 @@ final class PairingStore: ObservableObject { return "\(host.contains(":") ? "[\(host)]" : host):\(port)" } - static func valid(_ token: Data) -> Bool { token.count == 16 && token.contains(where: { $0 != 0 }) } + nonisolated static func valid(_ token: Data) -> Bool { token.count == 16 && token.contains(where: { $0 != 0 }) } func token(address: String) throws -> Data? { try storage.records().first { $0.account.hasSuffix("|\(address)") && Self.valid($0.token) }?.token diff --git a/ios/EternalMonitorTests/ConnectionProblemTests.swift b/ios/EternalMonitorTests/ConnectionProblemTests.swift index d8447735..5e48dff6 100644 --- a/ios/EternalMonitorTests/ConnectionProblemTests.swift +++ b/ios/EternalMonitorTests/ConnectionProblemTests.swift @@ -25,16 +25,20 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertTrue(ConnectionProblem.noPicture.canReport) } + /// Pairing tokens two PCs give when pairing is on, and the token a PC + /// gives with pairing off. + private let desk = Data(17...32), studio = Data(33...48), unpaired = Data(count: 16) + func testNoPictureAttemptsCountPerPCUpToTheLimit() { var streak = NoPictureStreak() XCTAssertEqual(NoPictureStreak.limit, 2) // Nothing answered: nothing to count. streak.endedWithoutPicture() XCTAssertEqual(streak.timeouts, 0) - streak.answered(by: "Desk PC", at: "192.168.1.20:9876") + streak.answered(by: "Desk PC", pairingToken: desk, at: "192.168.1.20:9876") streak.endedWithoutPicture() XCTAssertFalse(streak.stopsReconnecting, "one attempt without a picture gets another") - streak.answered(by: "Desk PC", at: "192.168.1.20:9876") + streak.answered(by: "Desk PC", pairingToken: desk, at: "192.168.1.20:9876") streak.endedWithoutPicture() XCTAssertTrue(streak.stopsReconnecting) // Try again starts a new count. @@ -44,25 +48,32 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertFalse(streak.stopsReconnecting) } - /// The count belongs to the PC by the name it answers with, not to the - /// address or the cable: two PCs on one cable count separately, and one - /// PC at a new address or on the cable counts on. + /// The count belongs to the PC by its pairing token, not to its name, + /// the address or the cable: two PCs with one name count separately, + /// and one PC renamed, at a new address or on the cable counts on. func testNoPictureAttemptsCountByThePCThatAnswered() { var streak = NoPictureStreak() - streak.answered(by: "Desk PC", at: nil) + streak.answered(by: "PC", pairingToken: desk, at: nil) streak.endedWithoutPicture() - streak.answered(by: "Studio PC", at: nil) - XCTAssertEqual(streak.timeouts, 0, "another PC on the cable starts a new count") + streak.answered(by: "PC", pairingToken: studio, at: nil) + XCTAssertEqual(streak.timeouts, 0, "another PC with the same name starts a new count") streak.endedWithoutPicture() XCTAssertFalse(streak.stopsReconnecting) - streak.answered(by: "Studio PC", at: "192.168.1.40:9876") + streak.answered(by: "Studio PC", pairingToken: studio, at: "192.168.1.40:9876") streak.endedWithoutPicture() - XCTAssertTrue(streak.stopsReconnecting, "the same PC over Wi-Fi counts on") - // A PC without a name counts by its address. + XCTAssertTrue(streak.stopsReconnecting, "the same PC, renamed and over Wi-Fi, counts on") + // With pairing off a PC counts by its name, or its address without one. + var named = NoPictureStreak() + named.answered(by: "Desk PC", pairingToken: unpaired, at: nil) + named.endedWithoutPicture() + named.answered(by: "Desk PC", pairingToken: unpaired, at: "192.168.1.20:9876") + XCTAssertEqual(named.timeouts, 1) + named.answered(by: "Studio PC", pairingToken: unpaired, at: "192.168.1.20:9876") + XCTAssertEqual(named.timeouts, 0) var unnamed = NoPictureStreak() - unnamed.answered(by: "", at: "192.168.1.20:9876") + unnamed.answered(by: "", pairingToken: unpaired, at: "192.168.1.20:9876") unnamed.endedWithoutPicture() - unnamed.answered(by: "", at: "192.168.1.30:9876") + unnamed.answered(by: "", pairingToken: unpaired, at: "192.168.1.30:9876") XCTAssertEqual(unnamed.timeouts, 0) } @@ -72,7 +83,7 @@ final class ConnectionProblemTests: XCTestCase { // Nothing answered yet: the usual messages. XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) XCTAssertEqual(streak.shown(.routerAddress, at: address), .routerAddress) - streak.answered(by: "Desk PC", at: address) + streak.answered(by: "Desk PC", pairingToken: desk, at: address) XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false), "a PC that answered but hasn't failed yet") streak.endedWithoutPicture() @@ -88,11 +99,14 @@ final class ConnectionProblemTests: XCTestCase { XCTAssertEqual(streak.shown(.noReply(usb: false), at: "192.168.1.30:9876"), .noReply(usb: false)) // The cable doesn't say which PC is on it, so a silent one keeps its own advice. XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) - streak.answered(by: "Desk PC", at: nil) + streak.answered(by: "Desk PC", pairingToken: desk, at: nil) streak.endedWithoutPicture() XCTAssertEqual(streak.shown(.noReply(usb: true), at: nil), .noReply(usb: true)) + // The same PC answering over the cable keeps where it answered over Wi-Fi. + XCTAssertEqual(streak.address, address) + XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noPicture) // Another PC answering ends the series. - streak.answered(by: "Studio PC", at: address) + streak.answered(by: "Desk PC", pairingToken: studio, at: address) XCTAssertEqual(streak.shown(.noReply(usb: false), at: address), .noReply(usb: false)) } diff --git a/ios/EternalMonitorTests/FakeUDPHost.swift b/ios/EternalMonitorTests/FakeUDPHost.swift index a06d4633..9a79174a 100644 --- a/ios/EternalMonitorTests/FakeUDPHost.swift +++ b/ios/EternalMonitorTests/FakeUDPHost.swift @@ -59,14 +59,14 @@ final class FakeUDPHost: @unchecked Sendable { return false } - /// The HELLO_ACK for the attempt with `nonce` from the PC `hostName`: - /// session 41, unless `status` refuses it. + /// The HELLO_ACK for the attempt with `nonce` from the PC `hostName` + /// with pairing token `authToken`: session 41, unless `status` refuses it. static func ack(nonce: UInt32, status: HelloStatus = .ok, acceptedVersion: UInt8 = 2, - hostName: String = "Test PC") -> Data { + hostName: String = "Test PC", authToken: Data = Data(17...32)) -> Data { Wire.encodeControl(sessionId: 0, msgSeq: 1, message: .helloAck(HelloAck( status: status, acceptedVersion: acceptedVersion, clientNonce: nonce, sessionId: status == .ok ? 41 : 0, heartbeatIntervalMs: 1000, reportIntervalMs: 500, livenessTimeoutMs: 3000, - streamConfig: StreamConfig(), hostName: hostName, authToken: Data(17...32), hostCaps: 0))) + streamConfig: StreamConfig(), hostName: hostName, authToken: authToken, hostCaps: 0))) } private func send(_ datagram: Data) { diff --git a/ios/EternalMonitorTests/USBLinkFailureTests.swift b/ios/EternalMonitorTests/USBLinkFailureTests.swift index 6865d764..76c4522b 100644 --- a/ios/EternalMonitorTests/USBLinkFailureTests.swift +++ b/ios/EternalMonitorTests/USBLinkFailureTests.swift @@ -366,8 +366,10 @@ final class USBLinkFailureTests: XCTestCase { // connects the cable again by itself a second later. /// Answers the iPad's HELLO2 on the cable's far end `fd` as the PC - /// `name`, then sends nothing more. False after five quiet seconds. - nonisolated private static func acceptSession(on fd: Int32, name: String = "Test PC") -> Bool { + /// `name` with pairing token `token`, then sends nothing more. False + /// after five quiet seconds. + nonisolated private static func acceptSession(on fd: Int32, name: String = "Test PC", + token: Data = Data(17...32)) -> Bool { var timeout = timeval(tv_sec: 0, tv_usec: 300_000) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) var framer = EMLinkFramer(expectsPreamble: false) @@ -378,7 +380,8 @@ final class USBLinkFailureTests: XCTestCase { guard count > 0, let datagrams = try? framer.append(Data(buffer.prefix(count))) else { continue } for datagram in datagrams { guard case .hello2(let hello) = Wire.parseControl(datagram)?.message, - let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce, hostName: name)) else { continue } + let framed = try? EMLinkFramer.encode(FakeUDPHost.ack(nonce: hello.clientNonce, hostName: name, + authToken: token)) else { continue } return framed.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } == framed.count } } @@ -435,10 +438,11 @@ final class USBLinkFailureTests: XCTestCase { return cable } - /// The PC `name` answers on the newest cable and sends no picture. - private func answer(as name: String = "Test PC") async { + /// The PC `name` with pairing token `token` answers on the newest cable + /// and sends no picture. + private func answer(as name: String = "Test PC", token: Data = Data(17...32)) async { let fd = peer - let accepted = await Task.detached { Self.acceptSession(on: fd, name: name) }.value + let accepted = await Task.detached { Self.acceptSession(on: fd, name: name, token: token) }.value XCTAssertTrue(accepted, "the iPad never sent HELLO2") } @@ -522,23 +526,25 @@ final class USBLinkFailureTests: XCTestCase { } /// Two PCs on the cable, each without a picture once, don't add up: the - /// count belongs to the PC that answered, by the name it gives. A cable - /// on which nothing answers gets its own advice, since the cable doesn't - /// say which PC is at its other end, and doesn't end the count. + /// count belongs to the PC that answered, by its pairing token, even + /// when both PCs have the same name. A cable on which nothing answers + /// gets its own advice, since the cable doesn't say which PC is at its + /// other end, and doesn't end the count. func testPCsOnTheCableCountSeparately() async throws { let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } UserDefaults.standard.set(false, forKey: "allowUSB") + let desk = Data(17...32), studio = Data(33...48) let (manager, first) = try await connectOverCable(timing: fast) var cables = [first] defer { cables.forEach { $0.stop() }; manager.cancel() } - await answer(as: "Desk PC") + await answer(token: desk) try await until("the first PC times out") { manager.state == .disconnected } XCTAssertEqual(manager.problem, .noPicture) cables.append(try await plugIn(manager)) - XCTAssertEqual(manager.state, .connecting, "another PC takes the cable") - await answer(as: "Studio PC") + XCTAssertEqual(manager.state, .connecting, "another PC with the same name takes the cable") + await answer(token: studio) try await until("the second PC times out") { manager.state == .disconnected } XCTAssertEqual(manager.problem, .noPicture) XCTAssertNil(manager.diagnostics.first { $0.message.hasPrefix("Stopped reconnecting") }, @@ -551,12 +557,40 @@ final class USBLinkFailureTests: XCTestCase { cables.append(try await plugIn(manager)) XCTAssertEqual(manager.state, .connecting) - await answer(as: "Studio PC") + await answer(token: studio) try await until("the second PC times out again") { manager.state == .disconnected } XCTAssertEqual(manager.problem, .noPicture) XCTAssertEqual(manager.usbStatus, "USB: disconnected", "its second timeout stops the cable") } + /// The PC answered over Wi-Fi without a picture, then over the cable. + /// Silent at its Wi-Fi address afterwards, it is most likely restarting + /// EternalMonitor: the cable doesn't erase where it answered. + func testAnAnswerOverTheCableKeepsThePCsWiFiAddress() async throws { + let previousUSB = UserDefaults.standard.object(forKey: "allowUSB") + defer { UserDefaults.standard.set(previousUSB, forKey: "allowUSB") } + UserDefaults.standard.set(false, forKey: "allowUSB") + let wifi = FakeUDPHost() + let manager = ConnectionManager(timing: fast) + var cables: [FramedLink] = [] + defer { cables.forEach { $0.stop() }; manager.cancel() } + manager.connect(host: "127.0.0.1", port: wifi.port) + let accepted = await Task.detached { wifi.acceptSession() }.value + XCTAssertTrue(accepted, "the iPad never sent HELLO2 over Wi-Fi") + try await until("the Wi-Fi attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + cables.append(try await plugIn(manager)) + XCTAssertEqual(manager.state, .connecting, "the same PC's cable is taken") + await answer() + try await until("the cable attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture) + + manager.connect(host: "127.0.0.1", port: wifi.port) // nothing answers now + try await until("the silent Wi-Fi attempt times out") { manager.state == .disconnected } + XCTAssertEqual(manager.problem, .noPicture, "the PC answered at this address before") + } + // MARK: Answers from the PC during the fallback. Busy and a restart pass; // the cycle keeps retrying.