From 1cb4a5d79ab0cebddcf0582ce453b833878cf963 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 07:55:15 -0400 Subject: [PATCH 1/6] feat: follow the controller's Bluetooth-then-Wi-Fi sequence --- apps/ios/Origin89/SetupView.swift | 45 ++-- .../SetupBench/Sources/SetupBench/main.swift | 29 ++- .../SetupKit/Sources/SetupKit/SetupFlow.swift | 204 ++++++++++++++++-- .../ControllerMismatchTests.swift | 8 +- .../Tests/SetupKitTests/SetupFlowTests.swift | 52 +++-- .../Tests/SetupKitTests/WiFiFlowTests.swift | 70 +++++- .../SetupKitTests/WiFiHandoverTests.swift | 147 ++++++++++++- 7 files changed, 493 insertions(+), 62 deletions(-) diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index afa59a1..f9dddd8 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -499,8 +499,12 @@ private struct OpenWindowView: View { ) } Origin89Notice( - "Press the pairing button on the controller's panel. The window stays open for 120 seconds, and pairing must finish inside it." + "Press the pairing button on the controller's panel. A controller no phone has paired with also opens it for 120 seconds after it powers on. The window stays open for 120 seconds, and pairing must finish inside it." ) + Text( + "This phone connects over Bluetooth once the window is open. The controller's Wi-Fi is off until pairing ends." + ) + .foregroundStyle(.secondary) Button("The window is open, pair now", action: opened) .buttonStyle(.borderedProminent) Spacer() @@ -561,13 +565,23 @@ private struct WrittenView: View { var body: some View { Form { Section { - Text("Network settings saved") - Origin89Status("Saved", tone: .nominal) - Text( - flow.reportsWiFi - ? "The controller accepted version \(version) and passes it to its radio. It may drop the Bluetooth connection while it joins the network. Once it joins, this phone continues over Wi-Fi if it is on the same network." - : "The controller accepted version \(version) and passes it to its radio. Watch the module join the network." - ) + if flow.joinsHeldNetwork { + Text("Network settings kept") + Origin89Status("Kept", tone: .nominal) + Text( + "The controller already holds \(flow.network?.ssid ?? "a network") and joins it now that pairing ended. It may drop the Bluetooth connection while it joins. Once it joins, this phone continues over Wi-Fi if it is on the same network." + ) + } else { + Text("Network settings saved") + Origin89Status("Saved", tone: .nominal) + Text( + flow.reportsWiFi + ? "The controller accepted version \(version) and passes it to its radio. It may drop the Bluetooth connection while it joins the network. Once it joins, this phone continues over Wi-Fi if it is on the same network." + : "The controller accepted version \(version) and passes it to its radio. Watch the module join the network." + ) + } + Button("Choose another network") { Task { await flow.changeNetwork() } } + .disabled(flow.isSwitchingToWiFi) } if flow.reportsWiFi { JoinSection(join: flow.join, flow: flow) } LinkSection(flow: flow) @@ -649,10 +663,17 @@ private struct JoinSection: View { Button("Check again") { Task { await flow.watchJoinAgain() } } case .connectionLost: Origin89Status("Unknown", tone: .warning) - Text( - "The Bluetooth connection ended before the controller said whether it joined. The network settings are saved." - ) - Button("Check again") { Task { await flow.watchJoinAgain() } } + if let unavailable = flow.wifiUnavailable { + Text( + "The Bluetooth connection ended before the controller said whether it joined, and this phone did not find it on Wi-Fi within 30 seconds. The network settings are saved." + ) + Origin89Notice(unavailable.reason) + } else { + Text( + "The Bluetooth connection ended before the controller said whether it joined. The network settings are saved." + ) + } + Button("Check again over Wi-Fi or Bluetooth") { Task { await flow.watchJoinAgain() } } } } header: { Text("Wi-Fi") diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index 27ea052..d66b38a 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -9,9 +9,11 @@ import SetupKit let usage = """ usage: setup-bench [options] - pair [--window-open] Pair with the controller whose code is in $ORIGIN89_SETUP_CODE + pair [--window-open] [--watch SECONDS] + Pair with the controller whose code is in $ORIGIN89_SETUP_CODE and read its network. Without --window-open it waits for Enter - once the pairing window is open on the panel. + once the pairing window is open on the panel. A controller that + holds a network joins it: watch that (default 60 s). read Continue from the kept enrolment and read the network section. scan Read, then run a Wi-Fi scan to its end. write --ssid NAME [--country CC] [--hostname NAME] [--watch SECONDS] @@ -282,12 +284,22 @@ func label(_ state: SetupFlow.State) -> String { } await flow.confirmWindowOpened() } - guard case .editingNetwork(let settings) = flow.state, let deviceID = flow.controller?.deviceID - else { throw BenchError("pairing stopped: \(label(flow.state))") } + guard let deviceID = flow.controller?.deviceID, let settings = flow.network else { + throw BenchError("pairing stopped: \(label(flow.state))") + } + switch flow.state { + case .editingNetwork, .written: break + default: throw BenchError("pairing stopped: \(label(flow.state))") + } try BenchFiles.ensureDirectory() try Data(deviceID.utf8).write(to: BenchFiles.lastDevice, options: [.atomic]) timeline.say("paired with \(deviceID)") report(settings) + // A controller holding a network starts its station once Pair closes the window. + if flow.joinsHeldNetwork { + timeline.say("the controller holds a network: watching it join") + await watchJoin(flow, for: try options.seconds("watch", default: 60)) + } await flow.reset() } @@ -359,7 +371,13 @@ func label(_ state: SetupFlow.State) -> String { await flow.reset() throw BenchError("write stopped") } - await watch(flow, for: watchFor) { flow in + await watchJoin(flow, for: watchFor) + await flow.reset() + } + + /// Follow the join and the move to Wi-Fi until either has an outcome. + func watchJoin(_ flow: SetupFlow, for limit: Duration) async { + await watch(flow, for: limit) { flow in if case .failed = flow.state { return true } switch flow.join { // A join is followed by the switch to Wi-Fi. @@ -371,7 +389,6 @@ func label(_ state: SetupFlow.State) -> String { } } timeline.say("final state \(label(flow.state)), join \(flow.join), link \(link(flow))") - await flow.reset() } func hold() async throws { diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index b338c5f..a8bb561 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -35,6 +35,12 @@ import Observation /// Hello and reads its network, whether or not a network was written, until /// the person starts over or the kept enrolment stops working. /// +/// Pairing follows the controller's window: Bluetooth opens only once the +/// person has opened it, since a controller whose station has joined +/// advertises only while the window is open. `Pair` closes the window, and a +/// controller holding a network starts its station then: the flow watches +/// that join instead of asking for a network to write. +/// /// On a controller that reports Wi-Fi (P-216) the flow also reads what its /// radio hears while the network is edited, and watches the join after a /// write. Both run in one background task that every other step stops and @@ -51,6 +57,11 @@ import Observation /// each time whether it is this controller (P-225); an address where Hello /// succeeds is kept. When Wi-Fi cannot be used the session stays on, or falls /// back to, Bluetooth, and `wifiUnavailable` says why. +/// +/// A station starting may drop the Bluetooth link. When that ends the join +/// watch, or takes the answer to a write, the flow looks for the controller +/// on Wi-Fi for up to `wifiLimit` and continues there; a write whose answer +/// was lost counts once the section read over Wi-Fi holds it. @MainActor @Observable public final class SetupFlow { /// After a write, what the radio did with it. public enum JoinWatch: Sendable, Equatable { @@ -68,6 +79,9 @@ import Observation static let scanLimit: Duration = .seconds(20) /// How long a write's join is watched. static let joinLimit: Duration = .seconds(60) + /// How long the flow looks for the controller on Wi-Fi after Bluetooth + /// drops while its station starts. + static let wifiLimit: Duration = .seconds(30) /// The link a session runs over. public enum Link: Sendable, Equatable { @@ -97,6 +111,17 @@ import Observation "The controller did not accept this phone over Wi-Fi, so it stays on Bluetooth." } } + /// Why Wi-Fi failed, for when Bluetooth is gone too. + public var reason: String { + switch self { + case .notReachable: + "This phone could not reach the controller over Wi-Fi. The phone must be on the same network as the controller." + case .localNetworkDenied: + "Origin89 is not allowed to use the local network. Turn on Local Network for Origin89 in Settings, then try again." + case .otherController: "Another controller answered at this controller's address." + case .refused: "The controller did not accept this phone over Wi-Fi." + } + } } /// A WebSocket session: its own transport and client, with the enrolment /// the store keeps. @@ -165,6 +190,12 @@ import Observation if let wifi { return .wifi(address: wifi.address) } return .bluetooth } + /// The controller held a network when it paired, so the flow watches it + /// join instead of writing one. False once the person writes a network. + public var joinsHeldNetwork: Bool { + guard let network, let writtenVersion else { return false } + return network.version == writtenVersion + } /// The network version this session wrote, kept across Time failures. public var writtenVersion: UInt32? { if case .written(let version) = resume { version } else { nil } @@ -284,7 +315,9 @@ import Observation if greets, webSocketFactory != nil, let deviceID = controller?.deviceID ?? lastDeviceID { // Active from here, so a suspend during the attempt ends it. transportActive = true + isSwitchingToWiFi = true let opened = await findWiFi(deviceID: deviceID, operation) + isSwitchingToWiFi = false guard generation == operation else { if let opened { await Self.close(opened.link) } return @@ -299,26 +332,35 @@ import Observation transportActive = false SetupLog.flow.notice("Wi-Fi is unavailable: connecting over Bluetooth") } + // Pairing needs the window first; `confirmWindowOpened()` connects. + if resume == .pair, !greets { + state = .openWindow + return + } + guard await openBluetooth(operation) else { return } + isConnecting = false + await openSession(operation) + } + + /// Open the Bluetooth transport. False when the open failed, with the flow + /// failed, or when the flow moved on meanwhile. + private func openBluetooth(_ operation: Int) async -> Bool { + guard let transport else { return false } transportActive = true do { try await transport.open() } catch { // A failed open leaves nothing connected. - if generation == operation { transportActive = false } - guard generation == operation else { return } + guard generation == operation else { return false } + transportActive = false await fail(Self.failure(error)) - return + return false } guard generation == operation else { await closeTransport() - return - } - if resume == .pair, !greets { - state = .openWindow - } else { - isConnecting = false - await openSession(operation) + return false } + return true } public func confirmWindowOpened() async { @@ -332,8 +374,15 @@ import Observation self.state = .failed(.windowClosed, .openWindow) await self.close() } + state = .discovering + // The window is open now, so the controller advertises. A session whose + // kept enrolment was lost is connected already. + if !transportActive { + isConnecting = true + guard await openBluetooth(operation) else { return } + isConnecting = false + } do { - state = .discovering let discovered = try await discover(client, operation) guard generation == operation else { return } controller = discovered @@ -354,7 +403,7 @@ import Observation let report = try await client.hello() guard generation == operation else { return } reportsWiFi = report.reportsWiFi - await readNetwork() + await readNetwork(afterPair: true) } catch { guard generation == operation else { return } deadlineTask?.cancel() @@ -455,7 +504,10 @@ import Observation } } - private func readNetwork() async { + /// Read the network section. Right after `Pair`, a controller holding a + /// network is starting its station: the flow watches that join as if the + /// network were just written. + private func readNetwork(afterPair: Bool = false) async { guard let client = session else { return } let operation = generation state = .readingNetwork @@ -466,7 +518,14 @@ import Observation "network section version \(settings.version, privacy: .public), network held \(settings.ssid != nil, privacy: .public), country held \(settings.country != nil, privacy: .public)" ) network = settings - state = .editingNetwork(settings) + if afterPair, reportsWiFi, settings.ssid != nil, settings.passphraseSet { + SetupLog.flow.info("the controller holds a network: watching its station join") + resume = .written(settings.version) + state = .written(settings.version) + watchJoin(settings.version) + } else { + state = .editingNetwork(settings) + } } catch { guard generation == operation else { return } await fail(error) @@ -490,9 +549,47 @@ import Observation if reportsWiFi, change.ssid != nil { watchJoin(version) } } catch { guard generation == operation else { return } + guard await !confirmOverWiFi(change, after: settings, error) else { return } await fail(error) } } + + /// A write whose answer was lost with the Bluetooth link may have landed: + /// the station it starts can drop the link. Look for the controller on + /// Wi-Fi and read the section there; a newer version holding the SSID + /// written is this write, and the flow continues from it over Wi-Fi. True + /// when the flow went on without the failure: the write was confirmed, or + /// the flow moved on meanwhile. + private func confirmOverWiFi( + _ change: NetworkChange, after settings: NetworkSettings, _ failure: SetupFailure + ) async -> Bool { + guard failure == .connectionDropped || failure == .timedOut, reportsWiFi, + let ssid = change.ssid, wifi == nil, webSocketFactory != nil, + let deviceID = controller?.deviceID + else { return false } + SetupLog.flow.notice("the write's answer was lost: confirming it over Wi-Fi") + await close() + let operation = generation + guard let opened = await reachWiFi(deviceID: deviceID, operation) else { + return generation != operation + } + guard await adopt(opened, operation) else { return true } + let held: NetworkSettings + do { + held = try await opened.link.client.readNetwork() + } catch { + return generation != operation + } + guard generation == operation else { return true } + guard held.version > settings.version, held.ssid == ssid else { + SetupLog.flow.notice("the section read over Wi-Fi does not hold the write") + return false + } + resume = .written(held.version) + state = .written(held.version) + watchJoin(held.version) + return true + } /// The optional signed Time. Success finishes setup and closes the /// connection. A refusal keeps the session for another try; a lost /// connection, a timeout or a bad reply ends it, and retry reconnects. @@ -619,10 +716,85 @@ import Observation ) // Before closing, so the watch never reads as idle in between. join = .connectionLost + // A station starting may have dropped Bluetooth: look on Wi-Fi. A stop + // leaves the next step to reconnect. Read before `close()` cancels this task. + guard wifi == nil, !Task.isCancelled, webSocketFactory != nil, + let deviceID = controller?.deviceID + else { + await close() + return + } + // Set first, so the search never reads as over in between. + isSwitchingToWiFi = true + let next = generation + 1 await close() + // A suspend or step during the close ends the search before it starts. + guard generation == next else { + isSwitchingToWiFi = false + return + } + join = .waiting + backgroundTask = Task { [weak self] in + await self?.continueOverWiFi(version, deviceID: deviceID, next) + } } } + /// Bluetooth ended during the join watch. Find the controller on Wi-Fi and + /// watch the join there, or leave the join `connectionLost` with the reason + /// in `wifiUnavailable`. + private func continueOverWiFi(_ version: UInt32, deviceID: String, _ operation: Int) async { + let opened = await reachWiFi(deviceID: deviceID, operation) + guard generation == operation else { + if let opened { await Self.close(opened.link) } + return + } + backgroundTask = nil + guard let opened else { + join = .connectionLost + return + } + guard await adopt(opened, operation) else { return } + join = .idle + // Stopped meanwhile: the step that stopped it goes on over Wi-Fi. + if !Task.isCancelled { watchJoin(version) } + } + + /// Look for the controller on Wi-Fi again every `pollInterval`, up to + /// `wifiLimit`, with nothing else open. The link counts as active meanwhile, + /// so a suspend ends the search. Nil when no attempt worked or the flow + /// moved on. + private func reachWiFi(deviceID: String, _ operation: Int) async -> WiFiSession? { + transportActive = true + isSwitchingToWiFi = true + defer { isSwitchingToWiFi = false } + let deadline = clock.now + Self.wifiLimit + while generation == operation { + if let opened = await findWiFi(deviceID: deviceID, operation) { return opened } + guard generation == operation, !Task.isCancelled, clock.now < deadline else { break } + do { try await clock.sleep(until: clock.now + Self.pollInterval) } catch { break } + } + if generation == operation { + SetupLog.flow.notice("the controller was not found on Wi-Fi") + transportActive = false + } + return nil + } + + /// Make an opened Wi-Fi session the flow's link. False, with it closed, + /// when the flow moved on. + private func adopt(_ opened: WiFiSession, _ operation: Int) async -> Bool { + guard generation == operation, transportActive else { + await Self.close(opened.link) + return false + } + wifi = opened.link + controller = opened.found + reportsWiFi = opened.report.reportsWiFi + SetupLog.flow.info("the session continues over Wi-Fi") + return true + } + /// Watch the join of the written network again, reconnecting if needed. A /// reconnect that fails in a way a retry would reconnect from returns to /// the written network with no verdict. @@ -684,9 +856,11 @@ import Observation private func switchToWiFi(_ operation: Int) async { guard wifi == nil, transportActive, let deviceID = controller?.deviceID else { return } // Its own task, so stopping the background task does not cut it short. + isSwitchingToWiFi = true let opened = await Task { [weak self] in await self?.findWiFi(deviceID: deviceID, operation) }.value + isSwitchingToWiFi = false guard let opened else { SetupLog.flow.notice("staying on Bluetooth") return @@ -712,8 +886,6 @@ import Observation /// alone. private func findWiFi(deviceID: String, _ operation: Int) async -> WiFiSession? { guard webSocketFactory != nil else { return nil } - isSwitchingToWiFi = true - defer { isSwitchingToWiFi = false } var tried: Set = [] func attempt(_ address: String) async -> WiFiSession? { guard generation == operation, tried.insert(address).inserted else { return nil } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift index 87beb50..adaff5c 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/ControllerMismatchTests.swift @@ -103,8 +103,10 @@ private struct PeerFactory: ControllerClientFactory { #expect(flow.state == .failed(.controllerMismatch, .connecting)) await flow.retry() #expect(flow.state == .openWindow) - #expect(driver.scans == [[], [wrong], []]) - #expect(driver.peer == wrong) + // Bluetooth opens again once the window is open, excluding nobody. + #expect(driver.scans == [[], [wrong]]) + await flow.confirmWindowOpened() + #expect(Array(driver.scans.prefix(3)) == [[], [wrong], []]) #expect(driver.mostConnections == 1) } @@ -135,6 +137,6 @@ private struct PeerFactory: ControllerClientFactory { #expect(driver.connections == 0) await flow.retry() #expect(flow.state == .openWindow) - #expect(driver.connections == 1) + #expect(driver.connections == 0) #expect(driver.mostConnections == 1) } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift index d4c79af..bad556d 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift @@ -308,28 +308,40 @@ private struct Factory: ControllerClientFactory { #expect(await client.calls == [.discover, .pair]) } +/// Pairing opens Bluetooth only once the window is open: a controller whose +/// station has joined advertises only then. +@Test @MainActor func bluetoothOpensOnlyOnceTheWindowIsOpen() async throws { + let transport = FakeTransport() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow(FakeClient(), clock: clock, transport: transport) + #expect(await transport.opens == 0) + await flow.confirmWindowOpened() + #expect(await transport.opens == 1) +} + @Test @MainActor func bluetoothUnavailableDuringConnection() async throws { - let flow = SetupFlow( - factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), - transportFactory: { FakeTransport(failure: .unreachable) } - ) - try flow.submitCode("valid") - #expect(flow.state == .connecting) - await flow.connect() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow( + FakeClient(), clock: clock, transport: FakeTransport(failure: .unreachable)) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.bluetoothUnavailable, .connecting)) + // The retry goes back to the window step, still without a connection. await flow.retry() + #expect(flow.state == .openWindow) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.bluetoothUnavailable, .connecting)) } /// A controller was found but its link never became ready: not reported as /// Bluetooth being unavailable. @Test @MainActor func aLinkThatIsNotReadyIsReportedAsSuch() async throws { - let flow = SetupFlow( - factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), - transportFactory: { FakeTransport(failure: .notReady) } - ) - try flow.submitCode("valid") - await flow.connect() + let clock = TestClock() + defer { clock.finish() } + let flow = try await makeFlow( + FakeClient(), clock: clock, transport: FakeTransport(failure: .notReady)) + await flow.confirmWindowOpened() #expect(flow.state == .failed(.linkNotReady, .connecting)) } @@ -484,7 +496,7 @@ private struct Factory: ControllerClientFactory { #expect(await transport.closes == 1) await flow.retry() #expect(flow.state == .openWindow) - #expect(await transport.opens == 2) + #expect(await transport.opens == 1) #expect(await transport.mostOpen == 1) clock.finish() } @@ -519,20 +531,18 @@ private struct Factory: ControllerClientFactory { #expect(await transport.mostOpen == 1) } -@Test @MainActor func backgroundWithTheWindowStepSuspendsAndReturnsToIt() async throws { +/// The window step holds no connection, so leaving the app keeps it. +@Test @MainActor func backgroundWithTheWindowStepKeepsIt() async throws { let client = FakeClient() let transport = FakeTransport() let clock = TestClock() defer { clock.finish() } let flow = try await makeFlow(client, clock: clock, transport: transport) await flow.suspend() - #expect(flow.state == .suspended) - #expect(await transport.closes == 1) - await flow.retry() #expect(flow.state == .openWindow) + #expect(await transport.opens == 0) + #expect(await transport.closes == 0) #expect(await client.calls.isEmpty) - #expect(await transport.opens == 2) - #expect(await transport.mostOpen == 1) } @Test @MainActor func backgroundBeforeAConnectOpensSuspendsIt() async throws { @@ -553,7 +563,7 @@ private struct Factory: ControllerClientFactory { #expect(await transport.opens == 0) await flow.retry() #expect(flow.state == .openWindow) - #expect(await transport.opens == 1) + #expect(await transport.opens == 0) } @Test @MainActor func backgroundDuringPairSuspendsAndAbandonsIt() async throws { diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift index f96d892..1f7449e 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift @@ -43,8 +43,9 @@ private actor WiFiClient: ControllerClient { init( reportsWiFi: Bool = true, scans: [NetworkScan] = [], statuses: [WiFiStatus] = [], + // No network held: after Pair the person chooses one. settings: NetworkSettings = NetworkSettings( - version: 7, ssid: "home", passphraseSet: true, country: "CA", hostname: "unit") + version: 7, ssid: nil, passphraseSet: false, country: "CA", hostname: "unit") ) { self.reportsWiFi = reportsWiFi self.scans = scans @@ -547,3 +548,70 @@ private let joined8 = WiFiStatus( #expect(!NetworkSecurity.open.isJoinable) #expect(!NetworkSecurity.other.isJoinable) } + +// MARK: - A controller that holds a network when it pairs + +private let home = NetworkSettings( + version: 7, ssid: "home", passphraseSet: true, country: "CA", hostname: "unit") + +/// Pair closes the window and the station joins the network the controller +/// holds: the flow watches that join and writes nothing. +@Test @MainActor func pairingAControllerThatHoldsANetworkWatchesItJoin() async throws { + let joined7 = WiFiStatus(section: 7, radio: (version: 7, state: .joined(address: "10.0.0.9"))) + let client = WiFiClient(statuses: [joined7], settings: home) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .written(7)) + #expect(flow.joinsHeldNetwork) + await settle { flow.join != .waiting } + #expect(flow.join == .joined(address: "10.0.0.9")) + #expect(await client.calls == [.discover, .pair, .hello, .read, .status]) + // The person may still choose another network. + await flow.changeNetwork() + #expect(flow.state == .editingNetwork(home)) +} + +/// Without Wi-Fi status reads there is no join to watch, and a network held +/// without its passphrase cannot be joined: both go to the network form. +@Test(arguments: [ + ( + true, + NetworkSettings(version: 7, ssid: "home", passphraseSet: false, country: "CA", hostname: "unit") + ), + (false, home), +]) +@MainActor func aHeldNetworkThatCannotBeWatchedGoesToTheForm( + reportsWiFi: Bool, settings: NetworkSettings +) async throws { + let client = WiFiClient(reportsWiFi: reportsWiFi, settings: settings) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .editingNetwork(settings)) + #expect(!flow.joinsHeldNetwork) + #expect(flow.join == .idle) +} + +/// Once the person writes a network, the written version is theirs. +@Test @MainActor func writingANetworkIsNotTheHeldOne() async throws { + let client = WiFiClient(statuses: [joining8]) + let clock = PollClock() + defer { clock.finish() } + let (flow, _) = try await editing(client, clock) + #expect(!flow.joinsHeldNetwork) + await flow.writeNetwork( + NetworkChange(ssid: "cabin", passphrase: "correct horse", country: "CA", hostname: "unit")) + #expect(flow.state == .written(8)) + #expect(!flow.joinsHeldNetwork) +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift index 3b4b80b..84a4f67 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift @@ -31,12 +31,19 @@ private actor LinkClient: ControllerClient { ControllerSummary(deviceID: deviceID)) var helloFailure: SetupFailure? var timeFailure: SetupFailure? + var statusFailure: SetupFailure? + var writeFailure: SetupFailure? var joinedAt: String? + /// The section `readNetwork` answers. + var held = settings init(joinedAt: String? = nil) { self.joinedAt = joinedAt } func answerDiscover(_ result: Result) { discovered = result } func failHello(with failure: SetupFailure?) { helloFailure = failure } func failTime(with failure: SetupFailure?) { timeFailure = failure } + func failStatus(with failure: SetupFailure?) { statusFailure = failure } + func failWrite(with failure: SetupFailure?) { writeFailure = failure } + func hold(_ settings: NetworkSettings) { held = settings } func restore(from store: any EnrolmentStore) async {} func isEnrolled() async -> Bool { true } func keep(in store: any EnrolmentStore) async throws {} @@ -52,13 +59,14 @@ private actor LinkClient: ControllerClient { } func readNetwork() async throws(SetupFailure) -> NetworkSettings { calls.append(.read) - return settings + return held } func scanWiFi(refresh: Bool) async throws(SetupFailure) -> NetworkScan { NetworkScan(progress: .none, networks: nil) } func wifiStatus() async throws(SetupFailure) -> WiFiStatus { calls.append(.status) + if let statusFailure { throw statusFailure } return WiFiStatus( section: 8, radio: (version: 8, state: joinedAt.map { .joined(address: $0) } ?? .joining)) } @@ -66,6 +74,7 @@ private actor LinkClient: ControllerClient { -> UInt32 { calls.append(.write) + if let writeFailure { throw writeFailure } return expectedVersion + 1 } func setTime(_ date: Date) async throws(SetupFailure) { @@ -129,6 +138,16 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { } } +/// DNS-SD answers that a test changes as the controller comes and goes. +@MainActor private final class FakeBrowser: ControllerBrowser { + var found: [String] = [] + private(set) var browses = 0 + func addresses(advertising deviceID: String) async -> [String] { + browses += 1 + return found + } +} + @MainActor private struct Bench { let flow: SetupFlow let bluetooth: LinkTransport @@ -145,7 +164,7 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { /// A launch that reconnects to the controller from its kept enrolment. @MainActor private func launch( bluetooth: LinkClient = LinkClient(joinedAt: address), wifi: LinkClient? = LinkClient(), - addresses: MemoryAddresses = MemoryAddresses() + addresses: MemoryAddresses = MemoryAddresses(), browser: FakeBrowser? = nil ) -> Bench { let bluetoothTransport = LinkTransport(.bluetooth) let webSocket = LinkTransport(.wifi) @@ -158,7 +177,7 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { dialled.addresses.append(address) return webSocket }, - addresses: addresses) + addresses: addresses, browser: browser) return Bench( flow: flow, bluetooth: bluetoothTransport, webSocket: webSocket, factory: factory, addresses: addresses, dialled: dialled) @@ -349,3 +368,125 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { #expect(await bench.webSocket.closes == opens) #expect(bench.flow.state == .written(8)) } + +// MARK: - Bluetooth dropping while the station starts + +/// Connect over Bluetooth with the controller not yet on the network. +@MainActor private func editingOverBluetooth(_ bench: Bench) async { + await bench.flow.connect() + #expect(bench.flow.state == .editingNetwork(settings)) + #expect(bench.flow.link == .bluetooth) +} + +/// The station starting drops Bluetooth before the join is reported: the +/// flow finds the controller over DNS-SD and reads the join over Wi-Fi. +@Test @MainActor func aBluetoothDropDuringTheJoinContinuesOverWiFi() async throws { + let bluetooth = LinkClient() + let wifi = LinkClient(joinedAt: address) + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .joined(address: address) } + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.link == .wifi(address: address)) + #expect(!bench.flow.isSwitchingToWiFi) + #expect(await bench.bluetooth.closes == 1) + #expect(await wifi.calls == [.discover, .hello, .status]) + #expect(bench.addresses.load(deviceID: deviceID) == address) +} + +/// The controller is looked for again until `wifiLimit`, then the join is +/// reported lost with the reason, and a check tries again. +@Test @MainActor func aControllerNotFoundOnWiFiIsReportedAfterTheLimit() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.webSocket.failOpens(with: .unreachable) + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .connectionLost && !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.link == nil) + #expect(bench.flow.wifiUnavailable == .notReachable) + // After the connect's browse, one at 0 s and one every 2 s up to the 30 s limit. + #expect(browser.browses == 1 + 16) + #expect(await bench.webSocket.opens == 16) + #expect(await bench.webSocket.closes == 0) +} + +/// Leaving the app during the search ends it with nothing open. +@Test @MainActor func suspendingDuringTheSearchEndsIt() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + await bench.webSocket.failOpens(with: .unreachable) + await bench.flow.writeNetwork(change) + await settle { bench.flow.isSwitchingToWiFi } + await bench.flow.suspend() + await settle { !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.link == nil) + #expect(bench.flow.state == .written(8)) + let browses = browser.browses + for _ in 0..<100 { await Task.yield() } + #expect(browser.browses == browses) +} + +/// A write whose answer was lost with Bluetooth landed when the section read +/// over Wi-Fi holds it: setup continues from it there. +@Test @MainActor func aLostWriteAnswerIsConfirmedOverWiFi() async throws { + let bluetooth = LinkClient() + let wifi = LinkClient(joinedAt: address) + await wifi.hold( + NetworkSettings(version: 8, ssid: "cabin", passphraseSet: true, country: "CA", hostname: "unit") + ) + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .written(8)) + #expect(bench.flow.writtenVersion == 8) + #expect(bench.flow.link == .wifi(address: address)) + await settle { bench.flow.join == .joined(address: address) } + #expect(await wifi.calls == [.discover, .hello, .read, .status]) +} + +/// Over Wi-Fi the section still holds the old network, or the controller is +/// not found: the write did not land as far as this phone knows. +@Test(arguments: [true, false]) +@MainActor func anUnconfirmedLostWriteFails(found: Bool) async throws { + let bluetooth = LinkClient() + let wifi = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, wifi: wifi, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .connectionDropped) + if found { browser.found = [address] } + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .failed(.connectionDropped, .connecting)) + #expect(bench.flow.writtenVersion == nil) + #expect(bench.flow.link == nil) + #expect(await bench.webSocket.closes == (found ? 1 : 0)) + #expect(await wifi.calls == (found ? [.discover, .hello, .read] : [])) +} + +/// A refusal is the controller's answer, not a lost one: nothing to confirm. +@Test @MainActor func aRefusedWriteIsNotLookedForOverWiFi() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failWrite(with: .staleVersion) + browser.found = [address] + await bench.flow.writeNetwork(change) + #expect(bench.flow.state == .failed(.staleVersion, .readingNetwork)) + #expect(browser.browses == 1) +} From 542ab176719043bdeb1749aed500f79f49f97cdb Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 08:28:48 -0400 Subject: [PATCH 2/6] feat: clear the network and write it while pairing from the bench --- .../SetupBench/Sources/SetupBench/main.swift | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index d66b38a..705527a 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -9,16 +9,19 @@ import SetupKit let usage = """ usage: setup-bench [options] - pair [--window-open] [--watch SECONDS] + pair [--window-open] [--ssid NAME] [--watch SECONDS] Pair with the controller whose code is in $ORIGIN89_SETUP_CODE and read its network. Without --window-open it waits for Enter once the pairing window is open on the panel. A controller that - holds a network joins it: watch that (default 60 s). + holds a network joins it: watch that (default 60 s). With + --ssid, a controller without one gets that network written on + the pairing connection, as write does. read Continue from the kept enrolment and read the network section. scan Read, then run a Wi-Fi scan to its end. write --ssid NAME [--country CC] [--hostname NAME] [--watch SECONDS] Read, then write the network with the passphrase in $ORIGIN89_WIFI_PASSPHRASE, and watch the join (default 60 s). + clear Read, then write no network, keeping the country and hostname. hold --seconds N Read, stay connected and idle for N seconds, then send a request. options: @@ -262,6 +265,7 @@ func label(_ state: SetupFlow.State) -> String { await flow.reset() case "scan": try await scan() case "write": try await write() + case "clear": try await clear() case "hold": try await hold() case "help": print(usage) default: throw BenchError(usage) @@ -295,6 +299,11 @@ func label(_ state: SetupFlow.State) -> String { try Data(deviceID.utf8).write(to: BenchFiles.lastDevice, options: [.atomic]) timeline.say("paired with \(deviceID)") report(settings) + // On the pairing connection: a new one may not be accepted once the window closes. + if case .editingNetwork = flow.state, let ssid = options["ssid"] { + try await write(ssid, flow, settings) + return + } // A controller holding a network starts its station once Pair closes the window. if flow.joinsHeldNetwork { timeline.say("the controller holds a network: watching it join") @@ -350,11 +359,16 @@ func label(_ state: SetupFlow.State) -> String { func write() async throws { guard let ssid = options["ssid"] else { throw BenchError("write needs --ssid") } + let (flow, settings) = try await resumed() + report(settings) + try await write(ssid, flow, settings) + } + + /// Write `ssid` on the open session, watch the join, then end the session. + func write(_ ssid: String, _ flow: SetupFlow, _ settings: NetworkSettings) async throws { let name = options["passphrase-env"] ?? "ORIGIN89_WIFI_PASSPHRASE" let passphrase = BenchFiles.secret(name, file: "wifi-passphrase") let watchFor = try options.seconds("watch", default: 60) - let (flow, settings) = try await resumed() - report(settings) var draft = NetworkDraft( ssid: ssid, settings: settings, region: Locale.current.region?.identifier) draft.passphrase = passphrase ?? "" @@ -391,6 +405,21 @@ func label(_ state: SetupFlow.State) -> String { timeline.say("final state \(label(flow.state)), join \(flow.join), link \(link(flow))") } + /// Leave the controller with no network, as a unit that was never set up. + func clear() async throws { + let (flow, settings) = try await resumed() + report(settings) + guard let country = settings.country, let hostname = settings.hostname else { + await flow.reset() + throw BenchError("not cleared: the controller holds no country or hostname to keep") + } + timeline.say("clearing the network") + await flow.writeNetwork( + NetworkChange(ssid: nil, passphrase: nil, country: country, hostname: hostname)) + timeline.say("state \(label(flow.state))") + await flow.reset() + } + func hold() async throws { let idle = try options.seconds("seconds", default: 60) let (flow, _) = try await resumed() From 75168b14498810770fca1263f01f79051681eff2 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 08:30:28 -0400 Subject: [PATCH 3/6] fix: end the Wi-Fi search on suspend and report why it failed --- .../SetupBench/Sources/SetupBench/main.swift | 4 ++- .../SetupKit/Sources/SetupKit/SetupFlow.swift | 19 ++++++++--- .../SetupKitTests/WiFiHandoverTests.swift | 32 +++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index 705527a..dd80503 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -398,7 +398,9 @@ func label(_ state: SetupFlow.State) -> String { case .joined: return options.has("bluetooth-only") || (!flow.isSwitchingToWiFi && (flow.link != .bluetooth || flow.wifiUnavailable != nil)) - case .failed, .noAnswer, .connectionLost: return true + case .failed, .noAnswer: return true + // A Wi-Fi search may follow a lost Bluetooth link. + case .connectionLost: return !flow.isSwitchingToWiFi case .idle, .waiting: return false } } diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index a8bb561..99257e9 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -568,8 +568,14 @@ import Observation let deviceID = controller?.deviceID else { return false } SetupLog.flow.notice("the write's answer was lost: confirming it over Wi-Fi") + // Set first, so a suspend during the close still ends the search. + isSwitchingToWiFi = true + let operation = generation + 1 await close() - let operation = generation + guard generation == operation else { + isSwitchingToWiFi = false + return true + } guard let opened = await reachWiFi(deviceID: deviceID, operation) else { return generation != operation } @@ -765,9 +771,11 @@ import Observation /// so a suspend ends the search. Nil when no attempt worked or the flow /// moved on. private func reachWiFi(deviceID: String, _ operation: Int) async -> WiFiSession? { + defer { isSwitchingToWiFi = false } + guard generation == operation else { return nil } transportActive = true isSwitchingToWiFi = true - defer { isSwitchingToWiFi = false } + wifiUnavailable = nil let deadline = clock.now + Self.wifiLimit while generation == operation { if let opened = await findWiFi(deviceID: deviceID, operation) { return opened } @@ -777,6 +785,8 @@ import Observation if generation == operation { SetupLog.flow.notice("the controller was not found on Wi-Fi") transportActive = false + // No candidate answered, or none was found at all. + if wifiUnavailable == nil { wifiUnavailable = .notReachable } } return nil } @@ -1000,8 +1010,9 @@ import Observation /// network stays written, since its step reconnects on its own; anything /// else in progress is suspended and resumes through retry. public func suspend() async { - // A connect still before its open counts too: closing ends it there. - guard transportActive || isConnecting else { return } + // A connect still before its open, or a Wi-Fi search about to start, + // counts too: closing ends it there. + guard transportActive || isConnecting || isSwitchingToWiFi else { return } SetupLog.flow.notice("the app left the foreground: closing the connection") await close() switch state { diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift index 84a4f67..8584428 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiHandoverTests.swift @@ -398,6 +398,38 @@ final class MemoryAddresses: ControllerAddressStore, @unchecked Sendable { #expect(bench.addresses.load(deviceID: deviceID) == address) } +/// With no candidate at all, the search still ends with a reason. +@Test @MainActor func aSearchWithNoCandidateReportsTheControllerUnreachable() async throws { + let bluetooth = LinkClient() + let bench = launch(bluetooth: bluetooth, browser: FakeBrowser()) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .connectionLost && !bench.flow.isSwitchingToWiFi } + #expect(bench.flow.wifiUnavailable == .notReachable) + #expect(bench.flow.link == nil) + #expect(await bench.webSocket.opens == 0) +} + +/// Starting over as the search is scheduled leaves nothing open. +@Test @MainActor func resettingBeforeTheSearchStartsLeavesNothingOpen() async throws { + let bluetooth = LinkClient() + let browser = FakeBrowser() + let bench = launch(bluetooth: bluetooth, browser: browser) + await editingOverBluetooth(bench) + await bluetooth.failStatus(with: .connectionDropped) + browser.found = [address] + await bench.flow.writeNetwork(change) + await settle { bench.flow.join == .waiting && bench.flow.isSwitchingToWiFi } + await bench.flow.reset() + for _ in 0..<100 { await Task.yield() } + #expect(bench.flow.state == .enterCode) + #expect(bench.flow.link == nil) + #expect(!bench.flow.isSwitchingToWiFi) + let opens = await bench.webSocket.opens + #expect(await bench.webSocket.closes == opens) +} + /// The controller is looked for again until `wifiLimit`, then the join is /// reported lost with the reason, and a check tries again. @Test @MainActor func aControllerNotFoundOnWiFiIsReportedAfterTheLimit() async throws { From 7ba6400fb489ed318e229891b8c0b7f412eb9836 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 09:22:51 -0400 Subject: [PATCH 4/6] fix: watch a held network after a reconnect that follows Pair --- .../SetupBench/Sources/SetupBench/main.swift | 4 +++ .../SetupKit/Sources/SetupKit/SetupFlow.swift | 17 ++++++--- .../Tests/SetupKitTests/WiFiFlowTests.swift | 35 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index dd80503..516a472 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -419,6 +419,10 @@ func label(_ state: SetupFlow.State) -> String { await flow.writeNetwork( NetworkChange(ssid: nil, passphrase: nil, country: country, hostname: hostname)) timeline.say("state \(label(flow.state))") + guard case .written = flow.state else { + await flow.reset() + throw BenchError("not cleared") + } await flow.reset() } diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index 99257e9..022245d 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -221,6 +221,9 @@ import Observation private var resume: Resume = .pair /// Set by a new code: the next connect first looks for a kept enrolment. private var restorePending = false + /// Set by `Pair` until a network read succeeds, across reconnects: a + /// controller holding a network starts its station once the window closes. + private var pairedAwaitingRead = false private var generation = 0 private var isConnecting = false private var deadlineTask: Task? @@ -399,11 +402,12 @@ import Observation deadlineTask?.cancel() rememberController() resume = .readNetwork + pairedAwaitingRead = true state = .greeting let report = try await client.hello() guard generation == operation else { return } reportsWiFi = report.reportsWiFi - await readNetwork(afterPair: true) + await readNetwork() } catch { guard generation == operation else { return } deadlineTask?.cancel() @@ -504,10 +508,10 @@ import Observation } } - /// Read the network section. Right after `Pair`, a controller holding a - /// network is starting its station: the flow watches that join as if the - /// network were just written. - private func readNetwork(afterPair: Bool = false) async { + /// Read the network section. The first read after `Pair`, even on a later + /// connection, finds a controller holding a network starting its station: + /// the flow watches that join as if the network were just written. + private func readNetwork() async { guard let client = session else { return } let operation = generation state = .readingNetwork @@ -518,6 +522,8 @@ import Observation "network section version \(settings.version, privacy: .public), network held \(settings.ssid != nil, privacy: .public), country held \(settings.country != nil, privacy: .public)" ) network = settings + let afterPair = pairedAwaitingRead + pairedAwaitingRead = false if afterPair, reportsWiFi, settings.ssid != nil, settings.passphraseSet { SetupLog.flow.info("the controller holds a network: watching its station join") resume = .written(settings.version) @@ -1085,6 +1091,7 @@ import Observation /// Drop what this flow learned about the controller it last reached. private func clearControllerState() { + pairedAwaitingRead = false controller = nil lastDeviceID = nil network = nil diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift index 1f7449e..d0a1fa1 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/WiFiFlowTests.swift @@ -78,8 +78,15 @@ private actor WiFiClient: ControllerClient { if let helloFailure { throw helloFailure } return SessionReport(reportsWiFi: reportsWiFi) } + /// The next read fails with this, once. + var readFailure: SetupFailure? + func failNextRead(with failure: SetupFailure) { readFailure = failure } func readNetwork() async throws(SetupFailure) -> NetworkSettings { calls.append(.read) + if let failure = readFailure { + readFailure = nil + throw failure + } return settings } func writeNetwork(_ change: NetworkChange, expectedVersion: UInt32) async throws(SetupFailure) @@ -615,3 +622,31 @@ private let home = NetworkSettings( #expect(flow.state == .written(8)) #expect(!flow.joinsHeldNetwork) } + +/// The link drops between Pair and the first read: the read on the next +/// connection still finds the network held since pairing, and watches it. +@Test @MainActor func aHeldNetworkReadAfterAReconnectIsStillWatched() async throws { + let joined7 = WiFiStatus(section: 7, radio: (version: 7, state: .joined(address: "10.0.0.9"))) + let client = WiFiClient(statuses: [joined7], settings: home) + await client.failNextRead(with: .connectionDropped) + let clock = PollClock() + defer { clock.finish() } + let flow = SetupFlow( + factory: Factory(client: client), store: NoEnrolmentStore(), + transportFactory: { Transport() }, clock: clock) + try flow.submitCode("valid") + await flow.connect() + await flow.confirmWindowOpened() + #expect(flow.state == .failed(.connectionDropped, .connecting)) + await flow.retry() + #expect(flow.state == .written(7)) + #expect(flow.joinsHeldNetwork) + await settle { flow.join != .waiting } + #expect(flow.join == .joined(address: "10.0.0.9")) + #expect( + await client.calls == [.discover, .pair, .hello, .read, .discover, .hello, .read, .status]) + + // Only the first read after Pair: reading again goes to the form. + await flow.changeNetwork() + #expect(flow.state == .editingNetwork(home)) +} From adf14c55fa1553bc98d6eb0b91c7c27ee247e56f Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 09:36:36 -0400 Subject: [PATCH 5/6] fix: keep a suspend made while a connect clears exclusions --- .../SetupKit/Sources/SetupKit/SetupFlow.swift | 1 + .../Tests/SetupKitTests/SetupFlowTests.swift | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index 022245d..61c4ec8 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -315,6 +315,7 @@ import Observation ) await closeTransport() await (transport as? any PeerExcludingTransport)?.clearExcludedPeers() + guard generation == operation else { return } if greets, webSocketFactory != nil, let deviceID = controller?.deviceID ?? lastDeviceID { // Active from here, so a suspend during the attempt ends it. transportActive = true diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift index bad556d..4f03b66 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/SetupFlowTests.swift @@ -566,6 +566,50 @@ private struct Factory: ControllerClientFactory { #expect(await transport.opens == 0) } +/// Holds `clearExcludedPeers` until released, like a transport still busy. +private actor HeldClearTransport: PeerExcludingTransport { + private var held: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + private(set) var opens = 0 + func open() async throws(TransportError) { opens += 1 } + func send(_ frame: Data) async throws(TransportError) {} + func receive() async throws(TransportError) -> Data { Data() } + func close() async {} + func excludeConnectedPeer() async {} + func clearExcludedPeers() async { + await withCheckedContinuation { + held = $0 + for waiter in waiters { waiter.resume() } + waiters = [] + } + } + func clearing() async { + if held != nil { return } + await withCheckedContinuation { waiters.append($0) } + } + func release() { + held?.resume() + held = nil + } +} + +/// A suspend while the connect clears exclusions stays suspended: the +/// connect does not go on to the window step. +@Test @MainActor func backgroundWhileClearingExclusionsSuspends() async throws { + let transport = HeldClearTransport() + let flow = SetupFlow( + factory: Factory(fake: FakeClient()), store: NoEnrolmentStore(), + transportFactory: { transport }, clock: TestClock()) + try flow.submitCode("valid") + let run = Task { await flow.connect() } + await transport.clearing() + await flow.suspend() + await transport.release() + await run.value + #expect(flow.state == .suspended) + #expect(await transport.opens == 0) +} + @Test @MainActor func backgroundDuringPairSuspendsAndAbandonsIt() async throws { let client = FakeClient(holdPair: true) let clock = TestClock() From 3972ce7437b0dfe265f2d68949280b82af46f167 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 10:02:23 -0400 Subject: [PATCH 6/6] fix: reconnect after the window opens when a kept enrolment is stale --- .../SetupKit/Sources/SetupKit/SetupFlow.swift | 9 ++++--- .../SetupKitTests/KeptEnrolmentTests.swift | 25 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift index 61c4ec8..532a4e2 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/SetupFlow.swift @@ -379,8 +379,7 @@ import Observation await self.close() } state = .discovering - // The window is open now, so the controller advertises. A session whose - // kept enrolment was lost is connected already. + // The window is open now, so the controller advertises. if !transportActive { isConnecting = true guard await openBluetooth(operation) else { return } @@ -435,11 +434,15 @@ import Observation let discovered = try await discover(client, operation) guard generation == operation else { return } controller = discovered - // A kept enrolment for another epoch (P-222): pair on this connection. + // A kept enrolment for another epoch (P-222): pair once the window is + // open. Opening it can restart the comms module and end this link, so + // `confirmWindowOpened()` connects again. guard await client.isEnrolled() else { guard generation == operation else { return } SetupLog.flow.notice("the kept enrolment is for another epoch: pairing again") keptEnrolmentLost = true + await closeTransport() + guard generation == operation else { return } state = .openWindow return } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift index 1d94d30..ef4e655 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift @@ -41,10 +41,12 @@ final class MemoryEnrolmentStore: EnrolmentStore, @unchecked Sendable { } private actor Transport: FrameTransport { - func open() async throws(TransportError) {} + private(set) var opens = 0 + private(set) var closes = 0 + func open() async throws(TransportError) { opens += 1 } func send(_ frame: Data) async throws(TransportError) {} func receive() async throws(TransportError) -> Data { Data() } - func close() async {} + func close() async { closes += 1 } } /// Holds a credential the way the Rust engine does: the setup code, a kept @@ -142,10 +144,9 @@ private let kept = Data([0x01, 0x55]) private let editing = SetupFlow.State.editingNetwork( NetworkSettings(version: 1, ssid: nil, passphraseSet: false, country: nil, hostname: nil)) -@MainActor private func connected(_ client: KeptClient, _ store: MemoryEnrolmentStore) async throws - -> SetupFlow -{ - let transport = Transport() +@MainActor private func connected( + _ client: KeptClient, _ store: MemoryEnrolmentStore, transport: Transport = Transport() +) async throws -> SetupFlow { let flow = SetupFlow( factory: Factory(client: client), store: store, transportFactory: { transport }, clock: OpenWindowClock()) @@ -177,16 +178,22 @@ private let editing = SetupFlow.State.editingNetwork( #expect(store[KeptClient.deviceID] == KeptClient.paired) } -/// P-222: a kept enrolment for another epoch pairs again on the same -/// connection, and the new enrolment replaces it. +/// P-222: a kept enrolment for another epoch pairs again, and the new +/// enrolment replaces it. Opening the window can restart the comms module, so +/// the link that found out is closed and a new one opens once it is open. @Test @MainActor func aKeptEnrolmentForAnotherEpochPairsAgain() async throws { let client = KeptClient(.otherEpoch) let store = MemoryEnrolmentStore([KeptClient.deviceID: kept]) - let flow = try await connected(client, store) + let transport = Transport() + let flow = try await connected(client, store, transport: transport) #expect(flow.state == .openWindow) #expect(flow.keptEnrolmentLost) #expect(await client.calls == ["discover"]) + #expect(flow.link == nil) + #expect(await transport.opens == 1) + #expect(await transport.closes == 1) await flow.confirmWindowOpened() + #expect(await transport.opens == 2) #expect(flow.state == editing) #expect(await client.calls == ["discover", "discover", "pair", "hello", "read"]) #expect(store[KeptClient.deviceID] == KeptClient.paired)