Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,14 @@ enum UploadState: Equatable, Sendable {
/// A network failure or a server error (5xx): sending again can work. `offline` means no
/// connection at all.
case failed(message: String, offline: Bool)
/// The server refused the scan (4xx), its answer couldn't be read, or the scan couldn't be
/// packaged. Sending again would send the same thing, so the way on is back to the review
/// or start over, never "Try again".
/// The server refused the scan (4xx) or the scan couldn't be packaged. Sending again would send
/// the same thing, so the way on is back to the review or start over, never "Try again".
case rejected(message: String)
/// The server answered, but House Scan couldn't use the answer: it didn't decode, or it didn't
/// name the scene sent. It isn't shown. The scan isn't at fault, so the way on is "Try again",
/// with sharing the scan or starting over for when asking again doesn't help. `attempts` counts
/// such answers since the scan was sent from the review or a gap, at least 1.
case unusableAnswer(attempts: Int)
case done
}

Expand Down
104 changes: 104 additions & 0 deletions ios/HouseScan/Runtime/GateAnswerProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import Synchronization

/// The placement server's side of an upload under `-answersFromGate`, played by a UI test through
/// the `-autopilotGate` folder. The app's real `PlacementHTTPClient` sends through `session`, so
/// the request, the answer's status check and `ResultBinding` all run as they do against a server;
/// only the socket is replaced.
///
/// For the n-th request (from 0, in the order the app sends them) it writes `request-n.target`
/// ("POST /v1/placements"), then the exact body as `request-n.json`, then waits up to 60 seconds
/// for the test's `answer-n.json` and answers HTTP 200 with its bytes. With no answer by then the
/// request fails as timed out.
///
/// The UI test used to run a server on 127.0.0.1 inside its runner. That server answered
/// URLSession on a Mac, but on the hosted Simulator the app's requests to it failed as offline
/// (Actions run 37107233020); the runner is a background app while the app under test is open.
/// HouseScanKit's `PlacementHTTPClientTests` still drive the client over a real loopback socket.
final class GateAnswerProtocol: URLProtocol, @unchecked Sendable {
static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [GateAnswerProtocol.self]
return URLSession(configuration: configuration)
}()

static let answerWait: TimeInterval = 60

/// Requests started in this process, which numbers the files.
private static let started = Mutex(0)

/// Set and read only on the loading thread: `startLoading`, the timer and `stopLoading` all
/// run on the run loop URLSession called `startLoading` on.
private var poll: Timer?

override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
guard let gate = LaunchOptions().autopilotGate, let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let body: Data
do {
body = try Self.body(of: request)
} catch {
client?.urlProtocol(self, didFailWithError: error)
return
}
// Numbered only once the body is read. A write that fails after this leaves a gap in the
// numbers, which the test's server skips over.
let index = Self.started.withLock { count in
defer { count += 1 }
return count
}
do {
let target = "\(request.httpMethod ?? "GET") \(url.path())"
try Data(target.utf8).write(to: gate.appending(path: "request-\(index).target"), options: .atomic)
try body.write(to: gate.appending(path: "request-\(index).json"), options: .atomic)
} catch {
client?.urlProtocol(self, didFailWithError: error)
return
}
let answer = gate.appending(path: "answer-\(index).json")
let deadline = Date().addingTimeInterval(Self.answerWait)
let timer = Timer(timeInterval: 0.1, repeats: true) { [self] timer in
if let body = try? Data(contentsOf: answer) {
timer.invalidate()
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
} else if Date() >= deadline {
timer.invalidate()
RuntimeLog.engine.error("answers from gate: no \(answer.lastPathComponent, privacy: .public) within \(Self.answerWait, privacy: .public) s")
client?.urlProtocol(self, didFailWithError: URLError(.timedOut))
}
}
RunLoop.current.add(timer, forMode: RunLoop.current.currentMode ?? .default)
poll = timer
}

override func stopLoading() {
poll?.invalidate()
poll = nil
}

/// An upload task hands its body over as a stream, not `httpBody`. A stream that fails to read
/// throws: a cut-short body would reach the test as an answer naming the wrong scene.
private static func body(of request: URLRequest) throws -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open()
defer { stream.close() }
var data = Data()
var buffer = [UInt8](repeating: 0, count: 64 * 1024)
while true {
let read = stream.read(&buffer, maxLength: buffer.count)
if read < 0 { throw stream.streamError ?? URLError(.cannotDecodeRawData) }
guard read > 0 else { break }
data.append(buffer, count: read)
}
return data
}
}
8 changes: 8 additions & 0 deletions ios/HouseScan/Runtime/LaunchOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import OSLog
/// ground, as ARKit refining the ground would (`ScanEngine.injectGroundForTest`). Replays carry
/// no plane evidence, so this is the only way a UI test reaches that path. Meters must be over
/// the engine's 1 cm refine threshold and at most `GroundPlaneChoice.maximumRaise`.
/// - `-answersFromGate`: with `-autopilotGate` and a server URL, uploads go through the gate
/// folder instead of the network: the UI test reads each request there and writes the server's
/// answer (`GateAnswerProtocol`).
/// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice
/// meter are unavailable whatever the stored switch says (`DeveloperSettings`). It can only take
/// the switch away, never offer it.
Expand All @@ -42,6 +45,7 @@ struct LaunchOptions: Equatable {
var autopilotSomethingThere = false
var simulateAppStore = false
var injectGroundRise: Float?
var answersFromGate = false

init(
arguments: [String] = ProcessInfo.processInfo.arguments,
Expand All @@ -62,6 +66,10 @@ struct LaunchOptions: Equatable {
sampleResult = arguments.contains("-sampleResult")
if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) }
if let hold = value(after: "-autopilotHold").flatMap(Double.init), hold > 0 { autopilotHold = hold }
answersFromGate = arguments.contains("-answersFromGate")
if answersFromGate, autopilotGate == nil || serverURL == nil {
preconditionFailure("-answersFromGate needs -autopilotGate and a server URL (-serverURL or the build's)")
}
if let text = value(after: "-injectGroundRise") {
guard let meters = Float(text), meters > 0.01, meters <= 0.1 else {
preconditionFailure("-injectGroundRise takes meters over 0.01 and at most 0.1, got \(text)")
Expand Down
98 changes: 28 additions & 70 deletions ios/HouseScan/Runtime/ResultClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,73 +9,51 @@ protocol ResultClient: AnyObject {
func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data
}

/// Posts scene.json to the placement server: `POST {serverURL}/v1/placements` with the JSON as
/// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no
/// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone
/// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The
/// response body is the result JSON, and it is returned only when it names the scene sent: its
/// `stats.input_sha256` must be present and be the hash of these exact bytes (`ResultBinding`).
/// Any other answer throws, and the upload screen treats it as an answer it couldn't read.
/// Posts scene.json to the placement server and returns the answer only when it names the scene
/// sent (HouseScanKit's `PlacementHTTPClient`, which the package tests drive over real HTTP). Only
/// the JSON goes: the keyframes stay on the phone, and in the scan folder's `scan.zip`, which
/// leaves only if the homeowner shares it.
@MainActor
final class HTTPResultClient: ResultClient {
let serverURL: URL
let isSample = false
private let client: PlacementHTTPClient

init(serverURL: URL) {
init(serverURL: URL, session: URLSession = .shared) {
self.serverURL = serverURL
}

nonisolated static func makeRequest(serverURL: URL) -> URLRequest {
var request = URLRequest(url: serverURL.appending(path: "v1/placements"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 120
return request
client = PlacementHTTPClient(serverURL: serverURL, session: session)
}

func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data {
let request = Self.makeRequest(serverURL: serverURL)
let delegate = UploadProgressDelegate(progress: progress)
let (data, response) = try await URLSession.shared.upload(for: request, from: scene, delegate: delegate)
guard let http = response as? HTTPURLResponse else { throw UploadError.notHTTP }
guard (200..<300).contains(http.statusCode) else {
throw UploadError.server(
status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self),
retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
try ResultBinding.check(answer: data, submittedScene: scene)
progress(1)
return data
try await client.submit(scene: scene, progress: progress)
}
}

enum UploadError: Error, CustomStringConvertible {
case notHTTP
case server(status: Int, body: String, retryAfter: String? = nil)
case missingSample

var description: String {
switch self {
case .notHTTP: "The server's answer was not HTTP."
case .server(let status, let body, _): "The server answered \(status): \(body)"
case .missingSample: "SampleResult.json is missing from the app bundle."
}
}
/// The bundled sample is missing: a build problem, not something the server said.
struct MissingSampleResult: Error, CustomStringConvertible {
var description: String { "SampleResult.json is missing from the app bundle." }
}

/// What the homeowner reads about a failed upload. Which failures are worth sending again is
/// decided (and tested) in HouseScanKit's `UploadFailureKind`; the technical detail goes to the
/// log, and the screen never shows raw error text.
enum UploadFailure {
/// A failure while sending or reading the answer (not while packaging the scan).
static func state(for error: any Error) -> UploadState {
let kind: UploadFailureKind = if case .server(let status, _, let retryAfter) = error as? UploadError {
UploadFailureKind.classify(httpStatus: status, retryAfter: retryAfter)
} else {
UploadFailureKind.classify(error)
/// `unusableAnswers` counts the server's answers House Scan couldn't use since the scan was
/// sent from the review or a gap; this adds one when `error` is such an answer.
static func state(for error: any Error, sample: Bool, unusableAnswers: inout Int) -> UploadState {
// No server was asked: a missing or unreadable bundled sample is the build's problem, and
// the words must not say a server answered.
if sample {
return .rejected(message: "This build's sample result is missing or unreadable. Start over with a build that has a server.")
}
return switch kind {
let kind = UploadFailureKind.classify(error)
if kind == .unreadableAnswer { unusableAnswers += 1 }
return state(for: kind, unusableAnswers: unusableAnswers)
}

static func state(for kind: UploadFailureKind, unusableAnswers: Int) -> UploadState {
switch kind {
case .offline:
.failed(message: "Your phone isn't connected to the internet. Your scan is saved on this phone.", offline: true)
case .unreachable:
Expand All @@ -86,8 +64,9 @@ enum UploadFailure {
.failed(message: busyMessage(retryAfter), offline: false)
case .refused:
.rejected(message: "The server couldn't use this scan. Go back to the review to check your marks, or start over.")
// The answer's problem, not the scan's: ask again, never "check your marks".
case .unreadableAnswer:
.rejected(message: "We couldn't read the server's answer. Go back to the review and send it again, or start over.")
.unusableAnswer(attempts: max(unusableAnswers, 1))
}
}

Expand Down Expand Up @@ -118,27 +97,6 @@ enum UploadFailure {
}
}

final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable {
private let progress: @Sendable (Double) -> Void

init(progress: @escaping @Sendable (Double) -> Void) {
self.progress = progress
}

/// Once the whole body is sent the server is working out the spot in the same request, so
/// report 1 and let the screen move on to "Check clearances". Before that, stop at 0.99 so
/// rounding never shows 100%. Holding 0.99 until the answer left the screen on "Sending
/// measurements, 99%" for the whole analysis (field test run 1).
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
guard totalBytesExpectedToSend > 0 else { return }
if totalBytesSent >= totalBytesExpectedToSend {
progress(1)
} else {
progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend)))
}
}
}

/// Answers with the bundled SampleResult.json, for tests and demos without a server. The result
/// is flagged `isSample` so the screen says it is not a real analysis. It answers no particular
/// scene (its `input_sha256` is all zeros), so it is deliberately not checked with
Expand All @@ -159,7 +117,7 @@ final class SampleResultClient: ResultClient {
progress(Double(step) / 4)
}
try await Task.sleep(for: .seconds(pace))
guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw UploadError.missingSample }
guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw MissingSampleResult() }
return try Data(contentsOf: url)
}
}
7 changes: 6 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -561,9 +561,14 @@ extension ScanEngine: ScanActions {
}
}

/// Sends the same scan again, only when the homeowner asks: after a network or server failure,
/// or an answer House Scan couldn't use. Never after a refusal, which the review has to fix.
func retryUpload() {
guard state.phase == .uploading else { return }
if case .failed = state.upload { startUpload() }
switch state.upload {
case .failed, .unusableAnswer: startUpload()
case .idle, .packaging, .uploading, .analyzing, .rejected, .done: break
}
}

/// Back to the feature review after a rejected upload. The scan (wall, coverage, keyframes,
Expand Down
14 changes: 10 additions & 4 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ final class ScanEngine {
private var uploadTask: Task<Void, Never>?
/// The current upload's scene has fixed its ground; keep this true through answer pacing.
private var scenePackaged = false
/// The server's answers House Scan couldn't use since this scan was last sent from the review
/// or a gap; "Try again" keeps counting, and a usable answer starts again at 0.
private var unusableAnswers = 0
private(set) var placement: PlacementResult?
/// Whether a ground change took the answer down and no answer has been shown since
/// (`answerAfter(_:)`).
Expand Down Expand Up @@ -243,7 +246,7 @@ final class ScanEngine {
store = KeyframeStore()
recorder = Self.makeRecorder(store)
if let url = options.serverURL, !options.sampleResult {
resultClient = HTTPResultClient(serverURL: url)
resultClient = HTTPResultClient(serverURL: url, session: options.answersFromGate ? GateAnswerProtocol.session : .shared)
} else {
resultClient = SampleResultClient(pace: options.autopilot ? options.autopilotHold : 1.2)
}
Expand Down Expand Up @@ -514,7 +517,7 @@ final class ScanEngine {
case .findMeter, .meterCloseUp, .wallWalk, .markFeatures, .gapRequest: true
case .uploading:
switch state.upload {
case .failed, .rejected: false
case .failed, .rejected, .unusableAnswer: false
case .idle, .packaging, .uploading, .analyzing, .done: true
}
case .onboarding, .spotConfirm, .result, .resultAR, .unsupported: false
Expand Down Expand Up @@ -1675,7 +1678,7 @@ final class ScanEngine {
let screen: GroundFreshness.Screen = switch state.phase {
case .uploading:
switch state.upload {
case .failed, .rejected: .stopped
case .failed, .rejected, .unusableAnswer: .stopped
case .idle, .packaging, .uploading, .analyzing, .done: .sending
}
case .spotConfirm: .spotCheck
Expand Down Expand Up @@ -1999,6 +2002,8 @@ final class ScanEngine {

func startUpload() {
scenePackaged = false
// "Try again" sends from the upload screen; anything else is a new send of this scan.
if state.phase != .uploading { unusableAnswers = 0 }
go(.uploading)
injectGroundForTest()
uploadTask?.cancel()
Expand Down Expand Up @@ -2080,6 +2085,7 @@ final class ScanEngine {
guard scan == generation, state.phase == .uploading, !Task.isCancelled else { return }
}
placement = result
unusableAnswers = 0
noteExchange(scene: scene, answer: data, packet: packaged.packet)
writeScanStamp(answer: result)
state.result = presentation(of: result, isSample: resultClient.isSample)
Expand Down Expand Up @@ -2111,7 +2117,7 @@ final class ScanEngine {
// failure the canceller already shows.
guard scan == generation, !Task.isCancelled else { return }
RuntimeLog.engine.error("upload failed: \(String(describing: error), privacy: .public)")
state.upload = UploadFailure.state(for: error)
state.upload = UploadFailure.state(for: error, sample: resultClient.isSample, unusableAnswers: &unusableAnswers)
updateRecording()
}
}
Expand Down
Loading
Loading