diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 21b3d5633..1891009d4 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -1,8 +1,8 @@ -# Where Architecture Rules +# Application Architecture Rules -`BumperBowling.swift` turns the module boundaries already documented in -`Where/**/AGENTS.md` into source-level checks. It scans production sources only; -tests and generated files are outside the architecture graph. +`BumperBowling.swift` turns the module boundaries documented in the Where and +Throw `AGENTS.md` files into source-level checks. It scans production sources +only. Tests and generated files are outside the architecture graph. ## Layer boundaries @@ -17,6 +17,12 @@ tests and generated files are outside the architecture graph. | `WhereShareExtension` | `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | | `RegionViewer` | `RegionKit`, `WhereCore`, `WhereUI` | Foundation, SwiftUI, UIKit | +| Throw component | Allowed Throw dependencies | Framework capabilities | +| --- | --- | --- | +| `ThrowCore` | none | Foundation | +| `ThrowUI` | `ThrowCore` | Foundation, SwiftUI, UIKit | +| `Throw` app | `ThrowUI` | Foundation, SwiftUI, UIKit | + An import of a declared Where module outside these edges is a `component_boundary` error. An import of a known framework capability outside the component's allow-list is a `forbidden_import` error. RegionKit and @@ -31,10 +37,13 @@ Delete or reshape a boundary only when the corresponding module architecture changes in its `AGENTS.md`, `Package.swift`, or `Project.swift`; update the documentation and executable rule in the same change. +ThrowCore and ThrowUI also forbid LifecycleKit. Throw is a retryable runtime, +not a terminal launch sequence. The Throw app cannot import ThrowCore directly. + ## Graph integrity - `duplicate_ownership` keeps every source path and module in one component. -- `declared_dependency_cycle` keeps the Where layer graph acyclic. +- `declared_dependency_cycle` keeps each application layer graph acyclic. The mutation tests in `.bumper/Tests` prove that a valid downward import passes and that representative upward/framework imports fail with the expected rule. @@ -92,6 +101,20 @@ These use Bumper's standard `constructionOwnership` shaper. TheButtonHeist's as the analogous lower-level ownership check and retained; the standard shaper fully expresses Where's constructor facts. +Throw has matching ownership and typed-projection guards: + +- `throw.session_composition_ownership` permits `ThrowSession` construction only in `ThrowSession+Composition.swift`. +- `throw.live_dependency_composition_ownership` keeps live stores, durable logging, sources, and polling dependencies in that same file. +- `throw.runtime_composition_ownership` permits `ThrowRuntime` construction only in `ThrowRuntime.swift`. +- `throw.layer_frame_erasure_ownership` permits raw DEBUG Testing `LayerFrame` construction only in `ProjectionModels.swift`. +- `throw.projected_frame_erasure_ownership` permits raw `ProjectionFrame` and `ProjectedLayer` construction only at the ThrowUI presentation boundary. +- `throw.typed_projection_families` preserves each layer kind's element or style + family, keeps airport identity in its glyph descriptor, and rejects erased + mark-array replacement at the presentation boundary. + +Repair a violation by injecting the existing object or by using a typed layer +or experience frame. Change an owner only when the matching Throw module contract changes. + ## Gregorian calendar `where.gregorian_calendar` rejects `Calendar.current` throughout Where's @@ -125,7 +148,11 @@ conformance only in the Where app component. Where production sources. Production logging uses the typed `WhereLog` or `RegionLog` Periscope facades. -This complements `where.logging_type_ownership`, which controls where the +`throw.logging_facade` rejects direct system-log imports and raw diagnostic +output calls in Throw production sources. Production logging uses typed +`ThrowLog` events. + +This complements `repository.logging_type_ownership`, which controls where the typed event declarations live. ## Preview coverage @@ -141,13 +168,38 @@ WhereIntents and WhereWidgets explicitly forbid direct `BroadwayCore` and ## Logging vocabulary ownership -`where.logging_type_ownership` keeps every nominal type ending in `Log` under a -module's `Sources/Logging` directory. This preserves the existing convention -that collaborators and their typed Periscope event vocabulary do not accrete in -the same files. +`repository.logging_type_ownership` keeps every nominal type ending in `Log` +under a Where or Throw module's `Sources/Logging` directory. This keeps typed +Periscope event vocabulary separate from collaborators. Repair a violation by moving the logging type into the owning module's Logging directory. Delete or reshape the rule if the repository deliberately adopts a different logging vocabulary layout. Bumper's standard `singleNominalSpelling` shaper expresses the invariant; no custom syntax rule is needed. + +## Throw concrete view boundaries + +`throw.no_any_view` rejects `AnyView` in Throw production sources. Controller +and projection scenes compose concrete ThrowUI roots. Runtime handoff exposes +the shared session instead of erasing the view type. + +## Throw provider boundary + +`throw.provider_implementation_boundary` rejects concrete aircraft provider +sources and decoders in ThrowUI. Source setup and provider capabilities go +through `AircraftSourceOperationServing`, whose production implementation is +owned by ThrowCore. + +## Throw checked concurrency boundaries + +`throw.checked_concurrency_boundaries` rejects `@preconcurrency` and +`nonisolated(unsafe)` in all Throw production sources. Repair a violation with +checked isolation. Do not add an exception without first documenting and +testing the synchronization boundary. + +## Throw controller-scene lifecycle + +`throw.controller_scene_lifecycle` rejects app-delegate background and +foreground callbacks in the Throw app. Controller roots deliver their exact +scene identities and lifecycle transitions to the process runtime instead. diff --git a/.bumper/Sources/RepositoryProjectRules.swift b/.bumper/Sources/RepositoryProjectRules.swift new file mode 100644 index 000000000..c8f57d64c --- /dev/null +++ b/.bumper/Sources/RepositoryProjectRules.swift @@ -0,0 +1,21 @@ +import BumperBowlingCore + +let repositoryProjectRules = RuleSet { + Rules.singleNominalSpelling( + suffix: "Log", + owner: loggingScope, + id: "repository.logging_type_ownership", + ) +} + +private let loggingScope = RuleScope + .under("Where/RegionKit/Sources/Logging") + .union(.under("Where/Where/Sources/Logging")) + .union(.under("Where/WhereCore/Sources/Logging")) + .union(.under("Where/WhereUI/Sources/Logging")) + .union(.under("Where/WhereIntents/Sources/Logging")) + .union(.under("Where/WhereWidgets/Sources/Logging")) + .union(.under("Where/WhereShareExtension/Sources/Logging")) + .union(.under("Throw/Throw/Sources/Logging")) + .union(.under("Throw/ThrowCore/Sources/Logging")) + .union(.under("Throw/ThrowUI/Sources/Logging")) diff --git a/.bumper/Sources/ThrowArchitecture.swift b/.bumper/Sources/ThrowArchitecture.swift new file mode 100644 index 000000000..bb76ce4d8 --- /dev/null +++ b/.bumper/Sources/ThrowArchitecture.swift @@ -0,0 +1,23 @@ +import BumperBowlingCore + +extension ComponentShape { + static let throwCoreLayer = ComponentShape { + MayUse(.foundation) + } + + static let throwPresentationLayer = ComponentShape { + MayUse(.foundation, .swiftUI, .uiKit) + } + + static let throwHostLayer = ComponentShape { + MayUse(.foundation, .swiftUI, .uiKit) + } +} + +extension AssertionShape { + static let throwArchitecture = AssertionShape { + DependencyBoundaries(.error) + SingleOwner(.error) + AcyclicDeclaredDependencies(.error) + } +} diff --git a/.bumper/Sources/ThrowProjectRules.swift b/.bumper/Sources/ThrowProjectRules.swift new file mode 100644 index 000000000..c9d34c0f2 --- /dev/null +++ b/.bumper/Sources/ThrowProjectRules.swift @@ -0,0 +1,377 @@ +import BumperBowlingCore +import SwiftSyntax + +let throwProjectRules = RuleSet { + Rules.constructionOwnership( + "ThrowSession", + allowed: .files([throwSessionCompositionPath]), + id: "throw.session_composition_ownership", + ) + Rules.constructionOwnership( + "ThrowRuntime", + allowed: .files([throwRuntimeCompositionPath]), + id: "throw.runtime_composition_ownership", + ) + Rules.constructionOwnership( + "LayerFrame", + allowed: .files([throwLayerFrameErasurePath]), + id: "throw.layer_frame_erasure_ownership", + ) + throwProjectedFrameErasureRule + throwTypedProjectionFamiliesRule + throwLiveDependencyCompositionRule + throwAnyViewRule + throwProviderBoundaryRule + throwSceneLifecycleRule + throwCheckedConcurrencyRule + throwLoggingFacadeRule +} + +private let throwSessionCompositionPath: RelativeFilePath = + "Throw/ThrowUI/Sources/Model/ThrowSession+Composition.swift" +private let throwRuntimeCompositionPath: RelativeFilePath = + "Throw/Throw/Sources/ThrowRuntime.swift" +private let throwLayerFrameErasurePath: RelativeFilePath = + "Throw/ThrowCore/Sources/ProjectionModels.swift" +private let throwProjectedFrameErasurePath: RelativeFilePath = + "Throw/ThrowUI/Sources/Projection/ProjectionFrame.swift" +private let throwProjectionModelsPath: RelativeFilePath = + "Throw/ThrowCore/Sources/ProjectionModels.swift" + +private let throwProductionScope = RuleScope + .component(ThrowComponent.throwCore) + .union(.component(ThrowComponent.throwUI)) + .union(.component(ThrowComponent.throwApp)) + +private let throwLiveDependencyNames: Set = [ + "AircraftPollingCoordinator", + "AircraftSourceFactory", + "AircraftSourceService", + "CoreLocationThrowSource", + "KeychainAircraftCredentialStore", + "PeriscopeThrowDurableLoggingStarter", + "UserDefaultsThrowPreferenceStore", +] + +private let throwProjectedFrameErasureRule = Rules.files( + "throw.projected_frame_erasure_ownership", + severity: .error, + summary: "Throw erases typed projected frames only at its presentation boundary.", + scope: throwProductionScope, +) { file in + guard file.path != throwProjectedFrameErasurePath else { return [] } + return functionCalls() + .filter { match in + guard let name = calledDeclarationName(match.node) else { return false } + return name == "ProjectedLayer" || name == "ProjectionFrame" + } + .matches(in: file) + .map { match in + let name = calledDeclarationName(match.node) ?? "projected frame" + return match.failure( + message: "Throw constructs \(name) outside its presentation erasure boundary.", + evidence: ViolationEvidence( + observed: "\(name)(...) in \(file.path.rawValue)", + expectation: "construction in \(throwProjectedFrameErasurePath.rawValue)", + ), + ) + } +} + +private let throwProjectionFamilyAliases: [String: (name: String, type: String)] = [ + "FlightsLayerKind": ("MarkElement", "FlightsMarkElement"), + "GeographyLayerKind": ("LineStyle", "GeographyLineKind"), + "SatellitesLayerKind": ("MarkElement", "SatelliteMarkElement"), + "StarsLayerKind": ("MarkElement", "StarMarkElement"), + "TransitNetworkLayerKind": ("LineStyle", "TransitNetworkLineStyle"), + "TransitVehiclesLayerKind": ("MarkElement", "TransitVehicleMarkElement"), +] + +private let throwTypedProjectionFamiliesRule = Rules.files( + "throw.typed_projection_families", + severity: .error, + summary: "Throw keeps projection element families compiler-checked through presentation.", + scope: throwProductionScope, +) { file in + if file.path == throwProjectionModelsPath { + let aliasFailures = SyntaxQuery() + .filter { match in + guard + let enumName = enclosingEnumName(match.node), + let expected = throwProjectionFamilyAliases[enumName], + match.node.name.text == expected.name + else { + return false + } + return match.node.initializer.value.trimmedDescription != expected.type + } + .matches(in: file) + .map { match in + let enumName = enclosingEnumName(match.node) ?? "projection layer kind" + let expected = throwProjectionFamilyAliases[enumName] + return match.failure( + message: "Throw changes the element family owned by \(enumName).", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: expected.map { "typealias \($0.name) = \($0.type)" } + ?? "the declared projection family", + ), + ) + } + let airportIdentityFailures = SyntaxQuery() + .filter { match in + guard + match.node.name.text == "airport", + enclosingEnumName(match.node) == "FlightsMarkElement" + else { + return false + } + let parameterTypes = match.node.parameterClause?.parameters + .map(\.type.trimmedDescription) ?? [] + return parameterTypes != ["AirportGlyphDescriptor"] + } + .matches(in: file) + .map { match in + match.failure( + message: "Throw stores airport identity separately from its glyph descriptor.", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: "case airport(AirportGlyphDescriptor)", + ), + ) + } + return aliasFailures + airportIdentityFailures + } + + guard file.path == throwProjectedFrameErasurePath else { return [] } + return SyntaxQuery() + .filter { $0.node.name.text == "replacingMarks" } + .matches(in: file) + .map { match in + match.failure( + message: "ThrowUI accepts an erased mark array at its presentation mutation seam.", + evidence: ViolationEvidence( + observed: match.node.signature.trimmedDescription, + expectation: "case-preserving presentation-field updates", + ), + ) + } +} + +private func enclosingEnumName(_ node: some SyntaxProtocol) -> String? { + var ancestor = Syntax(node).parent + while let current = ancestor { + if let declaration = current.as(EnumDeclSyntax.self) { + return declaration.name.text + } + ancestor = current.parent + } + return nil +} + +private let throwLiveDependencyCompositionRule = Rules.files( + "throw.live_dependency_composition_ownership", + severity: .error, + summary: "Throw constructs live stores, sources, and polling only at its session root.", + scope: throwProductionScope, +) { file in + guard file.path != throwSessionCompositionPath else { return [] } + return functionCalls() + .filter { match in + guard let name = calledDeclarationName(match.node) else { return false } + return throwLiveDependencyNames.contains(name) + } + .matches(in: file) + .map { match in + let name = calledDeclarationName(match.node) ?? "live dependency" + return match.failure( + message: "Throw constructs \(name) outside its session composition root.", + evidence: ViolationEvidence( + observed: "\(name)(...) in \(file.path.rawValue)", + expectation: "construction in \(throwSessionCompositionPath.rawValue)", + ), + ) + } +} + +private func calledDeclarationName(_ call: FunctionCallExprSyntax) -> String? { + if let reference = call.calledExpression.as(DeclReferenceExprSyntax.self) { + return reference.baseName.text + } + if let member = call.calledExpression.as(MemberAccessExprSyntax.self) { + return member.declName.baseName.text + } + return nil +} + +private let throwAnyViewRule = Rules.files( + "throw.no_any_view", + severity: .error, + summary: "Throw production boundaries preserve concrete SwiftUI view types.", + scope: throwProductionScope, +) { file in + let typeFailures = SyntaxQuery() + .filter { $0.node.name.text == "AnyView" } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code erases a SwiftUI view to AnyView.", + evidence: ViolationEvidence( + observed: "AnyView in \(file.path.rawValue)", + expectation: "a concrete view or a generic view boundary", + ), + ) + } + let constructionFailures = functionCalls() + .filter { $0.node.calledExpression.trimmedDescription == "AnyView" } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code constructs an AnyView.", + evidence: ViolationEvidence( + observed: "AnyView in \(file.path.rawValue)", + expectation: "a concrete view or a generic view boundary", + ), + ) + } + return typeFailures + constructionFailures +} + +private let providerImplementationNames: Set = [ + "ADSBExchangeRapidAPISource", + "ADSBExchangeV2Decoder", + "AdsBLolSource", + "Flightradar24Decoder", + "Flightradar24Source", + "ReadsbSource", +] + +private let throwProviderBoundaryRule = Rules.files( + "throw.provider_implementation_boundary", + severity: .error, + summary: "ThrowUI uses provider-neutral source operations instead of concrete adapters.", + scope: .component(ThrowComponent.throwUI), +) { file in + let typeFailures = SyntaxQuery() + .filter { providerImplementationNames.contains($0.node.name.text) } + .matches(in: file) + .map { match in + match.failure( + message: "ThrowUI refers to a concrete aircraft provider implementation.", + evidence: ViolationEvidence( + observed: match.node.name.text, + expectation: "an injected provider-neutral ThrowCore operation", + ), + ) + } + let referenceFailures = SyntaxQuery() + .filter { providerImplementationNames.contains($0.node.baseName.text) } + .matches(in: file) + .map { match in + match.failure( + message: "ThrowUI refers to a concrete aircraft provider implementation.", + evidence: ViolationEvidence( + observed: match.node.baseName.text, + expectation: "an injected provider-neutral ThrowCore operation", + ), + ) + } + return typeFailures + referenceFailures +} + +private let throwApplicationLifecycleMethodNames: Set = [ + "applicationDidEnterBackground", + "applicationWillEnterForeground", +] + +private let throwSceneLifecycleRule = Rules.files( + "throw.controller_scene_lifecycle", + severity: .error, + summary: "Throw derives foreground presence from controller scenes.", + scope: .component(ThrowComponent.throwApp), +) { file in + SyntaxQuery() + .filter { throwApplicationLifecycleMethodNames.contains($0.node.name.text) } + .matches(in: file) + .map { match in + match.failure( + message: "Throw uses an application callback for scene lifecycle.", + evidence: ViolationEvidence( + observed: match.node.name.text, + expectation: "typed controller-scene lifecycle delivery", + ), + ) + } +} + +private let throwCheckedConcurrencyRule = Rules.files( + "throw.checked_concurrency_boundaries", + severity: .error, + summary: "Throw production code uses checked Swift concurrency.", + scope: throwProductionScope, +) { file in + let preconcurrencyFailures = SyntaxQuery() + .filter { $0.node.attributeName.trimmedDescription == "preconcurrency" } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code uses an @preconcurrency escape hatch.", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: "checked Swift concurrency", + ), + ) + } + let unsafeNonisolatedFailures = SyntaxQuery() + .filter { match in + match.node.name.text == "nonisolated" + && match.node.tokens(viewMode: .sourceAccurate).contains { $0.text == "unsafe" } + } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code uses nonisolated(unsafe).", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: "checked actor isolation", + ), + ) + } + return preconcurrencyFailures + unsafeNonisolatedFailures +} + +private let throwLoggingFacadeRule = Rules.files( + "throw.logging_facade", + severity: .error, + summary: "Throw production logging goes through its typed Periscope facade.", + scope: throwProductionScope, +) { file in + let rawLoggingImports = SyntaxQuery() + .filter { ["OSLog", "os"].contains($0.node.path.trimmedDescription) } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code imports system logging directly.", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: "the typed ThrowLog facade", + ), + ) + } + + let rawOutputNames: Set = ["NSLog", "debugPrint", "dump", "print"] + let rawOutputCalls = functionCalls() + .filter { rawOutputNames.contains($0.node.calledExpression.trimmedDescription) } + .matches(in: file) + .map { match in + match.failure( + message: "Throw production code writes diagnostic output directly.", + evidence: ViolationEvidence( + observed: match.node.calledExpression.trimmedDescription, + expectation: "a typed ThrowLog event", + ), + ) + } + + return rawLoggingImports + rawOutputCalls +} diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 0e047ad3b..07e3f0d73 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -12,11 +12,6 @@ let whereProjectRules = RuleSet { allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]), id: "where.live_location_source_ownership", ) - Rules.singleNominalSpelling( - suffix: "Log", - owner: whereLoggingScope, - id: "where.logging_type_ownership", - ) productionStoreOpeningRule checkedConcurrencyBoundaryRule gregorianCalendarRule @@ -30,15 +25,6 @@ private let whereServicesConstructionScope = RuleScope .component(WhereComponent.whereCore) .union(.files(["Where/WhereUI/Sources/Preview/PreviewSupport.swift"])) -private let whereLoggingScope = RuleScope - .under("Where/RegionKit/Sources/Logging") - .union(.under("Where/Where/Sources/Logging")) - .union(.under("Where/WhereCore/Sources/Logging")) - .union(.under("Where/WhereUI/Sources/Logging")) - .union(.under("Where/WhereIntents/Sources/Logging")) - .union(.under("Where/WhereWidgets/Sources/Logging")) - .union(.under("Where/WhereShareExtension/Sources/Logging")) - private let productionStoreOpeningPaths: Set = [ "Where/WhereUI/Sources/Launch/WhereLaunch.swift", "Where/WhereShareExtension/Sources/ShareEvidenceModel.swift", diff --git a/.bumper/Tests/RepositoryProjectRulesTests.swift b/.bumper/Tests/RepositoryProjectRulesTests.swift new file mode 100644 index 000000000..bc179dd92 --- /dev/null +++ b/.bumper/Tests/RepositoryProjectRulesTests.swift @@ -0,0 +1,32 @@ +import BumperBowlingCore +import BumperBowlingTestSupport +import Testing + +struct RepositoryProjectRulesTests { + @Test func loggingTypesStayInModuleLoggingDirectories() throws { + let report = try RuleTestHarness(repositoryProjectRules).evaluate( + VirtualRepository { + VirtualSourceFile.swift( + "Where/Where/Sources/Logging/WhereAppLog.swift", + component: WhereComponent.app, + source: "enum WhereAppLog {}", + ) + VirtualSourceFile.swift( + "Throw/ThrowCore/Sources/Logging/ThrowLog.swift", + component: ThrowComponent.throwCore, + source: "enum ThrowLog {}", + ) + VirtualSourceFile.swift( + "Throw/ThrowUI/Sources/Model/RogueLog.swift", + component: ThrowComponent.throwUI, + source: "enum RogueLog {}", + ) + }, + ) + + let violation = try #require(report.violations.first) + #expect(report.violations.count == 1) + #expect(violation.rule.id == "repository.logging_type_ownership") + #expect(violation.path == "Throw/ThrowUI/Sources/Model/RogueLog.swift") + } +} diff --git a/.bumper/Tests/ThrowArchitectureTests.swift b/.bumper/Tests/ThrowArchitectureTests.swift new file mode 100644 index 000000000..b08fc3d10 --- /dev/null +++ b/.bumper/Tests/ThrowArchitectureTests.swift @@ -0,0 +1,71 @@ +import BumperBowlingCore +import Testing + +struct ThrowArchitectureTests { + @Test func downwardDependenciesPass() throws { + let report = try bumper.evaluate( + RepositoryInput( + architecture: bumper.architecture, + files: [ + SourceInput( + path: "Throw/ThrowUI/Sources/Screen.swift", + component: ComponentID(ThrowComponent.throwUI.rawValue), + source: "import ThrowCore\nimport SwiftUI\nstruct Screen {}", + ), + SourceInput( + path: "Throw/Throw/Sources/App.swift", + component: ComponentID(ThrowComponent.throwApp.rawValue), + source: "import ThrowUI\nimport UIKit\nstruct AppHost {}", + ), + ], + ), + ) + + #expect(report.violations.isEmpty) + } + + @Test func coreCannotImportUIFrameworks() throws { + let report = try bumper.evaluate( + RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: "Throw/ThrowCore/Sources/LeakingDomain.swift", + component: ComponentID(ThrowComponent.throwCore.rawValue), + source: "import SwiftUI\nstruct LeakingDomain {}", + )], + ), + ) + + #expect(report.violations.map(\.rule.id) == [.forbiddenImport]) + } + + @Test func uiCannotImportWhereModules() throws { + let report = try bumper.evaluate( + RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: "Throw/ThrowUI/Sources/LeakingScreen.swift", + component: ComponentID(ThrowComponent.throwUI.rawValue), + source: "import WhereCore\nstruct LeakingScreen {}", + )], + ), + ) + + #expect(report.violations.map(\.rule.id) == [.componentBoundary]) + } + + @Test func appCannotBypassUIToImportCore() throws { + let report = try bumper.evaluate( + RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: "Throw/Throw/Sources/LeakingApp.swift", + component: ComponentID(ThrowComponent.throwApp.rawValue), + source: "import ThrowCore\nstruct LeakingApp {}", + )], + ), + ) + + #expect(report.violations.map(\.rule.id) == [.componentBoundary]) + } +} diff --git a/.bumper/Tests/ThrowProjectRulesTests.swift b/.bumper/Tests/ThrowProjectRulesTests.swift new file mode 100644 index 000000000..6f3d68a12 --- /dev/null +++ b/.bumper/Tests/ThrowProjectRulesTests.swift @@ -0,0 +1,256 @@ +import BumperBowlingCore +import BumperBowlingTestSupport +import Testing + +struct ThrowProjectRulesTests { + @Test func sessionConstructionStaysAtItsCompositionRoot() throws { + let allowed = try evaluate( + path: "Throw/ThrowUI/Sources/Model/ThrowSession+Composition.swift", + component: .throwUI, + source: "func live() { _ = ThrowSession() }", + ) + let rejected = try evaluate( + path: "Throw/ThrowUI/Sources/Model/CompetingSession.swift", + component: .throwUI, + source: "func live() { _ = ThrowSession() }", + ) + + #expect(allowed.violations.isEmpty) + #expect( + rejected.violations.map(\.rule.id) == ["throw.session_composition_ownership"], + ) + } + + @Test func runtimeConstructionStaysAtItsAppOwner() throws { + let allowed = try evaluate( + path: "Throw/Throw/Sources/ThrowRuntime.swift", + component: .throwApp, + source: "func live() { _ = ThrowRuntime() }", + ) + let rejected = try evaluate( + path: "Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift", + component: .throwApp, + source: "func fallback() { _ = ThrowRuntime() }", + ) + + #expect(allowed.violations.isEmpty) + #expect( + rejected.violations.map(\.rule.id) == ["throw.runtime_composition_ownership"], + ) + } + + @Test func liveDependenciesStayAtTheSessionCompositionRoot() throws { + let source = """ + func live() { + _ = AircraftPollingCoordinator() + _ = AircraftSourceFactory() + _ = AircraftSourceService() + _ = CoreLocationThrowSource() + _ = KeychainAircraftCredentialStore() + _ = PeriscopeThrowDurableLoggingStarter() + _ = UserDefaultsThrowPreferenceStore() + } + """ + let allowed = try evaluate( + path: "Throw/ThrowUI/Sources/Model/ThrowSession+Composition.swift", + component: .throwUI, + source: source, + ) + let rejected = try evaluate( + path: "Throw/ThrowUI/Sources/Model/CompetingLiveGraph.swift", + component: .throwUI, + source: source, + ) + + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.count == 7) + #expect(rejected.violations.allSatisfy { + $0.rule.id == "throw.live_dependency_composition_ownership" + }) + } + + @Test func rawLayerFramesStayAtTheCoreErasureBoundary() throws { + let allowed = try evaluate( + path: "Throw/ThrowCore/Sources/ProjectionModels.swift", + component: .throwCore, + source: "func erase() { _ = LayerFrame() }", + ) + let rejected = try evaluate( + path: "Throw/ThrowUI/Sources/Model/LooseLayer.swift", + component: .throwUI, + source: "func erase() { _ = LayerFrame() }", + ) + + #expect(allowed.violations.isEmpty) + #expect( + rejected.violations.map(\.rule.id) == ["throw.layer_frame_erasure_ownership"], + ) + } + + @Test func projectedFramesEraseOnlyAtThePresentationBoundary() throws { + let source = "func erase() { _ = ProjectedLayer(); _ = ProjectionFrame() }" + let allowed = try evaluate( + path: "Throw/ThrowUI/Sources/Projection/ProjectionFrame.swift", + component: .throwUI, + source: source, + ) + let rejected = try evaluate( + path: "Throw/ThrowUI/Sources/Projection/ProjectionFrameWorker.swift", + component: .throwUI, + source: source, + ) + + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.count == 2) + #expect(rejected.violations.allSatisfy { + $0.rule.id == "throw.projected_frame_erasure_ownership" + }) + } + + @Test func projectionFamiliesStayTypedThroughThePresentationBoundary() throws { + let coreAllowed = try evaluate( + path: "Throw/ThrowCore/Sources/ProjectionModels.swift", + component: .throwCore, + source: """ + enum StarsLayerKind { typealias MarkElement = StarMarkElement } + enum GeographyLayerKind { typealias LineStyle = GeographyLineKind } + enum FlightsMarkElement { case airport(AirportGlyphDescriptor) } + """, + ) + let coreRejected = try evaluate( + path: "Throw/ThrowCore/Sources/ProjectionModels.swift", + component: .throwCore, + source: """ + enum StarsLayerKind { typealias MarkElement = FlightsMarkElement } + enum GeographyLayerKind { typealias LineStyle = TransitNetworkLineStyle } + enum FlightsMarkElement { + case airport(AirportID, AirportGlyphDescriptor) + } + """, + ) + let presentationAllowed = try evaluate( + path: "Throw/ThrowUI/Sources/Projection/ProjectionFrame.swift", + component: .throwUI, + source: "func updatingMarkPresentation(fieldsByID: [ID: Fields]) {}", + ) + let presentationRejected = try evaluate( + path: "Throw/ThrowUI/Sources/Projection/ProjectionFrame.swift", + component: .throwUI, + source: "func replacingMarks(_ marks: [PresentedMark]) {}", + ) + + #expect(coreAllowed.violations.isEmpty) + #expect(coreRejected.violations.count == 3) + #expect(coreRejected.violations.allSatisfy { + $0.rule.id == "throw.typed_projection_families" + }) + #expect(presentationAllowed.violations.isEmpty) + #expect( + presentationRejected.violations.map(\.rule.id) == + ["throw.typed_projection_families"], + ) + } + + @Test func productionViewsKeepConcreteTypes() throws { + let allowed = try evaluate( + path: "Throw/Throw/Sources/ConcreteRoot.swift", + component: .throwApp, + source: "struct ConcreteRoot: View { var body: some View { Text(\"Throw\") } }", + ) + let rejected = try evaluate( + path: "Throw/Throw/Sources/ErasedRoot.swift", + component: .throwApp, + source: "func root() -> AnyView { AnyView(Text(\"Throw\")) }", + ) + + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.allSatisfy { $0.rule.id == "throw.no_any_view" }) + #expect(rejected.violations.isEmpty == false) + } + + @Test func productionLoggingUsesTypedFacade() throws { + let allowed = try evaluate( + path: "Throw/ThrowCore/Sources/Worker.swift", + component: .throwCore, + source: "func run() { ThrowLog.session { .durableLoggingReady } }", + ) + let printRejected = try evaluate( + path: "Throw/ThrowUI/Sources/PrintingWorker.swift", + component: .throwUI, + source: "func run() { print(\"done\") }", + ) + let osLogRejected = try evaluate( + path: "Throw/Throw/Sources/LoggingShell.swift", + component: .throwApp, + source: "import os\nstruct LoggingShell {}", + ) + + #expect(allowed.violations.isEmpty) + #expect(printRejected.violations.map(\.rule.id) == ["throw.logging_facade"]) + #expect(osLogRejected.violations.map(\.rule.id) == ["throw.logging_facade"]) + } + + @Test func providerImplementationsStayInCore() throws { + let allowed = try evaluate( + path: "Throw/ThrowCore/Sources/SourceService.swift", + component: .throwCore, + source: "func make() { _ = Flightradar24Source.self }", + ) + let rejected = try evaluate( + path: "Throw/ThrowUI/Sources/SourceSettings.swift", + component: .throwUI, + source: "func make() { _ = Flightradar24Source.self }", + ) + + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.map(\.rule.id) == ["throw.provider_implementation_boundary"]) + } + + @Test func appLifecycleCallbacksCannotReplaceControllerSceneLifecycle() throws { + let allowed = try evaluate( + path: "Throw/Throw/Sources/ControllerSceneObserver.swift", + component: .throwApp, + source: "func controllerSceneDidEnterBackground() {}", + ) + let rejected = try evaluate( + path: "Throw/Throw/Sources/ThrowApp.swift", + component: .throwApp, + source: "func applicationDidEnterBackground() {}", + ) + + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.map(\.rule.id) == ["throw.controller_scene_lifecycle"]) + } + + @Test func uncheckedConcurrencyEscapeHatchesFail() throws { + let preconcurrency = try evaluate( + path: "Throw/ThrowCore/Sources/Legacy.swift", + component: .throwCore, + source: "@preconcurrency import Foundation", + ) + let unsafeIsolation = try evaluate( + path: "Throw/ThrowUI/Sources/UnsafeSession.swift", + component: .throwUI, + source: "final class Session { nonisolated(unsafe) var task: Task? }", + ) + + #expect( + preconcurrency.violations.map(\.rule.id) == ["throw.checked_concurrency_boundaries"], + ) + #expect( + unsafeIsolation.violations.map(\.rule.id) == ["throw.checked_concurrency_boundaries"], + ) + } + + private func evaluate( + path: RelativeFilePath, + component: ThrowComponent, + source: String, + ) throws -> RuleReport { + try RuleTestHarness(throwProjectRules).evaluate( + VirtualRepository { + VirtualSourceFile.swift(path, component: component, source: source) + }, + ) + } +} diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index d42a8c7c9..8721a2653 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -122,34 +122,6 @@ struct WhereProjectRulesTests { #expect(violation.path == rejectedPath) } - @Test - func `Log event types stay in logging directories`() throws { - let appAllowed = try evaluate( - path: "Where/Where/Sources/Logging/WhereAppLog.swift", - component: .app, - source: "enum WhereAppLog {}", - ) - let allowed = try evaluate( - path: "Where/WhereUI/Sources/Logging/ScreenLog.swift", - component: .whereUI, - source: "enum ScreenLog {}", - ) - let rejectedPath: RelativeFilePath = - "Where/WhereUI/Sources/Model/ScreenLog.swift" - let rejected = try evaluate( - path: rejectedPath, - component: .whereUI, - source: "enum ScreenLog {}", - ) - - #expect(appAllowed.violations.isEmpty) - #expect(allowed.violations.isEmpty) - let violation = try #require(rejected.violations.first) - #expect(rejected.violations.count == 1) - #expect(violation.rule.id == "where.logging_type_ownership") - #expect(violation.path == rejectedPath) - } - @Test func `Where uses explicit Gregorian calendars`() throws { let intentsAllowed = try evaluate( diff --git a/.gitattributes b/.gitattributes index 98cafca3e..c1689ba9c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,7 @@ # assets) are untouched, and so future modules that adopt snapshot testing are # covered automatically. **/__Snapshots__/**/*.png filter=lfs diff=lfs merge=lfs -text + +# Keep pinned Natural Earth source inputs byte-for-byte identical to the +# verified release archives, including upstream end-of-line whitespace. +Throw/ThrowCore/Tools/source/natural-earth-v5.1.2/*.geojson whitespace=-blank-at-eol diff --git a/AGENTS.md b/AGENTS.md index ff24b24d3..ee006b182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,11 +54,11 @@ tests](#running-tests)). `./icons`, `./attribution`, and `./simulator` own state easy to corrupt by hand. `./simulator` owns a per-checkout device (see the [`running-tests`](.agents/skills/running-tests/SKILL.md) skill). -Retained Python and Ruby implementations are importable and directly tested -under `Tools/Tests`; shell around them is limited to public argument handling, -bootstrap, and process orchestration. In particular, -`tla-check` owns discovery and the pinned TLC download while -`Tools/tla_check.py` owns manifest validation, TLC argv, and result policy. +Retained Python and Ruby implementations are importable. Direct tests live +under `Tools/Tests`. Shell wrappers only handle public arguments, bootstrap, +and process orchestration. In particular, `tla-check` owns the public command +and the pinned TLC download. `Tools/tla_check.py` owns cross-feature discovery, +manifest validation, TLC arguments, and result policy. ### Managing app icons @@ -121,12 +121,12 @@ own tests can't do this job. A test bundle can't read `Package.swift`. ## Architecture lint -Bumper Bowling enforces the production Where module graph and selected -source-level invariants. The entry point is +Bumper Bowling enforces the production Where and Throw module graphs and +selected source-level invariants. The entry point is [`BumperBowling.swift`](BumperBowling.swift). Repository-owned shapes and rules live in [`.bumper/Sources`](.bumper/Sources). [`.bumper/RULES.md`](.bumper/RULES.md) is the rule catalog. -Run `./test --architecture-only` after changing a Where dependency, +Run `./test --architecture-only` after changing a Where or Throw dependency, composition root, or documented concurrency boundary. This command validates the configuration, tests the rules, and runs the lint. Keep the relevant `AGENTS.md`, the executable rule, its catalog entry, and its mutation test in @@ -212,10 +212,12 @@ Measured symbol-coalescing detail and the correction history: PR #145. Platforms and minimum OS live in [`Project.swift`](Project.swift). The iOS targets and the native-macOS **Ledger** app are there. That is why the package declares -both platforms. To get the app onto a connected iPhone without the Xcode UI, use -[`./Where/install`](Where/install). That command is macOS-only. It needs a signing team -configured once via `./ide --team-id` (see -[`Where/AGENTS.md`](Where/AGENTS.md#installing-to-a-device)). Its `--dry-run` +both platforms. Use [`./Where/install`](Where/install) or +[`./Throw/install`](Throw/install) to install an iOS app without the Xcode UI. +Both commands are macOS-only. Each command needs a signing team configured once +with `./ide --team-id`. Read the device-install section for +[`Where`](Where/AGENTS.md#installing-to-a-device) or +[`Throw`](Throw/AGENTS.md#installing-to-a-device). The `--dry-run` option resolves the exact physical device without generating, building, installing, or launching. [`./Ledger/install`](Ledger/install) is the equivalent for Ledger. It builds a diff --git a/BumperBowling.swift b/BumperBowling.swift index 52e813a48..17b5e8f07 100644 --- a/BumperBowling.swift +++ b/BumperBowling.swift @@ -11,6 +11,12 @@ enum WhereComponent: String, ComponentKey { case regionViewer } +enum ThrowComponent: String, ComponentKey { + case throwCore + case throwUI + case throwApp +} + let bumper = BumperProject { Included { "Where/RegionKit/Sources" @@ -21,6 +27,9 @@ let bumper = BumperProject { "Where/WhereWidgets/Sources" "Where/WhereShareExtension/Sources" "Where/RegionViewer/Sources" + "Throw/ThrowCore/Sources" + "Throw/ThrowUI/Sources" + "Throw/Throw/Sources" } Excluded { @@ -90,8 +99,35 @@ let bumper = BumperProject { } } + Architecture(ThrowComponent.self) { + Component(.throwCore) { + Owns("Throw/ThrowCore/Sources") + Modules("ThrowCore") + Applies(.throwCoreLayer) + DoesNotUse("LifecycleKit") + } + + Component(.throwUI) { + Owns("Throw/ThrowUI/Sources") + Modules("ThrowUI") + MayDependOn(.throwCore) + Applies(.throwPresentationLayer) + DoesNotUse("LifecycleKit") + } + + Component(.throwApp) { + Owns("Throw/Throw/Sources") + Modules("Throw") + MayDependOn(.throwUI) + Applies(.throwHostLayer) + } + } + Rules { ApplyAssertions(.whereArchitecture) + ApplyAssertions(.throwArchitecture) + repositoryProjectRules whereProjectRules + throwProjectRules } } diff --git a/Package.swift b/Package.swift index 933ad2e17..a1d086888 100644 --- a/Package.swift +++ b/Package.swift @@ -29,6 +29,8 @@ let package = Package( .library(name: "WhereIntents", targets: ["WhereIntents"]), .library(name: "BroadwayCore", targets: ["BroadwayCore"]), .library(name: "BroadwayUI", targets: ["BroadwayUI"]), + .library(name: "ThrowCore", targets: ["ThrowCore"]), + .library(name: "ThrowUI", targets: ["ThrowUI"]), ], dependencies: [ .package( @@ -218,5 +220,30 @@ let package = Package( ], path: "Shared/Broadway/BroadwayUI/Sources", ), + .target( + name: "ThrowCore", + dependencies: [ + .target(name: "PeriscopeCore"), + ], + path: "Throw/ThrowCore/Sources", + resources: [ + .process("Resources"), + ], + ), + .target( + name: "ThrowUI", + dependencies: [ + .target(name: "BroadwayCore"), + .target(name: "BroadwayUI"), + .target(name: "CreditKit"), + .target(name: "SnapshotKit"), + .target(name: "ThrowCore"), + .product(name: "SFSafeSymbols", package: "SFSafeSymbols"), + ], + path: "Throw/ThrowUI/Sources", + resources: [ + .process("Resources"), + ], + ), ], ) diff --git a/Project.swift b/Project.swift index 9a055b96a..8d704cbbf 100644 --- a/Project.swift +++ b/Project.swift @@ -184,6 +184,55 @@ let project = Project( packages: [stuffPackage, sfSafeSymbolsPackage], settings: projectSettings, targets: [ + .target( + name: "Throw", + destinations: destinations, + product: .app, + bundleId: "com.stuff.throw", + deploymentTargets: deployment, + infoPlist: .extendingDefault(with: [ + "CFBundleDisplayName": .string("Throw"), + "CFBundleShortVersionString": .string("0.1"), + "CFBundleVersion": .string("1"), + "NSAppTransportSecurity": .dictionary([ + "NSAllowsLocalNetworking": .boolean(true), + ]), + "NSLocalNetworkUsageDescription": .string( + "Throw connects to a readsb receiver you choose on your local network.", + ), + "NSLocationWhenInUseUsageDescription": .string( + "Throw uses your location to place aircraft correctly around you.", + ), + "UIApplicationSceneManifest": .dictionary([ + "UIApplicationSupportsMultipleScenes": .boolean(true), + "UISceneConfigurations": .dictionary([ + "UIWindowSceneSessionRoleApplication": .array([ + .dictionary([ + "UISceneConfigurationName": .string("Throw Controller"), + ]), + ]), + "UIWindowSceneSessionRoleExternalDisplayNonInteractive": .array([ + .dictionary([ + "UISceneConfigurationName": .string("Throw External Display"), + "UISceneDelegateClassName": .string( + "$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate", + ), + ]), + ]), + ]), + ]), + "UIApplicationSupportsIndirectInputEvents": .boolean(true), + "UILaunchScreen": .dictionary([:]), + ]), + sources: ["Throw/Throw/Sources/**"], + resources: ["Throw/Throw/Resources/**"], + dependencies: [ + .package(product: "ThrowUI"), + ], + settings: .settings(base: [ + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + ]), + ), .target( name: "Where", destinations: destinations, @@ -397,6 +446,20 @@ let project = Project( ], environmentVariables: packageResourceEnvironment, ), + .target( + name: "ThrowTests", + destinations: destinations, + product: .unitTests, + bundleId: "com.stuff.throw.tests", + deploymentTargets: deployment, + sources: ["Throw/Throw/Tests/**"], + dependencies: [ + .target(name: "Throw"), + .package(product: "TestHostSupport"), + .package(product: "ThrowUI"), + ], + environmentVariables: packageResourceEnvironment, + ), .target( name: "StuffTestHost", destinations: destinations, @@ -572,6 +635,18 @@ let project = Project( productDependency: "WhereIntents", sources: ["Where/WhereIntents/Tests/**"], ), + unitTests( + name: "ThrowCoreTests", + bundleIdSuffix: "throwcore", + productDependency: "ThrowCore", + sources: ["Throw/ThrowCore/Tests/**"], + ), + unitTests( + name: "ThrowUITests", + bundleIdSuffix: "throwui", + productDependency: "ThrowUI", + sources: ["Throw/ThrowUI/Tests/**"], + ), // Image snapshot bundles: one per module that owns image references, // all gathered into the single `StuffSnapshotTests` scheme below so CI // runs them in one `snapshot` job. They are slow and LFS-backed, so @@ -639,6 +714,14 @@ let project = Project( extraPackageProducts: ["SnapshotKitTesting"], environmentVariables: snapshotEnvironment, ), + unitTests( + name: "ThrowUISnapshotTests", + bundleIdSuffix: "throwui.snapshot", + productDependency: "ThrowUI", + sources: ["Throw/ThrowUI/SnapshotTests/**"], + extraPackageProducts: ["SnapshotKitTesting"], + environmentVariables: snapshotEnvironment, + ), .target( name: "BroadwayCatalog", destinations: destinations, @@ -690,6 +773,12 @@ let project = Project( // WhereCoreTests` / `tuist test WhereTests` / `tuist test WhereUITests` // target a single bundle without building the whole workspace. schemes: [ + .scheme( + name: "Throw", + shared: true, + buildAction: .buildAction(targets: ["Throw"]), + runAction: .runAction(executable: "Throw"), + ), // App target schemes are normally autogenerated, but declare the // RegionViewer one explicitly so `tuist build RegionViewer` (and a // Run that launches the Catalyst app) is always available. @@ -722,6 +811,7 @@ let project = Project( name: "Stuff-iOS-Tests", shared: true, buildAction: .buildAction(targets: [ + "Throw", "Where", "RegionViewer", "StuffTestHost", @@ -746,6 +836,9 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "ThrowCoreTests", + "ThrowUITests", + "ThrowTests", ]), testAction: .targets( [ @@ -769,6 +862,9 @@ let project = Project( "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", + "ThrowCoreTests", + "ThrowUITests", + "ThrowTests", ], arguments: .arguments(environmentVariables: packageResourceEnvironment), ), @@ -790,6 +886,9 @@ let project = Project( testScheme(name: "WhereCoreTests"), testScheme(name: "WhereTests"), testScheme(name: "WhereUITests"), + testScheme(name: "ThrowCoreTests"), + testScheme(name: "ThrowUITests"), + testScheme(name: "ThrowTests"), // Every image-snapshot bundle, in one scheme, so CI runs them all in // the single `snapshot` job. A new module's image suite gets its own // `*SnapshotTests` target above and joins the lists here — it must not @@ -807,6 +906,7 @@ let project = Project( "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", "InspectorSnapshotTests", + "ThrowUISnapshotTests", ]), testAction: .targets( [ @@ -814,6 +914,7 @@ let project = Project( "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", "InspectorSnapshotTests", + "ThrowUISnapshotTests", ], arguments: .arguments(environmentVariables: snapshotEnvironment), ), diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index 2bd1d80f8..614b34509 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -46,6 +46,9 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. - **Anything that prints one is a row in a report and an image in the count.** Nothing marks it synthetic. - **Split each channel for that reason.** `report(...)` / `emit()` print. `line(...)` only returns the JSON. - **A test that pins the wire shape calls `line(...)`.** +- **Derive review reference paths with swift-snapshot-testing's exact name + sanitization.** Spaces and punctuation become one hyphen. Guard: + `SnapshotReferenceDiffTests`. - **When they were one function, this module's own tests put a fabricated reference at the top of `./test --review`.** Its numbers were borrowed from a real regression. - **Five invented captures also landed in `--timings`.** Then a run that captured nothing reported "5 captures, 0.024s per image". - **The runner fails fast, once, on setup problems.** Examples: a simulator that does not match the `SNAPSHOT_EXPECTED_*` pins, two variants sharing one reference name. diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index d6c8b3786..1e6829c06 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -137,6 +137,8 @@ is, the largest single-channel delta, and the bounding box of the change. Read the **max delta** first — it is what separates a broken render from sub-visible drift, and pixel count does not. The worst genuine defect found so far touched fewer pixels than the noisiest harmless difference in the suite. +The review path uses the comparison library's filename sanitization, so case +names with spaces or punctuation resolve to their committed reference images. Failure messages also print the reference and failed-capture file URLs. To get a ready-to-run [Kaleidoscope](https://kaleidoscope.app) command instead, forward diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotReferenceDiff.swift b/Shared/SnapshotKitTesting/Sources/SnapshotReferenceDiff.swift index c6307c365..f0c83b214 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotReferenceDiff.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotReferenceDiff.swift @@ -75,12 +75,23 @@ import UIKit let testFile = URL(fileURLWithPath: testFilePath) // The library strips a trailing `()` from `#function`, so `year()` and the // `year` directory component agree. - let function = testName.hasSuffix("()") ? String(testName.dropLast(2)) : testName + let unsanitizedFunction = testName.hasSuffix("()") + ? String(testName.dropLast(2)) + : testName + let function = sanitizedSnapshotPathComponent(unsanitizedFunction) + let sanitizedIdentifier = sanitizedSnapshotPathComponent(identifier) return testFile .deletingLastPathComponent() .appendingPathComponent("__Snapshots__") .appendingPathComponent(testFile.deletingPathExtension().lastPathComponent) - .appendingPathComponent("\(function).\(identifier).png") + .appendingPathComponent("\(function).\(sanitizedIdentifier).png") +} + +/// Mirrors swift-snapshot-testing's private path-component normalization. +private func sanitizedSnapshotPathComponent(_ value: String) -> String { + value + .replacingOccurrences(of: "\\W+", with: "-", options: .regularExpression) + .replacingOccurrences(of: "^-|-$", with: "", options: .regularExpression) } /// Compares `capturedPNG` against the reference at `referenceURL`. diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotReferenceDiffTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotReferenceDiffTests.swift index f71c8ebed..0630b80b5 100644 --- a/Shared/SnapshotKitTesting/Tests/SnapshotReferenceDiffTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SnapshotReferenceDiffTests.swift @@ -33,6 +33,15 @@ struct SnapshotReferenceDiffTests { #expect(url.lastPathComponent == "thing.iPhone.png") } + @Test func referencePathSanitizesNamesLikeTheComparisonLibrary() { + let url = snapshotReferenceURL( + testFilePath: "/repo/T/Tests/TSnapshotTests.swift", + testName: "flight activity()", + identifier: "adsb.lol Source_iPhone", + ) + #expect(url.lastPathComponent == "flight-activity.adsb-lol-Source_iPhone.png") + } + /// The derivation above is only useful if it lands on a real file. This /// repo's own Inspector reference is the fixture. @Test func derivedPathFindsAnActualReferenceInThisRepo() { @@ -51,6 +60,18 @@ struct SnapshotReferenceDiffTests { ) } + @Test func derivedPathFindsAReferenceWithASanitizedIdentifier() { + let url = snapshotReferenceURL( + testFilePath: throwProjectionTestFilePath, + testName: "projectionSurface()", + identifier: "Flight Activity Map_16x9", + ) + #expect( + FileManager.default.fileExists(atPath: url.path), + "Derived sanitized reference path does not exist: \(url.path)", + ) + } + @Test func identicalBytesShortCircuit() throws { let png = try #require(solidImage(.red, size: CGSize(width: 8, height: 8)).pngData()) let url = try write(png, named: "identical.png") @@ -173,6 +194,17 @@ struct SnapshotReferenceDiffTests { .path } + private var throwProjectionTestFilePath: String { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Throw/ThrowUI/SnapshotTests") + .appendingPathComponent("ProjectionSurfaceSnapshotTests.swift") + .path + } + private func solidImage(_ color: UIColor, size: CGSize, patch: CGRect? = nil) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1 diff --git a/Throw/AGENTS.md b/Throw/AGENTS.md new file mode 100644 index 000000000..86487b653 --- /dev/null +++ b/Throw/AGENTS.md @@ -0,0 +1,66 @@ +# Throw – Feature Shape + +Throw is an iPhone/iPad ceiling-projection app; see [`README.md`](README.md). +This file complements the root [`AGENTS.md`](../AGENTS.md), which owns build, +formatting, and repository-wide conventions. + +## Modules and layering + +The dependency direction is **ThrowCore → ThrowUI → Throw app**. Put domain +values, projection, sources, polling, persistence seams, and schedules in +ThrowCore. Put observable presentation state and SwiftUI/UIKit presentation in +ThrowUI. Keep the app target a scene and composition shell. Never import +WhereCore, RegionKit, or LifecycleKit into this feature. + +## Invariants + +- Construct `ThrowRuntime` only in `ThrowRuntime.swift`. Construct the live + `ThrowSession` and its live dependencies only in `ThrowSession+Composition.swift`. + Inject those objects into every scene. +- Start one retained cold-launch task from the process runtime. Gate every root + on the session's exhaustive launch state. +- Keep the View catalog compile-time. Use `ProjectionExperience` in code and + “View” in user-facing text. Never add runtime plugins or `AnyView` boundaries. +- Keep planned View IDs at display and persistence boundaries. Pass only a + `RunnableProjectionExperienceID` to playlist and runtime commands. +- Keep aircraft provider implementations in ThrowCore. ThrowUI uses the + provider-neutral operation service and domain results. +- Keep one experience coordinator for all scenes. Only the active and + prewarming experience runtimes may run at the same time. +- Exchange complete experience frames only while the projection is black. + Never draw layers from two experiences together. +- Keep projected layer membership typed through ThrowCore. Erase it once in + ThrowUI's `Projection/ProjectionFrame.swift` into closed presentation cases. + Never accept erased marks back into a production case. +- Select exactly one aircraft source. Cancel and drain it before starting + another; never combine or automatically fall back between providers. +- Keep source configuration and validation in one `AircraftSourceSelection`. + Derive each paid provider's fixed credential ID from its source kind. +- Render Preview, full-screen fallback, and external displays with the same + `ProjectionSurface`. +- Keep secrets in `AircraftCredentialStore` only. Never log a key, observer or + Map-center coordinate, receiver URL, request URL, aircraft identity, or response body. +- Send production diagnostics through typed `ThrowLog` events. Do not import + system logging or write raw diagnostic output. +- Keep post-launch operation failures in the typed owner ledger. Resolve only + the owner whose operation succeeds. +- Keep the external surface opaque black and noninteractive. Calibration may + bypass quiet output without starting a feed. +- Keep Geography offline and Map-only. Never add online map tiles or transmit + the observer location to a map provider. +- Revalidate the availability-gated iOS 27 scene-accessory adapter against the + GM SDK before release. + +## Installing to a device + +`./Throw/install` builds, signs, installs, and launches Throw on a connected +iPhone or iPad. It is macOS-only. Configure the signing team once with +`./ide --team-id `. Run `./Throw/install --help` for all options. + +## Testing + +Use `./test ThrowCoreTests`, `./test ThrowUITests`, and `./test ThrowTests` for +focused coverage. Throw's image bundle joins the shared `StuffSnapshotTests` +scheme; do not add a separate snapshot scheme. Run +`./test --architecture-only` after changing a module boundary or composition +root. diff --git a/Throw/README.md b/Throw/README.md new file mode 100644 index 000000000..4333021de --- /dev/null +++ b/Throw/README.md @@ -0,0 +1,158 @@ +# Throw + +Throw is an iPhone and iPad ceiling-projector app. Its Air & Space View places +nearby aircraft on a geographic map or a directional sky dome. The device +remains the controller, and an attached display shows an opaque-black surface. +Aircraft data is ambient and incomplete; Throw is not a navigation or safety +tool. + +## Modules + +- [`ThrowCore`](ThrowCore) owns typed coordinates, projection math, ADS-B + normalization and providers, polling, preferences, credentials, location, + quiet scheduling, and the compile-time View and layer catalogs. +- [`ThrowUI`](ThrowUI) owns the controller, onboarding and settings flows, + calibration, and the one projection renderer shared by every output. +- [`Throw`](Throw) is the iOS composition root. It creates one runtime and + hands its shared session to controller and external-display scenes. + +The dependency direction is `ThrowCore` → `ThrowUI` → the `Throw` app. Throw +does not import WhereCore or RegionKit and does not use LifecycleKit. + +## Build and run + +Generate the workspace, then use the shared `Throw` scheme on an iOS 26 or +newer iPhone or iPad: + +```bash +./ide --no-open +``` + +You can also build, install, and launch Throw on a connected device: + +```bash +./ide --team-id # one-time signing setup +./Throw/install +``` + +Run `./Throw/install --dry-run` to resolve the device without generating, +building, installing, or launching. Run `./Throw/install --help` for all +options. + +The guaranteed physical-output path is a powered USB-C-to-HDMI connection. +AirPlay through Apple TV is supported by the system; if it mirrors instead of +creating a distinct external scene, use Throw's explicit full-screen output. +Preview runs the same projection renderer on the device. + +## Projection Views + +“View” is the user-facing name for a `ProjectionExperience`. Air & Space is the +first enabled View. It contains Geography, Flights, and the planned Stars and +Satellites layers. Transit is planned for nearby moving buses, trains, and +ferries. This change does not include a live transit provider. + +One playlist controls every projector, full-screen output, and Preview. Each +View has a dwell duration. Automatic rotation starts only when two Views are +configured. Throw prepares the next View before a change. It keeps the current +View visible until the new View has fresh data and a complete prepared frame. +Both values must belong to the same activation. The surface fades to black, +exchanges atomically, and fades back in. Two Views never share one frame. + +Air & Space is the only configurable View in this release. Automatic rotation +therefore remains dormant. Transit stays in the display and preference formats +as a planned View. It has no runnable identity, so release code cannot add it to +the playlist or send it to the coordinator. A future runtime must add a new +runnable identity and update the exhaustive activation switch. + +## Aircraft sources + +Setup requires an explicit, tested choice of `adsb.lol`, a user-owned local +`readsb` `aircraft.json`, the ADS-B Exchange Personal API through RapidAPI, or +the paid Flightradar24 API. Throw never mixes frames or silently falls back to +another source. API credentials belong to the user and remain in this device's +Keychain; the selected source and non-secret settings live in Throw preferences. + +When labels are enabled, Throw optionally sends newly seen broadcast callsigns +to ADSBDB to resolve origin and destination. Aircraft and observer positions are +not included. Route results stay in a short-lived memory cache and do not delay +or affect the selected aircraft feed. Failed lookups pause for five minutes +before Throw tries the provider again. + +When Flightradar24 is selected, origin and destination come from the same FR24 +record as the aircraft position. Throw does not contact ADSBDB for that source. +FR24 bills the live full-position endpoint by returned aircraft, so credit use +depends on both polling cadence and local traffic density. The source settings +page reads the saved token's last 24 hours from FR24's usage report. Throw uses +the observed credits per request to estimate hourly and 30-day use. The report +can include requests that other clients make with the same token. +An FR24 region that crosses the antimeridian needs two provider requests for +each poll. Throw publishes that poll only when both requests succeed and counts +both requests in its credit estimate. + +Throw keeps aircraft snapshots, routes, and motion history only in memory. +The app predicts from the last successful snapshot until the next poll. +A force quit removes that snapshot. The next launch requests current data. +At a five-minute cadence, the new result can differ from the previous display. + +## Ambient flight activity + +Throw estimates local arrivals and departures from route data and aircraft motion. +A local airport is within 50 NM of the observer. +Inbound and outbound cues stay dim until aircraft enter an approach or initial-climb stage. +These cues use small guide marks around each aircraft in Map and True Sky. + +Map mode also shows the longest open runway for each relevant airport. +Confirmed airports show a code when labels are enabled. +Inferred airports stay graphical. +Throw bundles public-domain airport and runway geometry from OurAirports. + +The activity stages are ambient estimates. +They do not identify literal touchdown or liftoff times. +This limitation is more visible when the selected aircraft source uses a slow polling interval. + +The ADS-B Exchange path is for a personal beta using each user's own +personal/non-commercial subscription. Public distribution requires written +provider authorization, the applicable commercial terms, and a +provider-approved credential architecture that does not ship a shared secret +in the app. Those and the remaining physical release checks are tracked in +[`TODOs.md`](TODOs.md). + +## Offline geography + +Map mode draws a dim Geography layer behind aircraft. It includes generalized +coastlines, lakes, rivers, national boundaries, and regional boundaries. The +United States layer also includes state boundaries, county boundaries, and +primary roads. + +The layer is on by default. You can turn it off or set its intensity from zero +through 20 percent. True Sky does not draw geography. + +Map mode can use a center that differs from the observer location. Throw saves +one fixed center for each coarse observer region. A location refresh within +that region does not move the map. A small dim ring shows the observer location +when it is inside the visible Map. True Sky always uses the observer location. + +Cloud aircraft sources receive a coarse version of the Map center and the query +radius. They do not receive the exact observer location when the centers differ. + +Throw bundles Natural Earth Vector 1:10m data and selected 2025 U.S. Census +Bureau data. Map rendering does not request tiles or send a location to a map +provider. The generated archive contains no place names or road names. + +The data is generalized and is not authoritative. Natural Earth boundaries use +the default de facto view. Census boundaries support statistical work and are +not legal land descriptions. See the [Natural Earth +terms](https://www.naturalearthdata.com/about/terms-of-use/) and the [2025 +TIGER/Line documentation](https://www2.census.gov/geo/pdfs/maps-data/data/tiger/tgrshp2025/TGRSHP2025_TechDoc_Ch1.pdf). + +## External scenes + +iOS 26 discovers noninteractive external displays through the declared scene +role. On iOS 27, the controller also registers a retained +`UISceneAccessory.externalNonInteractive` adapter. The iOS 27 integration was +compiled against the installed beta SDK and must be revalidated against the +iOS 27 GM SDK before release. Focus, keystone, and optical registration remain +projector responsibilities. + +See [`AGENTS.md`](AGENTS.md) for the feature's editing rules and each module's +README for its public API and limitations. diff --git a/Throw/Specifications/BackgroundPreferencePersistence/BackgroundPreferencePersistence.tla b/Throw/Specifications/BackgroundPreferencePersistence/BackgroundPreferencePersistence.tla new file mode 100644 index 000000000..b1f5a41c6 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/BackgroundPreferencePersistence.tla @@ -0,0 +1,1179 @@ +---- MODULE BackgroundPreferencePersistence ---- +EXTENDS FiniteSets, Integers, Sequences + +CONSTANTS Implementation, SceneLimit, ProducerLimit, RequestLimit, FlushLimit + +ASSUME /\ Implementation \in {"current", "untrackedProducer", "uncancelledWaiter"} + /\ SceneLimit \in Nat \ {0} + /\ ProducerLimit \in Nat + /\ RequestLimit \in Nat \ {0} + /\ FlushLimit \in Nat \ {0} + +SceneIDs == 1..SceneLimit +ProducerIDs == 1..ProducerLimit +RequestIDs == 1..RequestLimit +FlushIDs == 1..FlushLimit +WaiterIDs == FlushIDs + +ProducerKinds == {"none", "mutation", "selection", "transition"} +ProducerSources == {"none", "direct", "coordinator"} +ProducerPhases == { + "unused", + "suspendedBeforePublish", + "readyToPublish", + "waitingSave", + "readyAfterSave", + "suspendedAfterPublish", + "readyToFinish", + "done" +} + +Activities == {"idle", "saving", "mutating", "mutatingAndSaving"} +WorkerPhases == {"none", "scheduled", "saving"} +RequestKinds == {"none", "coalesced", "immediate"} +RequestOwners == 0..(ProducerLimit + 1) +RequestStates == {"unused", "pending", "saving", "done"} +RequestResults == {"none", "success", "failure", "cancelled"} +DeferredFailures == {"preference", "playlist"} + +RuntimePhases == {"idle", "active"} +TaskPhases == { + "unused", + "scheduled", + "flushEntry", + "handlerReady", + "registerReady", + "waiting", + "resumed", + "done" +} +TaskOutcomes == {"none", "completed", "cancelled"} +LeaseStates == {"unused", "active", "ended"} +LeaseEndCauses == {"none", "completion", "expiration", "foreground"} +CallbackPhases == {"notQueued", "queued", "rejecting", "admitted", "done"} + +CoverageEvents == { + "admitted-producer", + "producer-at-barrier", + "queued-request", + "worker-saving", + "save-failure", + "deferred-save", + "waiter-parked", + "normal-flush", + "cancel-busy", + "cancel-cleanup", + "registered-waiter-cleanup", + "expired-lease", + "multi-scene", + "second-lease", + "stale-generation", + "coordinator-denied", + "coordinator-reconciled" +} + +ViolationKinds == { + "unadmitted-post-barrier-work", + "work-after-completed-flush", + "unsafe-quiescent-flush", + "wrong-generation-end" +} + +VARIABLES foregroundScenes, + admission, + barrierState, + producerState, + persistenceState, + coordinatorCallback, + runtimeState, + taskState, + waiterState, + leaseState, + coverage, + violations + +vars == <> + +CurrentWaiterDesign == Implementation # "uncancelledWaiter" +LegacyCallbackCanPersist == Implementation = "untrackedProducer" + +MutationIsActive(ps) == ps.activity \in {"mutating", "mutatingAndSaving"} + +SourceQuiescent(ps, producers) == + /\ ps.activity = "idle" + /\ producers.active = {} + +ProtocolQuiescent(ps, producers, callback) == + /\ SourceQuiescent(ps, producers) + /\ ~(LegacyCallbackCanPersist /\ callback.phase = "queued") + +HandlerIsActive(phase) == phase \in {"registerReady", "waiting"} + +UnusedProducers(producers) == + {producer \in ProducerIDs : producers.phase[producer] = "unused"} + +QueueIsAvailable(ps) == ps.nextRequest \in RequestIDs + +SequenceElements(sequence) == + {sequence[index] : index \in 1..Len(sequence)} + +QueuePersistence(ps, owner, kind) == + IF ~QueueIsAvailable(ps) + THEN ps + ELSE LET request == ps.nextRequest + startsWorker == ps.worker = "none" + nextActivity == + CASE ps.activity = "idle" -> "saving" + [] ps.activity = "mutating" -> "mutatingAndSaving" + [] OTHER -> ps.activity + IN [ps EXCEPT + !.activity = nextActivity, + !.worker = IF startsWorker THEN "scheduled" ELSE @, + !.pending = Append(@, request), + !.nextRequest = @ + 1, + !.requestKind[request] = kind, + !.requestOwner[request] = owner, + !.requestState[request] = "pending", + !.workVersion = @ + 1] + +PreferenceWorkViolations(owner) == + violations + \cup (IF admission = "closed" /\ owner \notin barrierState.allowed + THEN {"unadmitted-post-barrier-work"} + ELSE {}) + \cup (IF admission = "closed" /\ barrierState.id \in barrierState.completed + THEN {"work-after-completed-flush"} + ELSE {}) + +ResumeAllWaiters(waiters) == + [waiters EXCEPT + !.registered = {}, + !.resumed = @ \cup waiters.registered, + !.resumeCount = [w \in WaiterIDs |-> + waiters.resumeCount[w] + IF w \in waiters.registered THEN 1 ELSE 0]] + +SignalAllTasks(tasks, waiters, safe, version) == + [tasks EXCEPT + !.signalSafe = [g \in FlushIDs |-> + IF tasks.waiter[g] \in waiters.registered + THEN safe + ELSE tasks.signalSafe[g]], + !.signalVersion = [g \in FlushIDs |-> + IF tasks.waiter[g] \in waiters.registered + THEN version + ELSE tasks.signalVersion[g]]] + +SignalOneTask(tasks, generation, safe, version) == + [tasks EXCEPT + !.signalSafe[generation] = safe, + !.signalVersion[generation] = version] + +CancelWaiterState(waiters, tasks, generation) == + LET waiter == tasks.waiter[generation] + IN [waiters EXCEPT + !.cancelRequested = + IF waiter # 0 THEN @ \cup {waiter} ELSE @, + !.cleanup = + IF CurrentWaiterDesign /\ waiter # 0 /\ HandlerIsActive(tasks.phase[generation]) + THEN @ \cup {waiter} + ELSE @] + +BasePersistenceAfterMutation(ps) == + [ps EXCEPT + !.activity = IF ps.activity = "mutatingAndSaving" THEN "saving" ELSE "idle", + !.mutationProducer = 0, + !.deferred = {}] + +Init == + /\ foregroundScenes = {} + /\ admission = "closed" + /\ barrierState = [id |-> 0, allowed |-> {}, completed |-> {}] + /\ producerState = [ + phase |-> [p \in ProducerIDs |-> "unused"], + kind |-> [p \in ProducerIDs |-> "none"], + source |-> [p \in ProducerIDs |-> "none"], + active |-> {}, + waitRequest |-> [p \in ProducerIDs |-> 0], + enqueueCount |-> [p \in ProducerIDs |-> 0] + ] + /\ persistenceState = [ + activity |-> "idle", + worker |-> "none", + pending |-> <<>>, + inFlight |-> 0, + mutationProducer |-> 0, + deferred |-> {}, + nextRequest |-> 1, + requestKind |-> [r \in RequestIDs |-> "none"], + requestOwner |-> [r \in RequestIDs |-> 0], + requestState |-> [r \in RequestIDs |-> "unused"], + requestResult |-> [r \in RequestIDs |-> "none"], + workVersion |-> 0 + ] + /\ coordinatorCallback = [ + phase |-> "notQueued", + outstanding |-> FALSE, + producer |-> 0 + ] + /\ runtimeState = [phase |-> "idle", id |-> 0, cursor |-> 1] + /\ taskState = [ + phase |-> [g \in FlushIDs |-> "unused"], + cancelled |-> [g \in FlushIDs |-> FALSE], + waiter |-> [g \in FlushIDs |-> 0], + signalSafe |-> [g \in FlushIDs |-> FALSE], + signalVersion |-> [g \in FlushIDs |-> 0], + outcome |-> [g \in FlushIDs |-> "none"] + ] + /\ waiterState = [ + nextID |-> 1, + registered |-> {}, + resumed |-> {}, + cleanup |-> {}, + cancelRequested |-> {}, + owner |-> [w \in WaiterIDs |-> 0], + resumeCount |-> [w \in WaiterIDs |-> 0] + ] + /\ leaseState = [ + state |-> [g \in FlushIDs |-> "unused"], + endCount |-> [g \in FlushIDs |-> 0], + endCause |-> [g \in FlushIDs |-> "none"], + expirationUsed |-> [g \in FlushIDs |-> FALSE] + ] + /\ coverage = {} + /\ violations = {} + +FirstControllerEntersIdle(scene) == + /\ scene \in SceneIDs + /\ foregroundScenes = {} + /\ runtimeState.phase = "idle" + /\ runtimeState.cursor \in FlushIDs + /\ foregroundScenes' = {scene} + /\ admission' = "open" + /\ barrierState' = [barrierState EXCEPT !.id = 0, !.allowed = {}] + /\ UNCHANGED <> + +FirstControllerEntersAndCancels(scene) == + /\ scene \in SceneIDs + /\ foregroundScenes = {} + /\ runtimeState.phase = "active" + /\ LET generation == runtimeState.id + busyEvent == IF persistenceState.activity # "idle" + THEN {"cancel-busy"} + ELSE {} + IN /\ foregroundScenes' = {scene} + /\ admission' = "open" + /\ barrierState' = [barrierState EXCEPT !.id = 0, !.allowed = {}] + /\ runtimeState' = [phase |-> "idle", id |-> 0, cursor |-> generation + 1] + /\ taskState' = [taskState EXCEPT !.cancelled[generation] = TRUE] + /\ waiterState' = CancelWaiterState(waiterState, taskState, generation) + /\ leaseState' = [leaseState EXCEPT + !.state[generation] = "ended", + !.endCount[generation] = @ + 1, + !.endCause[generation] = "foreground"] + /\ coverage' = coverage \cup busyEvent + /\ UNCHANGED <> + +AdditionalControllerEnters(scene) == + /\ scene \in SceneIDs \ foregroundScenes + /\ foregroundScenes # {} + /\ foregroundScenes' = foregroundScenes \cup {scene} + /\ coverage' = coverage \cup (IF Cardinality(foregroundScenes') > 1 + THEN {"multi-scene"} + ELSE {}) + /\ UNCHANGED <> + +ControllerLeavesWithoutClosing(scene) == + /\ scene \in foregroundScenes + /\ Cardinality(foregroundScenes) > 1 + /\ foregroundScenes' = foregroundScenes \ {scene} + /\ UNCHANGED <> + +FinalControllerLeaves(scene) == + /\ foregroundScenes = {scene} + /\ runtimeState.phase = "idle" + /\ runtimeState.cursor \in FlushIDs + /\ LET generation == runtimeState.cursor + barrierCoverage == IF producerState.active # {} + THEN {"producer-at-barrier"} + ELSE {} + repeatedCoverage == IF generation > 1 THEN {"second-lease"} ELSE {} + IN /\ foregroundScenes' = {} + /\ admission' = "closed" + /\ barrierState' = [barrierState EXCEPT + !.id = generation, + !.allowed = producerState.active] + /\ runtimeState' = [phase |-> "active", id |-> generation, + cursor |-> generation] + /\ taskState' = [taskState EXCEPT + !.phase[generation] = "scheduled", + !.cancelled[generation] = FALSE, + !.waiter[generation] = 0, + !.signalSafe[generation] = FALSE, + !.signalVersion[generation] = 0, + !.outcome[generation] = "none"] + /\ leaseState' = [leaseState EXCEPT + !.state[generation] = "active", + !.endCount[generation] = 0, + !.endCause[generation] = "none"] + /\ coverage' = coverage \cup barrierCoverage \cup repeatedCoverage + /\ UNCHANGED <> + +BeginProducer(producer, kind) == + /\ producer \in ProducerIDs + /\ kind \in ProducerKinds \ {"none"} + /\ admission = "open" + /\ producerState.phase[producer] = "unused" + /\ (coordinatorCallback.phase # "queued" \/ + Cardinality(UnusedProducers(producerState)) > 1) + /\ kind = "mutation" => ~MutationIsActive(persistenceState) + /\ producerState' = [producerState EXCEPT + !.phase[producer] = "suspendedBeforePublish", + !.kind[producer] = kind, + !.source[producer] = "direct", + !.active = @ \cup {producer}] + /\ persistenceState' = + IF kind = "mutation" + THEN [persistenceState EXCEPT + !.activity = IF persistenceState.activity = "saving" + THEN "mutatingAndSaving" + ELSE "mutating", + !.mutationProducer = producer, + !.deferred = {}] + ELSE persistenceState + /\ coverage' = coverage \cup {"admitted-producer"} + /\ UNCHANGED <> + +ProducerReturnsFromAwait(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "suspendedBeforePublish" + /\ producerState' = [producerState EXCEPT !.phase[producer] = "readyToPublish"] + /\ UNCHANGED <> + +DeferPreferenceSave(failure) == + /\ failure \in DeferredFailures + /\ MutationIsActive(persistenceState) + /\ persistenceState' = [persistenceState EXCEPT !.deferred = @ \cup {failure}] + /\ coverage' = coverage \cup {"deferred-save"} + /\ UNCHANGED <> + +PublishMutation(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "readyToPublish" + /\ producerState.kind[producer] = "mutation" + /\ QueueIsAvailable(persistenceState) + /\ LET request == persistenceState.nextRequest + IN /\ producerState' = [producerState EXCEPT + !.phase[producer] = "waitingSave", + !.waitRequest[producer] = request, + !.enqueueCount[producer] = @ + 1] + /\ persistenceState' = QueuePersistence( + persistenceState, + producer, + "immediate" + ) + /\ coverage' = coverage \cup {"queued-request"} + /\ violations' = PreferenceWorkViolations(producer) + /\ UNCHANGED <> + +PublishNonmutationWhileMutating(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "readyToPublish" + /\ producerState.kind[producer] \in {"selection", "transition"} + /\ MutationIsActive(persistenceState) + /\ LET isSelection == producerState.kind[producer] = "selection" + nextActive == IF isSelection + THEN producerState.active \ {producer} + ELSE producerState.active + IN /\ producerState' = [producerState EXCEPT + !.phase[producer] = IF isSelection THEN "done" ELSE "suspendedAfterPublish", + !.active = nextActive] + /\ persistenceState' = [persistenceState EXCEPT + !.deferred = @ \cup {"playlist"}] + /\ coordinatorCallback' = coordinatorCallback + /\ coverage' = coverage \cup {"deferred-save"} + /\ UNCHANGED <> + +PublishNonmutation(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "readyToPublish" + /\ producerState.kind[producer] \in {"selection", "transition"} + /\ ~MutationIsActive(persistenceState) + /\ QueueIsAvailable(persistenceState) + /\ LET isSelection == producerState.kind[producer] = "selection" + nextActive == IF isSelection + THEN producerState.active \ {producer} + ELSE producerState.active + IN /\ producerState' = [producerState EXCEPT + !.phase[producer] = IF isSelection THEN "done" ELSE "suspendedAfterPublish", + !.active = nextActive, + !.enqueueCount[producer] = @ + 1] + /\ persistenceState' = QueuePersistence( + persistenceState, + producer, + "coalesced" + ) + /\ coverage' = coverage \cup {"queued-request"} + /\ violations' = PreferenceWorkViolations(producer) + /\ UNCHANGED <> + +SavedMutationCanContinue(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "waitingSave" + /\ producerState.waitRequest[producer] \in RequestIDs + /\ persistenceState.requestState[producerState.waitRequest[producer]] = "done" + /\ producerState' = [producerState EXCEPT !.phase[producer] = "readyAfterSave"] + /\ UNCHANGED <> + +RetryMutation(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "readyAfterSave" + /\ producerState.kind[producer] = "mutation" + /\ producerState.enqueueCount[producer] < 2 + /\ QueueIsAvailable(persistenceState) + /\ LET request == persistenceState.nextRequest + IN /\ producerState' = [producerState EXCEPT + !.phase[producer] = "waitingSave", + !.waitRequest[producer] = request, + !.enqueueCount[producer] = @ + 1] + /\ persistenceState' = QueuePersistence( + persistenceState, + producer, + "immediate" + ) + /\ coverage' = coverage \cup {"queued-request"} + /\ violations' = PreferenceWorkViolations(producer) + /\ UNCHANGED <> + +PublishSavedMutation(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "readyAfterSave" + /\ producerState.kind[producer] = "mutation" + /\ producerState' = [producerState EXCEPT + !.phase[producer] = "suspendedAfterPublish"] + /\ UNCHANGED <> + +PostPublicationAwaitReturns(producer) == + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "suspendedAfterPublish" + /\ producerState' = [producerState EXCEPT !.phase[producer] = "readyToFinish"] + /\ UNCHANGED <> + +FinishNonmutationProducer(producer) == + /\ producer \in ProducerIDs + /\ producerState.kind[producer] \in {"selection", "transition"} + /\ producerState.phase[producer] \in {"suspendedBeforePublish", "readyToPublish", + "readyToFinish"} + /\ LET nextActive == producerState.active \ {producer} + nextProducerState == [producerState EXCEPT + !.phase[producer] = "done", + !.active = nextActive] + nextCallback == + IF producerState.source[producer] = "coordinator" + THEN [coordinatorCallback EXCEPT + !.phase = "done", + !.outstanding = FALSE, + !.producer = 0] + ELSE coordinatorCallback + becomesQuiescent == + persistenceState.activity = "idle" /\ nextActive = {} + IN /\ producerState' = nextProducerState + /\ coordinatorCallback' = nextCallback + /\ waiterState' = IF becomesQuiescent + THEN ResumeAllWaiters(waiterState) + ELSE waiterState + /\ taskState' = IF becomesQuiescent + THEN SignalAllTasks( + taskState, + waiterState, + ProtocolQuiescent( + persistenceState, + nextProducerState, + nextCallback + ), + persistenceState.workVersion + ) + ELSE taskState + /\ UNCHANGED <> + +FinishMutationProducer(producer, persistDeferred) == + /\ producer \in ProducerIDs + /\ persistDeferred \in BOOLEAN + /\ producerState.kind[producer] = "mutation" + /\ producerState.phase[producer] \in {"suspendedBeforePublish", "readyToPublish", + "readyToFinish"} + /\ persistenceState.mutationProducer = producer + /\ (~persistDeferred \/ persistenceState.deferred = {} \/ + QueueIsAvailable(BasePersistenceAfterMutation(persistenceState))) + /\ LET nextActive == producerState.active \ {producer} + nextProducerState == [producerState EXCEPT + !.phase[producer] = "done", + !.active = nextActive] + basePersistence == BasePersistenceAfterMutation(persistenceState) + writesDeferred == persistDeferred /\ persistenceState.deferred # {} + nextPersistence == + IF writesDeferred + THEN QueuePersistence(basePersistence, producer, "coalesced") + ELSE basePersistence + becomesQuiescent == + nextPersistence.activity = "idle" /\ nextActive = {} + IN /\ producerState' = nextProducerState + /\ persistenceState' = nextPersistence + /\ waiterState' = IF becomesQuiescent + THEN ResumeAllWaiters(waiterState) + ELSE waiterState + /\ taskState' = IF becomesQuiescent + THEN SignalAllTasks( + taskState, + waiterState, + ProtocolQuiescent( + nextPersistence, + nextProducerState, + coordinatorCallback + ), + nextPersistence.workVersion + ) + ELSE taskState + /\ coverage' = coverage \cup (IF writesDeferred + THEN {"queued-request"} + ELSE {}) + /\ violations' = IF writesDeferred + THEN PreferenceWorkViolations(producer) + ELSE violations + /\ UNCHANGED <> + +QueueCoordinatorCallback == + /\ coordinatorCallback.phase = "notQueued" + /\ foregroundScenes # {} + /\ (LegacyCallbackCanPersist \/ + \E producer \in ProducerIDs : producerState.phase[producer] = "unused") + /\ coordinatorCallback' = [ + phase |-> "queued", + outstanding |-> TRUE, + producer |-> 0 + ] + /\ UNCHANGED <> + +AdmitCoordinatorCallback(producer) == + /\ ~LegacyCallbackCanPersist + /\ coordinatorCallback.phase = "queued" + /\ admission = "open" + /\ producer \in ProducerIDs + /\ producerState.phase[producer] = "unused" + /\ producerState' = [producerState EXCEPT + !.phase[producer] = "suspendedBeforePublish", + !.kind[producer] = "transition", + !.source[producer] = "coordinator", + !.active = @ \cup {producer}] + /\ coordinatorCallback' = [coordinatorCallback EXCEPT + !.phase = "admitted", + !.producer = producer] + /\ coverage' = coverage \cup {"admitted-producer"} + /\ UNCHANGED <> + +RejectCoordinatorCallback == + /\ ~LegacyCallbackCanPersist + /\ coordinatorCallback.phase = "queued" + /\ admission = "closed" + /\ coordinatorCallback' = [coordinatorCallback EXCEPT !.phase = "rejecting"] + /\ coverage' = coverage \cup {"coordinator-denied"} + /\ UNCHANGED <> + +CoordinatorInvalidationReturns == + /\ coordinatorCallback.phase = "rejecting" + /\ coordinatorCallback' = [ + phase |-> "done", + outstanding |-> FALSE, + producer |-> 0 + ] + /\ coverage' = coverage \cup {"coordinator-reconciled"} + /\ UNCHANGED <> + +ApplyLegacyCoordinatorCallback == + /\ LegacyCallbackCanPersist + /\ coordinatorCallback.phase = "queued" + /\ ~MutationIsActive(persistenceState) + /\ QueueIsAvailable(persistenceState) + /\ persistenceState' = QueuePersistence( + persistenceState, + ProducerLimit + 1, + "coalesced" + ) + /\ coordinatorCallback' = [ + phase |-> "done", + outstanding |-> FALSE, + producer |-> 0 + ] + /\ coverage' = coverage \cup {"queued-request"} + /\ violations' = PreferenceWorkViolations(ProducerLimit + 1) + /\ UNCHANGED <> + +StartWorker == + /\ persistenceState.worker = "scheduled" + /\ Len(persistenceState.pending) > 0 + /\ LET request == Head(persistenceState.pending) + IN persistenceState' = [persistenceState EXCEPT + !.worker = "saving", + !.pending = Tail(@), + !.inFlight = request, + !.requestState[request] = "saving"] + /\ coverage' = coverage \cup {"worker-saving"} + /\ UNCHANGED <> + +SaveReturns(result) == + /\ result \in RequestResults \ {"none"} + /\ persistenceState.worker = "saving" + /\ persistenceState.inFlight \in RequestIDs + /\ LET completed == persistenceState.inFlight + hasNext == Len(persistenceState.pending) > 0 + nextRequest == IF hasNext THEN Head(persistenceState.pending) ELSE 0 + finalActivity == + IF hasNext + THEN persistenceState.activity + ELSE CASE persistenceState.activity = "saving" -> "idle" + [] persistenceState.activity = "mutatingAndSaving" -> "mutating" + completedPersistence == [persistenceState EXCEPT + !.activity = finalActivity, + !.worker = IF hasNext THEN "saving" ELSE "none", + !.pending = IF hasNext THEN Tail(@) ELSE <<>>, + !.inFlight = nextRequest, + !.requestState[completed] = "done", + !.requestResult[completed] = result] + nextPersistence == + IF hasNext + THEN [completedPersistence EXCEPT + !.requestState[nextRequest] = "saving"] + ELSE completedPersistence + becomesQuiescent == + nextPersistence.activity = "idle" /\ producerState.active = {} + IN /\ persistenceState' = nextPersistence + /\ waiterState' = IF becomesQuiescent + THEN ResumeAllWaiters(waiterState) + ELSE waiterState + /\ taskState' = IF becomesQuiescent + THEN SignalAllTasks( + taskState, + waiterState, + ProtocolQuiescent( + nextPersistence, + producerState, + coordinatorCallback + ), + nextPersistence.workVersion + ) + ELSE taskState + /\ coverage' = coverage \cup (IF result = "failure" + THEN {"save-failure"} + ELSE {}) + /\ UNCHANGED <> + +FlushRuntimeGuard(generation) == + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "scheduled" + /\ taskState' = [taskState EXCEPT + !.phase[generation] = IF taskState.cancelled[generation] + THEN "done" + ELSE "flushEntry", + !.outcome[generation] = IF taskState.cancelled[generation] + THEN "cancelled" + ELSE @] + /\ coverage' = coverage \cup (IF taskState.cancelled[generation] + THEN {"cancel-cleanup"} + ELSE {}) + /\ UNCHANGED <> + +AllocateFlushWaiter(generation) == + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "flushEntry" + /\ waiterState.nextID \in WaiterIDs + /\ LET waiter == waiterState.nextID + legacyStopsBeforeAllocation == + ~CurrentWaiterDesign /\ taskState.cancelled[generation] + IN /\ taskState' = [taskState EXCEPT + !.phase[generation] = + IF legacyStopsBeforeAllocation + THEN "done" + ELSE IF CurrentWaiterDesign THEN "handlerReady" ELSE "registerReady", + !.waiter[generation] = + IF legacyStopsBeforeAllocation THEN 0 ELSE waiter, + !.outcome[generation] = + IF legacyStopsBeforeAllocation THEN "cancelled" ELSE @] + /\ waiterState' = + IF legacyStopsBeforeAllocation + THEN waiterState + ELSE [waiterState EXCEPT + !.nextID = @ + 1, + !.owner[waiter] = generation, + !.cancelRequested = + IF taskState.cancelled[generation] + THEN @ \cup {waiter} + ELSE @] + /\ coverage' = coverage \cup (IF legacyStopsBeforeAllocation + THEN {"cancel-cleanup"} + ELSE {}) + /\ UNCHANGED <> + +EnterFlushCancellationHandler(generation) == + /\ CurrentWaiterDesign + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "handlerReady" + /\ LET waiter == taskState.waiter[generation] + IN /\ taskState' = [taskState EXCEPT !.phase[generation] = "registerReady"] + /\ waiterState' = [waiterState EXCEPT + !.cancelRequested = IF taskState.cancelled[generation] + THEN @ \cup {waiter} + ELSE @, + !.cleanup = IF taskState.cancelled[generation] + THEN @ \cup {waiter} + ELSE @] + /\ UNCHANGED <> + +RegisterFlushWaiter(generation) == + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "registerReady" + /\ LET waiter == taskState.waiter[generation] + isQuiescent == SourceQuiescent(persistenceState, producerState) + cancelsSynchronously == CurrentWaiterDesign /\ taskState.cancelled[generation] + resumesNow == isQuiescent \/ cancelsSynchronously + safeSignal == isQuiescent /\ + ProtocolQuiescent(persistenceState, producerState, coordinatorCallback) + IN /\ waiterState' = + IF resumesNow + THEN [waiterState EXCEPT + !.resumed = @ \cup {waiter}, + !.resumeCount[waiter] = @ + 1] + ELSE [waiterState EXCEPT !.registered = @ \cup {waiter}] + /\ taskState' = + IF resumesNow + THEN SignalOneTask( + [taskState EXCEPT !.phase[generation] = "resumed"], + generation, + safeSignal, + persistenceState.workVersion + ) + ELSE [taskState EXCEPT !.phase[generation] = "waiting"] + /\ coverage' = coverage \cup (IF resumesNow + THEN {} + ELSE {"waiter-parked"}) + /\ UNCHANGED <> + +CleanupCanceledWaiter(waiter) == + /\ CurrentWaiterDesign + /\ waiter \in waiterState.cleanup + /\ LET wasRegistered == waiter \in waiterState.registered + generation == waiterState.owner[waiter] + IN /\ waiterState' = [waiterState EXCEPT + !.cleanup = @ \ {waiter}, + !.registered = @ \ {waiter}, + !.resumed = IF wasRegistered THEN @ \cup {waiter} ELSE @, + !.resumeCount[waiter] = IF wasRegistered THEN @ + 1 ELSE @] + /\ taskState' = + IF wasRegistered + THEN SignalOneTask( + taskState, + generation, + FALSE, + persistenceState.workVersion + ) + ELSE taskState + /\ coverage' = coverage \cup {"cancel-cleanup"} + \cup (IF wasRegistered THEN {"registered-waiter-cleanup"} ELSE {}) + /\ UNCHANGED <> + +ObserveWaiterResume(generation) == + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "waiting" + /\ taskState.waiter[generation] \in waiterState.resumed + /\ taskState' = [taskState EXCEPT !.phase[generation] = "resumed"] + /\ UNCHANGED <> + +ReturnFromFlush(generation) == + /\ generation \in FlushIDs + /\ taskState.phase[generation] = "resumed" + /\ LET wasCancelled == taskState.cancelled[generation] + canCompleteLease == ~wasCancelled /\ + runtimeState.phase = "active" /\ runtimeState.id = generation + wrongGeneration == ~wasCancelled /\ runtimeState.phase = "active" /\ + runtimeState.id # generation + IN /\ taskState' = [taskState EXCEPT + !.phase[generation] = "done", + !.outcome[generation] = IF wasCancelled THEN "cancelled" ELSE "completed"] + /\ runtimeState' = + IF canCompleteLease + THEN [phase |-> "idle", id |-> 0, cursor |-> generation + 1] + ELSE runtimeState + /\ leaseState' = + IF canCompleteLease + THEN [leaseState EXCEPT + !.state[generation] = "ended", + !.endCount[generation] = @ + 1, + !.endCause[generation] = "completion"] + ELSE leaseState + /\ barrierState' = + IF wasCancelled + THEN barrierState + ELSE [barrierState EXCEPT !.completed = @ \cup {generation}] + /\ coverage' = coverage \cup (IF wasCancelled + THEN {"cancel-cleanup"} + ELSE {"normal-flush"}) + /\ violations' = violations + \cup (IF ~wasCancelled /\ ~taskState.signalSafe[generation] + THEN {"unsafe-quiescent-flush"} + ELSE {}) + \cup (IF wrongGeneration + THEN {"wrong-generation-end"} + ELSE {}) + /\ UNCHANGED <> + +ExpireBackgroundLease(generation) == + /\ generation \in FlushIDs + /\ leaseState.state[generation] # "unused" + /\ ~leaseState.expirationUsed[generation] + /\ LET expiresCurrent == + runtimeState.phase = "active" /\ runtimeState.id = generation + rejectsStale == + runtimeState.phase = "active" /\ runtimeState.id # generation + busyEvent == IF expiresCurrent /\ persistenceState.activity # "idle" + THEN {"cancel-busy"} + ELSE {} + IN /\ runtimeState' = + IF expiresCurrent + THEN [phase |-> "idle", id |-> 0, cursor |-> generation + 1] + ELSE runtimeState + /\ taskState' = + IF expiresCurrent + THEN [taskState EXCEPT !.cancelled[generation] = TRUE] + ELSE taskState + /\ waiterState' = + IF expiresCurrent + THEN CancelWaiterState(waiterState, taskState, generation) + ELSE waiterState + /\ leaseState' = + IF expiresCurrent + THEN [leaseState EXCEPT + !.state[generation] = "ended", + !.endCount[generation] = @ + 1, + !.endCause[generation] = "expiration", + !.expirationUsed[generation] = TRUE] + ELSE [leaseState EXCEPT !.expirationUsed[generation] = TRUE] + /\ coverage' = coverage + \cup (IF expiresCurrent THEN {"expired-lease"} ELSE {}) + \cup (IF rejectsStale THEN {"stale-generation"} ELSE {}) + \cup busyEvent + /\ UNCHANGED <> + +TerminalStutter == + /\ runtimeState.phase = "idle" + /\ runtimeState.cursor = FlushLimit + 1 + /\ persistenceState.activity = "idle" + /\ producerState.active = {} + /\ coordinatorCallback.phase \in {"notQueued", "done"} + /\ waiterState.registered = {} + /\ waiterState.cleanup = {} + /\ \A g \in FlushIDs : taskState.phase[g] \in {"unused", "done"} + /\ UNCHANGED vars + +ControllerAction == + \/ \E scene \in SceneIDs : FirstControllerEntersIdle(scene) + \/ \E scene \in SceneIDs : FirstControllerEntersAndCancels(scene) + \/ \E scene \in SceneIDs : AdditionalControllerEnters(scene) + \/ \E scene \in SceneIDs : ControllerLeavesWithoutClosing(scene) + \/ \E scene \in SceneIDs : FinalControllerLeaves(scene) + +ProducerAction == + \/ \E producer \in ProducerIDs, kind \in ProducerKinds \ {"none"} : + BeginProducer(producer, kind) + \/ \E producer \in ProducerIDs : ProducerReturnsFromAwait(producer) + \/ \E failure \in DeferredFailures : DeferPreferenceSave(failure) + \/ \E producer \in ProducerIDs : PublishMutation(producer) + \/ \E producer \in ProducerIDs : PublishNonmutationWhileMutating(producer) + \/ \E producer \in ProducerIDs : PublishNonmutation(producer) + \/ \E producer \in ProducerIDs : SavedMutationCanContinue(producer) + \/ \E producer \in ProducerIDs : RetryMutation(producer) + \/ \E producer \in ProducerIDs : PublishSavedMutation(producer) + \/ \E producer \in ProducerIDs : PostPublicationAwaitReturns(producer) + \/ \E producer \in ProducerIDs : FinishNonmutationProducer(producer) + \/ \E producer \in ProducerIDs, persistDeferred \in BOOLEAN : + FinishMutationProducer(producer, persistDeferred) + +CoordinatorAction == + \/ QueueCoordinatorCallback + \/ \E producer \in ProducerIDs : AdmitCoordinatorCallback(producer) + \/ RejectCoordinatorCallback + \/ CoordinatorInvalidationReturns + \/ ApplyLegacyCoordinatorCallback + +WorkerAction == + \/ StartWorker + \/ \E result \in RequestResults \ {"none"} : SaveReturns(result) + +FlushTaskAction(generation) == + \/ FlushRuntimeGuard(generation) + \/ AllocateFlushWaiter(generation) + \/ EnterFlushCancellationHandler(generation) + \/ RegisterFlushWaiter(generation) + \/ ObserveWaiterResume(generation) + \/ ReturnFromFlush(generation) + +CleanupAction(waiter) == CleanupCanceledWaiter(waiter) + +Next == + \/ ControllerAction + \/ ProducerAction + \/ CoordinatorAction + \/ WorkerAction + \/ \E generation \in FlushIDs : FlushTaskAction(generation) + \/ \E waiter \in WaiterIDs : CleanupAction(waiter) + \/ \E generation \in FlushIDs : ExpireBackgroundLease(generation) + \/ TerminalStutter + +Spec == + /\ Init + /\ [][Next]_vars + /\ \A generation \in FlushIDs : WF_vars(FlushTaskAction(generation)) + /\ \A waiter \in WaiterIDs : WF_vars(CleanupAction(waiter)) + /\ WF_vars(CoordinatorInvalidationReturns) + +PersistenceActivityShape == + /\ (persistenceState.activity = "idle") + <=> (persistenceState.worker = "none" /\ + persistenceState.mutationProducer = 0) + /\ (persistenceState.activity = "saving") + <=> (persistenceState.worker # "none" /\ + persistenceState.mutationProducer = 0) + /\ (persistenceState.activity = "mutating") + <=> (persistenceState.worker = "none" /\ + persistenceState.mutationProducer \in ProducerIDs) + /\ (persistenceState.activity = "mutatingAndSaving") + <=> (persistenceState.worker # "none" /\ + persistenceState.mutationProducer \in ProducerIDs) + +TypeOK == + /\ foregroundScenes \subseteq SceneIDs + /\ admission \in {"open", "closed"} + /\ barrierState \in [ + id: 0..FlushLimit, + allowed: SUBSET ProducerIDs, + completed: SUBSET FlushIDs + ] + /\ producerState \in [ + phase: [ProducerIDs -> ProducerPhases], + kind: [ProducerIDs -> ProducerKinds], + source: [ProducerIDs -> ProducerSources], + active: SUBSET ProducerIDs, + waitRequest: [ProducerIDs -> 0..RequestLimit], + enqueueCount: [ProducerIDs -> 0..2] + ] + /\ persistenceState \in [ + activity: Activities, + worker: WorkerPhases, + pending: Seq(RequestIDs), + inFlight: 0..RequestLimit, + mutationProducer: 0..ProducerLimit, + deferred: SUBSET DeferredFailures, + nextRequest: 1..(RequestLimit + 1), + requestKind: [RequestIDs -> RequestKinds], + requestOwner: [RequestIDs -> RequestOwners], + requestState: [RequestIDs -> RequestStates], + requestResult: [RequestIDs -> RequestResults], + workVersion: 0..RequestLimit + ] + /\ coordinatorCallback \in [ + phase: CallbackPhases, + outstanding: BOOLEAN, + producer: 0..ProducerLimit + ] + /\ runtimeState \in [ + phase: RuntimePhases, + id: 0..FlushLimit, + cursor: 1..(FlushLimit + 1) + ] + /\ taskState \in [ + phase: [FlushIDs -> TaskPhases], + cancelled: [FlushIDs -> BOOLEAN], + waiter: [FlushIDs -> 0..FlushLimit], + signalSafe: [FlushIDs -> BOOLEAN], + signalVersion: [FlushIDs -> 0..RequestLimit], + outcome: [FlushIDs -> TaskOutcomes] + ] + /\ waiterState \in [ + nextID: 1..(FlushLimit + 1), + registered: SUBSET WaiterIDs, + resumed: SUBSET WaiterIDs, + cleanup: SUBSET WaiterIDs, + cancelRequested: SUBSET WaiterIDs, + owner: [WaiterIDs -> 0..FlushLimit], + resumeCount: [WaiterIDs -> 0..1] + ] + /\ leaseState \in [ + state: [FlushIDs -> LeaseStates], + endCount: [FlushIDs -> 0..1], + endCause: [FlushIDs -> LeaseEndCauses], + expirationUsed: [FlushIDs -> BOOLEAN] + ] + /\ coverage \subseteq CoverageEvents + /\ violations \subseteq ViolationKinds + /\ PersistenceActivityShape + /\ (admission = "open" <=> foregroundScenes # {}) + /\ (runtimeState.phase = "active" => + /\ foregroundScenes = {} + /\ admission = "closed" + /\ runtimeState.id \in FlushIDs + /\ leaseState.state[runtimeState.id] = "active") + /\ (runtimeState.phase = "idle" => runtimeState.id = 0) + /\ (admission = "open" => + /\ barrierState.id = 0 + /\ barrierState.allowed = {}) + /\ (admission = "closed" => producerState.active \subseteq barrierState.allowed) + /\ producerState.active = { + p \in ProducerIDs : producerState.phase[p] \notin {"unused", "done"} + } + /\ (persistenceState.mutationProducer # 0 => + /\ persistenceState.mutationProducer \in producerState.active + /\ producerState.kind[persistenceState.mutationProducer] = "mutation") + /\ (persistenceState.mutationProducer = 0 => persistenceState.deferred = {}) + /\ (persistenceState.worker = "none" => persistenceState.inFlight = 0) + /\ (persistenceState.worker = "scheduled" => + /\ persistenceState.inFlight = 0 + /\ Len(persistenceState.pending) > 0) + /\ (persistenceState.worker = "saving" => + /\ persistenceState.inFlight \in RequestIDs + /\ persistenceState.requestState[persistenceState.inFlight] = "saving") + /\ \A request \in SequenceElements(persistenceState.pending) : + persistenceState.requestState[request] = "pending" + /\ Cardinality(SequenceElements(persistenceState.pending)) = + Len(persistenceState.pending) + /\ waiterState.registered \cap waiterState.resumed = {} + /\ waiterState.registered \cup waiterState.resumed \cup waiterState.cleanup + \subseteq 1..(waiterState.nextID - 1) + /\ \A generation \in FlushIDs : + taskState.waiter[generation] # 0 => + /\ waiterState.owner[taskState.waiter[generation]] = generation + /\ taskState.waiter[generation] < waiterState.nextID + /\ \A first, second \in FlushIDs : + first # second /\ taskState.waiter[first] # 0 /\ taskState.waiter[second] # 0 + => taskState.waiter[first] # taskState.waiter[second] + /\ (coordinatorCallback.phase \in {"queued", "rejecting", "admitted"} + <=> coordinatorCallback.outstanding) + /\ (coordinatorCallback.phase = "admitted" => + /\ coordinatorCallback.producer \in producerState.active + /\ producerState.source[coordinatorCallback.producer] = "coordinator") + +QuiescentFlushSafety == "unsafe-quiescent-flush" \notin violations + +NoUnadmittedPostBarrierWork == + "unadmitted-post-barrier-work" \notin violations + +NoWorkAfterCompletedFlush == + "work-after-completed-flush" \notin violations + +WaitersResumeAtMostOnce == + \A waiter \in WaiterIDs : waiterState.resumeCount[waiter] <= 1 + +CanceledReturnHasNoRegisteredWaiter == + \A generation \in FlushIDs : + taskState.outcome[generation] = "cancelled" => + taskState.waiter[generation] \notin waiterState.registered + +LeaseEndExactlyOnce == + \A generation \in FlushIDs : + /\ leaseState.endCount[generation] <= 1 + /\ (leaseState.state[generation] = "ended" + <=> leaseState.endCount[generation] = 1) + /\ (leaseState.state[generation] = "active" + => leaseState.endCount[generation] = 0) + +FinishedFlushReleasedLease == + \A generation \in FlushIDs : + taskState.outcome[generation] # "none" => + leaseState.state[generation] = "ended" + +ForegroundGenerationCannotEndNewerLease == + "wrong-generation-end" \notin violations + +CanceledWaitersTerminateAndAreRemoved == + /\ \A generation \in FlushIDs : + taskState.cancelled[generation] ~> taskState.phase[generation] = "done" + /\ \A waiter \in WaiterIDs : + waiter \in waiterState.cancelRequested ~> + (waiter \in waiterState.resumed /\ waiter \notin waiterState.registered) + +DeniedCoordinatorRequestsEventuallyReconcile == + coordinatorCallback.phase = "rejecting" ~> + (coordinatorCallback.phase = "done" /\ ~coordinatorCallback.outstanding) + +PersistenceCoverageNotReached == + ~({"admitted-producer", "producer-at-barrier", "queued-request", + "worker-saving", "save-failure", "deferred-save", "waiter-parked", + "normal-flush"} \subseteq coverage) + +LifecycleCoverageNotReached == + ~({"multi-scene", "cancel-busy", "cancel-cleanup", "second-lease", + "registered-waiter-cleanup", "stale-generation", "expired-lease", + "coordinator-denied", + "coordinator-reconciled"} \subseteq coverage) + +==== diff --git a/Throw/Specifications/BackgroundPreferencePersistence/BrokenUncancelledWaiter.cfg b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUncancelledWaiter.cfg new file mode 100644 index 000000000..f04eb9632 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUncancelledWaiter.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "uncancelledWaiter" + SceneLimit = 1 + ProducerLimit = 1 + RequestLimit = 2 + FlushLimit = 1 + +INVARIANTS + TypeOK + CanceledReturnHasNoRegisteredWaiter + LeaseEndExactlyOnce + +PROPERTY CanceledWaitersTerminateAndAreRemoved + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedPostBarrier.cfg b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedPostBarrier.cfg new file mode 100644 index 000000000..40793be17 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedPostBarrier.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "untrackedProducer" + SceneLimit = 1 + ProducerLimit = 1 + RequestLimit = 2 + FlushLimit = 1 + +INVARIANTS + TypeOK + NoWorkAfterCompletedFlush + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedQuiescence.cfg b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedQuiescence.cfg new file mode 100644 index 000000000..1ddfb1be0 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/BrokenUntrackedQuiescence.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "untrackedProducer" + SceneLimit = 1 + ProducerLimit = 1 + RequestLimit = 2 + FlushLimit = 1 + +INVARIANTS + TypeOK + QuiescentFlushSafety + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/CurrentRepeated.cfg b/Throw/Specifications/BackgroundPreferencePersistence/CurrentRepeated.cfg new file mode 100644 index 000000000..425113a00 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/CurrentRepeated.cfg @@ -0,0 +1,25 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneLimit = 2 + ProducerLimit = 0 + RequestLimit = 1 + FlushLimit = 2 + +INVARIANTS + TypeOK + QuiescentFlushSafety + NoUnadmittedPostBarrierWork + NoWorkAfterCompletedFlush + WaitersResumeAtMostOnce + CanceledReturnHasNoRegisteredWaiter + LeaseEndExactlyOnce + FinishedFlushReleasedLease + ForegroundGenerationCannotEndNewerLease + +PROPERTIES + CanceledWaitersTerminateAndAreRemoved + DeniedCoordinatorRequestsEventuallyReconcile + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/CurrentSmall.cfg b/Throw/Specifications/BackgroundPreferencePersistence/CurrentSmall.cfg new file mode 100644 index 000000000..d9e873597 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/CurrentSmall.cfg @@ -0,0 +1,25 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneLimit = 1 + ProducerLimit = 1 + RequestLimit = 3 + FlushLimit = 1 + +INVARIANTS + TypeOK + QuiescentFlushSafety + NoUnadmittedPostBarrierWork + NoWorkAfterCompletedFlush + WaitersResumeAtMostOnce + CanceledReturnHasNoRegisteredWaiter + LeaseEndExactlyOnce + FinishedFlushReleasedLease + ForegroundGenerationCannotEndNewerLease + +PROPERTIES + CanceledWaitersTerminateAndAreRemoved + DeniedCoordinatorRequestsEventuallyReconcile + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/README.md b/Throw/Specifications/BackgroundPreferencePersistence/README.md new file mode 100644 index 000000000..b4a4ab655 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/README.md @@ -0,0 +1,198 @@ +# Background preference persistence + +This model checks one question. Can Throw reach preference quiescence and release its retained +UIKit background lease safely during producer, worker, scene, cancellation, and expiration races? + +The model represents Throw at revision `60c2540189c899a9efca542c157d6b0686710d06`. +It retains controls for the producer-admission fix in `58e15c31f78ee8c842246c72e96d987682edb0cf`. +It also retains the cancellable-waiter control from `27ff3f0c0eb4eece71a371d435f26367f4a06b3d`. + +The source map includes context renewal from `b53e5786a0cb97f0a370d22b23ea88f4b4633a83`. +It also includes physical polling suspension from `30b569d9927da66badd92a14663efd604d7b3773`. +Those changes add awaits inside producer scopes but do not change the persistence protocol. + +The tracked model uses raw TLA+ because it composes parameterized actions without a process scheduler. +The manifest declares `source: tla`. +The checker does not run PlusCal translation for this concern. + +A relevant change to the mapped source invalidates this result. Check the map and rerun TLC after +such a change. + +## Source correspondence + +| Model state or action | Production counterpart | +| --- | --- | +| `foregroundScenes` and the three controller actions | [`ThrowRuntime.controllerScene`](../../Throw/Sources/ThrowRuntime.swift#L190-L209) keeps aggregate foreground membership. Only the first entry and final exit change session state. | +| `admission` and `barrierState.allowed` | [`controllerForegroundPresenceDidChange`](../../ThrowUI/Sources/Model/ThrowSession.swift#L850-L860) calls [`setAcceptsProducers`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L629-L637) before the runtime starts a flush. Closing admission preserves the active producer set. | +| Typed producer identities and kinds | [`ThrowPreferenceProducerLease`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L498-L519) and [`ProducerAdmission`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L550-L566) make admission and exact-once removal explicit. | +| `BeginProducer` and producer completion | [`beginProducer`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L640-L654), [`finishProducer`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L656-L670), and the typed mutation wrappers at lines 182-202. | +| Direct selection producers | [`performExperienceSelection`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L98-L118) covers explicit, next, and previous commands. The producer spans both coordinator awaits and selection publication. | +| Coordinator transition producers | The action-stream task at [`ThrowSession.swift` lines 807-814](../../ThrowUI/Sources/Model/ThrowSession.swift#L807-L814) calls [`applyExperienceCoordinatorAction`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L152-L210). A denied transition awaits coordinator invalidation instead of publishing. | +| Transition publication and later awaits | [`transitionExperience`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L295-L420) carries one producer through fade and coordinator awaits. Its publication helpers require that producer at lines 423-457 and 515-524. | +| Mutation producers | [`beginMutation` and `finishMutation`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L672-L708) combine one mutation with its producer identity. Aircraft, location, and onboarding mutations use this seam. | +| `suspendedAfterPublish` and `PostPublicationAwaitReturns` | Source and observer transactions await renewal and coordinator configuration at [`ThrowSession+Aircraft.swift` lines 260-270](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L260-L270) and [`ThrowSession+Location.swift` lines 417-420](../../ThrowUI/Sources/Model/ThrowSession+Location.swift#L417-L420). Their admitted mutation remains active. | +| Admitted producer stutter before finish | Active credential deletion awaits physical polling suspension at [`ThrowSession+Aircraft.swift` lines 290-330](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L290-L330). It releases the mutation producer only after those awaits return. | +| `persistenceState.activity` | The exhaustive [`Activity`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L536-L548) enum represents idle, saving, mutating, and mutating while saving. | +| Deferred work | [`recordDeferredFailure`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L710-L742) records save causes during a mutation. [`finishPreferenceMutation`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L186-L192) schedules them before it removes the producer. | +| Pending, scheduled, and saving requests | [`enqueue`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L744-L766), [`takeNextRequest`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L797-L814), and [`drainPreferenceSaveQueue`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L352-L370). | +| Immediate write and retry phases | [`persistReconciledPreferenceMutation`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L212-L287) can retry after its storage await. The mutation producer stays active through publication. | +| Typed waiter identities | [`ThrowPreferenceQuiescenceWaiterID`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L521-L531) gives each continuation a unique identity. | +| Waiter registration, removal, and resume | [`flushPreferencesSave`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift#L159-L180) installs a cancellation handler. Lines 829-867 register, remove, and resume waiters only through typed identities. | +| Runtime generation and retained lease | [`BackgroundPreferenceFlushState`](../../Throw/Sources/ThrowRuntime.swift#L108-L119) stores one generation, UIKit lease, and flush task as one value. | +| Runtime start, completion, and expiration | [`startBackgroundPreferenceFlush`](../../Throw/Sources/ThrowRuntime.swift#L243-L258), [`completeBackgroundPreferenceFlush`](../../Throw/Sources/ThrowRuntime.swift#L265-L273), and [`expireBackgroundPreferenceFlush`](../../Throw/Sources/ThrowRuntime.swift#L275-L284). Both exits compare the captured generation. | +| Idempotent UIKit lease end | [`UIApplicationBackgroundExecutionLease.end`](../../Throw/Sources/ThrowRuntime.swift#L48-L63) clears its identifier before it calls UIKit. | + +The source has the following asynchronous producer paths: + +- `selectExperience`, `selectNextExperience`, and `selectPreviousExperience` use the direct + selection producer in `performExperienceSelection`. +- Automatic rotation and prepared transitions enter through the coordinator action stream. The + `beginTransition` action uses the transition producer before it awaits or publishes. +- [`useSource`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L166-L273) uses a mutation + producer for credential, preference, projection, and coordinator awaits. +- Both credential deletion methods use mutation producers at + [`ThrowSession+Aircraft.swift` lines 276-334](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L276-L334). +- [`saveObserverLocation`](../../ThrowUI/Sources/Model/ThrowSession+Location.swift#L279-L349) and + [`accept`](../../ThrowUI/Sources/Model/ThrowSession+Location.swift#L351-L373) use mutation producers. + [`commitObserverLocation`](../../ThrowUI/Sources/Model/ThrowSession+Location.swift#L375-L422) + keeps that producer active through persistence, renewal, and coordinator configuration. +- [`completeOnboarding`](../../ThrowUI/Sources/Model/ThrowSession+Onboarding.swift#L5-L86) uses a + mutation producer around its final write, retry, publication, and coordinator await. + +These are all calls to `beginPreferenceMutation` or `beginPreferenceProducer` in production +sources at the modeled revision. + +The model represents each main-actor segment as one atomic action. It splits the following real +await or reentrancy boundaries: + +- A producer starts before its first await. It can return, publish preference-backed state, await + more work, and then release its typed lease. +- Source and observer mutations await projection renewal and coordinator configuration after publication. + They stutter in this model while the admitted mutation remains in `suspendedAfterPublish`. +- Active credential deletion awaits physical polling suspension before producer release. + That await stutters while the admitted mutation remains active before its finish action. +- The separate [`ProjectionActivation`](../ProjectionActivation/README.md) model verifies those renewal and suspension protocols. + This model keeps only producer activity across their awaits. +- An immediate mutation write suspends in `preferenceStore.save`. It can retry against a newer + snapshot before it publishes. +- Starting the preference worker permits main-actor reentrancy before the worker dequeues a request. +- Each store save can succeed, fail, or receive cancellation. The worker then dequeues the next + request or becomes idle without another await. +- A coordinator transition callback can wait for main-actor entry. Admission can close before that + entry. A denied callback then waits for coordinator invalidation. +- The runtime task checks cancellation before flush entry. Waiter allocation, handler installation, + registration, suspension, resumption, and return are separate actions. +- Foreground entry or UIKit expiration can occur between any enabled actions. Each event cancels + only the matching runtime generation. + +## Properties + +- `TypeOK` checks every variable and the exhaustive persistence activity shape. +- `QuiescentFlushSafety` permits successful flush return only after the source is quiescent. No + active admitted producer can publish more preference work. +- `NoUnadmittedPostBarrierWork` rejects preference work from a callback or producer that was not in + the closed barrier. +- `NoWorkAfterCompletedFlush` rejects any enqueue after a completed background flush while + admission remains closed. +- `WaitersResumeAtMostOnce` checks exact-once continuation resumption. +- `CanceledReturnHasNoRegisteredWaiter` checks that a canceled flush cannot return with a parked + continuation. +- `CanceledWaitersTerminateAndAreRemoved` checks cancellation liveness for tasks and typed waiters. +- `LeaseEndExactlyOnce` checks each retained background lease across completion, expiration, and + foreground cancellation. +- `FinishedFlushReleasedLease` checks that every finished task has released its retained lease. +- `ForegroundGenerationCannotEndNewerLease` rejects completion or expiration from an older + generation that changes the newer lease. +- `DeniedCoordinatorRequestsEventuallyReconcile` checks that denied automatic transitions clear + the coordinator request. +- Both current configurations check deadlock. The terminal stutter represents exhaustion of the + finite generation bound, not an application deadlock. + +## Bounds and results + +`CurrentSmall.cfg` models one scene, one producer, three requests, and one flush generation. It +checks mutation, selection, transition, deferred-save, retry, worker, waiter, cancellation, and +completion interleavings. + +`CurrentRepeated.cfg` models two scenes and two flush generations without producers. This separate +bound isolates aggregate scene membership, typed waiter allocation, stale expiration, and retained +lease ownership. + +**Verified for these model bounds and assumptions.** + +TLC exhausted both current state spaces without an invariant, temporal, or deadlock error. +The historical controls failed for their mapped reasons. +Both reachability controls reached their required branches. + +| Configuration | Purpose | Generated / distinct states | Depth | Result | +| --- | --- | ---: | ---: | --- | +| `CurrentSmall.cfg` | Current producer and persistence protocol | 373,130 / 95,306 | 30 | Pass | +| `CurrentRepeated.cfg` | Current multi-scene and repeated-generation protocol | 35,689 / 9,375 | 21 | Pass | +| `ReachPersistence.cfg` | Persistence anti-vacuity trace | 27,394 / 9,401 | 14 | Expected reachability failure | +| `ReachLifecycle.cfg` | Lifecycle and registered-waiter anti-vacuity trace | 5,122,617 / 1,289,319 | 18 | Expected reachability failure | +| `BrokenUntrackedQuiescence.cfg` | Pre-`58e15c31` callback control | 6,689 / 2,826 | 9 | Expected safety failure | +| `BrokenUntrackedPostBarrier.cfg` | Pre-`58e15c31` post-flush control | 13,754 / 5,575 | 10 | Expected safety failure | +| `BrokenUncancelledWaiter.cfg` | Pre-`27ff3f0c` waiter control | 84,852 / 23,534 | lasso | Expected temporal failure | + +The persistence reachability trace crosses the barrier with an admitted mutation. It defers a +save, schedules that save before producer release, parks a waiter, and returns a storage failure. +The worker then becomes idle and resumes the waiter. The runtime completes the flush normally. + +The lifecycle reachability trace uses two foreground scenes and two background generations. It +denies and reconciles a queued coordinator transition. It also expires a busy flush with a +registered waiter. The cancellation task removes and resumes that waiter. A late expiration from +the first generation cannot end the second lease. + +## Broken controls + +`BrokenUntrackedQuiescence.cfg` models the coordinator callback before `58e15c31`. The callback is +queued before admission closes, but it owns no producer lease. The flush observes idle persistence +and no active producer. It resumes and ends the background lease without accounting for the queued +callback. + +`BrokenUntrackedPostBarrier.cfg` continues that trace. The old callback enqueues preference work +after the runtime completed its flush. This violates the closed barrier directly. + +`BrokenUncancelledWaiter.cfg` models the waiter before `27ff3f0c`. Foreground entry cancels the +runtime task after its initial guard. The task remains parked because no cancellation handler +removes its registered continuation. The trace can stutter while an external producer stays +suspended. The current handler does not depend on producer completion. + +The two reachability configurations deliberately invert their coverage goals. Their expected +failures prove that TLC reached deferred failure, parked waiter, registered-waiter cleanup, +multi-scene, stale-generation, expiration, and coordinator-reconciliation branches. + +## Fairness and exclusions + +Weak fairness applies only to each scheduled flush task, each scheduled waiter-cleanup task, and +the coordinator invalidation return. These operations correspond to finite Swift tasks or one +actor call. + +The model does not add fairness for producer awaits, preference storage, worker scheduling, scene +events, or UIKit expiration. Therefore, it does not claim that an uncanceled flush always +finishes. A hung store can retain work until UIKit expires the lease. + +The finite producer and waiter sets represent typed `UInt64` identities without overflow. The +model reserves one unused producer identity for a queued current callback. This rule prevents a +small bound from creating an identity-exhaustion deadlock that production cannot reach. + +The model allows two mutation writes. This bound represents the first write and one retry after +main-actor drift. A longer retry sequence keeps the same producer active, so it cannot cross the +closed barrier unnoticed. + +The model abstracts preference values, request coalescing contents, diagnostics, and projection +presentation details. It does not model process termination, identifier overflow, or an invalid +UIKit background-task identifier. + +The check used tla2tools 1.7.4 and TLC2 2.19 at revision `5a47802`. +It used Eclipse Temurin Java 21.0.8+9. +The pinned JAR SHA-256 is +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`. + +## Run it + +From the repository root: + +```sh +./tla-check BackgroundPreferencePersistence +``` diff --git a/Throw/Specifications/BackgroundPreferencePersistence/ReachLifecycle.cfg b/Throw/Specifications/BackgroundPreferencePersistence/ReachLifecycle.cfg new file mode 100644 index 000000000..24f0f3e51 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/ReachLifecycle.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneLimit = 2 + ProducerLimit = 1 + RequestLimit = 2 + FlushLimit = 2 + +INVARIANTS + TypeOK + LifecycleCoverageNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/ReachPersistence.cfg b/Throw/Specifications/BackgroundPreferencePersistence/ReachPersistence.cfg new file mode 100644 index 000000000..a10e7601b --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/ReachPersistence.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneLimit = 1 + ProducerLimit = 1 + RequestLimit = 3 + FlushLimit = 1 + +INVARIANTS + TypeOK + PersistenceCoverageNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/BackgroundPreferencePersistence/manifest.json b/Throw/Specifications/BackgroundPreferencePersistence/manifest.json new file mode 100644 index 000000000..0dc25b529 --- /dev/null +++ b/Throw/Specifications/BackgroundPreferencePersistence/manifest.json @@ -0,0 +1,46 @@ +{ + "source": "tla", + "module": "BackgroundPreferencePersistence.tla", + "cases": [ + { + "name": "broken-untracked-quiescence", + "config": "BrokenUntrackedQuiescence.cfg", + "expect": "fail", + "outputContains": "Invariant QuiescentFlushSafety is violated." + }, + { + "name": "broken-untracked-post-barrier", + "config": "BrokenUntrackedPostBarrier.cfg", + "expect": "fail", + "outputContains": "Invariant NoWorkAfterCompletedFlush is violated." + }, + { + "name": "broken-uncancelled-waiter", + "config": "BrokenUncancelledWaiter.cfg", + "expect": "fail", + "outputContains": "Temporal properties were violated." + }, + { + "name": "persistence-reachability", + "config": "ReachPersistence.cfg", + "expect": "fail", + "outputContains": "Invariant PersistenceCoverageNotReached is violated." + }, + { + "name": "lifecycle-reachability", + "config": "ReachLifecycle.cfg", + "expect": "fail", + "outputContains": "Invariant LifecycleCoverageNotReached is violated." + }, + { + "name": "current-small", + "config": "CurrentSmall.cfg", + "expect": "pass" + }, + { + "name": "current-repeated", + "config": "CurrentRepeated.cfg", + "expect": "pass" + } + ] +} diff --git a/Throw/Specifications/PollingPublication/BrokenLeaseLess.cfg b/Throw/Specifications/PollingPublication/BrokenLeaseLess.cfg new file mode 100644 index 000000000..5c91170bc --- /dev/null +++ b/Throw/Specifications/PollingPublication/BrokenLeaseLess.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "leaseLess" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + ExactTokenPublicationSafety + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/BrokenRevisionLess.cfg b/Throw/Specifications/PollingPublication/BrokenRevisionLess.cfg new file mode 100644 index 000000000..aea698479 --- /dev/null +++ b/Throw/Specifications/PollingPublication/BrokenRevisionLess.cfg @@ -0,0 +1,14 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "revisionLess" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + CorrectAtQuiescence + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/CurrentDeactivation.cfg b/Throw/Specifications/PollingPublication/CurrentDeactivation.cfg new file mode 100644 index 000000000..593c19d7a --- /dev/null +++ b/Throw/Specifications/PollingPublication/CurrentDeactivation.cfg @@ -0,0 +1,19 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = TRUE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + ExactTokenAcceptanceSafety + ExactTokenPublicationSafety + AcceptedRevisionsNeverRegress + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/CurrentRepeated.cfg b/Throw/Specifications/PollingPublication/CurrentRepeated.cfg new file mode 100644 index 000000000..ff4136989 --- /dev/null +++ b/Throw/Specifications/PollingPublication/CurrentRepeated.cfg @@ -0,0 +1,19 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- ActivationThenUpdate + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + ExactTokenAcceptanceSafety + ExactTokenPublicationSafety + AcceptedRevisionsNeverRegress + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/CurrentSingle.cfg b/Throw/Specifications/PollingPublication/CurrentSingle.cfg new file mode 100644 index 000000000..1cf113a7f --- /dev/null +++ b/Throw/Specifications/PollingPublication/CurrentSingle.cfg @@ -0,0 +1,19 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + ExactTokenAcceptanceSafety + ExactTokenPublicationSafety + AcceptedRevisionsNeverRegress + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/CurrentUpdate.cfg b/Throw/Specifications/PollingPublication/CurrentUpdate.cfg new file mode 100644 index 000000000..59bcbf96d --- /dev/null +++ b/Throw/Specifications/PollingPublication/CurrentUpdate.cfg @@ -0,0 +1,19 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneUpdate + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + AcceptanceShape + CorePublicationShape + ExactTokenAcceptanceSafety + ExactTokenPublicationSafety + AcceptedRevisionsNeverRegress + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PollingPublication/EarlyUpdateReachability.cfg b/Throw/Specifications/PollingPublication/EarlyUpdateReachability.cfg new file mode 100644 index 000000000..b607a9196 --- /dev/null +++ b/Throw/Specifications/PollingPublication/EarlyUpdateReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + EarlyNewUpdateRejectionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/InactiveReachability.cfg b/Throw/Specifications/PollingPublication/InactiveReachability.cfg new file mode 100644 index 000000000..b5426d24c --- /dev/null +++ b/Throw/Specifications/PollingPublication/InactiveReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = TRUE + +INVARIANTS + TypeOK + InactiveApplicationNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/OldInFlightReachability.cfg b/Throw/Specifications/PollingPublication/OldInFlightReachability.cfg new file mode 100644 index 000000000..99af6f379 --- /dev/null +++ b/Throw/Specifications/PollingPublication/OldInFlightReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + OldBufferedOrInFlightNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/OldRejectionReachability.cfg b/Throw/Specifications/PollingPublication/OldRejectionReachability.cfg new file mode 100644 index 000000000..2239509d6 --- /dev/null +++ b/Throw/Specifications/PollingPublication/OldRejectionReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + OldUpdateRejectionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/OvertakeReachability.cfg b/Throw/Specifications/PollingPublication/OvertakeReachability.cfg new file mode 100644 index 000000000..a20168822 --- /dev/null +++ b/Throw/Specifications/PollingPublication/OvertakeReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + OvertakenRecoveryRejectionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/PollingPublication.tla b/Throw/Specifications/PollingPublication/PollingPublication.tla new file mode 100644 index 000000000..aefa972ea --- /dev/null +++ b/Throw/Specifications/PollingPublication/PollingPublication.tla @@ -0,0 +1,541 @@ +---- MODULE PollingPublication ---- +EXTENDS Integers, Sequences + +CONSTANTS Implementation, Operations, FinalDeactivate + +AllowedImplementations == {"current", "leaseLess", "revisionLess"} +AllowedOperations == {"activate", "update"} + +ASSUME /\ Implementation \in AllowedImplementations + /\ Operations \in Seq(AllowedOperations) + /\ Len(Operations) > 0 + /\ FinalDeactivate \in BOOLEAN + +ContextCount == Len(Operations) + 1 +Contexts == 1..ContextCount +Tokens == Contexts +Revisions == 0..2 + +NoUpdate == [ + kind |-> "none", + token |-> 0, + context |-> 0, + revision |-> 0, + state |-> "none" +] + +InactiveUpdate == [ + kind |-> "inactive", + token |-> 0, + context |-> 0, + revision |-> 0, + state |-> "inactive" +] + +ActiveUpdate(token, revision, state) == [ + kind |-> "active", + token |-> token, + context |-> token, + revision |-> revision, + state |-> state +] + +ActiveUpdates == + {ActiveUpdate(token, 1, "loading") : token \in Tokens} \union + {ActiveUpdate(token, 2, "healthy") : token \in Tokens} + +Updates == {NoUpdate, InactiveUpdate} \union ActiveUpdates + +InactiveAcceptance(applied) == [ + kind |-> "inactive", + token |-> 0, + revision |-> 0, + inactiveApplied |-> applied +] + +AwaitingAcceptance == [ + kind |-> "awaiting", + token |-> 0, + revision |-> 0, + inactiveApplied |-> FALSE +] + +ActiveAcceptance(token, revision) == [ + kind |-> "active", + token |-> token, + revision |-> revision, + inactiveApplied |-> FALSE +] + +AcceptanceStates == + {InactiveAcceptance(applied) : applied \in BOOLEAN} \union + {AwaitingAcceptance} \union + {ActiveAcceptance(token, revision) : + token \in Tokens, revision \in Revisions} + +SourcePhases == {"dormant", "ready", "done"} +LifecyclePhases == { + "stable", "resetting", "callingCore", "callingUpdate", "tokenMinted", "draining", "returningToken", + "installingToken", "readingCurrent", "recoveryHeld", "deactivating", + "deactivationDrain", "resettingInactive", "done" +} + +MaxGeneration == 4 * ContextCount + 4 + +Frame(token, context, revision, generation) == [ + token |-> token, + context |-> context, + revision |-> revision, + generation |-> generation +] + +Frames == [ + token : Tokens, + context : Contexts, + revision : 1..2, + generation : 0..MaxGeneration +] + +OneActivation == <<"activate">> +OneUpdate == <<"update">> +ActivationThenUpdate == <<"activate", "update">> + +(* --algorithm PollingPublicationAlgorithm { +variables operationIndex = 0, + lifecyclePhase = "stable", + targetContext = 1, + mintedToken = 1, + coreToken = 1, + coreContext = 1, + coreRevision = 1, + coreUpdate = ActiveUpdate(1, 1, "loading"), + sourcePhase = [token \in Tokens |-> + IF token = 1 THEN "ready" ELSE "dormant"], + streamBuffer = ActiveUpdate(1, 1, "loading"), + streamDelivery = NoUpdate, + recoveryUpdate = NoUpdate, + acceptance = ActiveAcceptance(1, 1), + lastConsumed = ActiveUpdate(1, 1, "loading"), + stateGeneration = 1, + pendingFrames = {}, + uiState = "loading", + contentContext = 0, + contentToken = 0, + contentRevision = 0, + revisionOrderPreserved = TRUE, + finished = FALSE, + sawOldBufferedOrInFlight = FALSE, + sawOldUpdateRejected = FALSE, + sawEarlyNewUpdateRejected = FALSE, + sawRecoveryApplied = FALSE, + sawOvertakenRecoveryRejected = FALSE, + sawStaleFrameRejected = FALSE, + sawInactiveApplied = FALSE, + sawUpdateOperation = FALSE; + +define { + IsActiveUpdate(update) == update.kind = "active" + + IsOldForTarget(update) == + /\ IsActiveUpdate(update) + /\ update.context /= targetContext + + AcceptsActive(update) == + /\ IsActiveUpdate(update) + /\ CASE Implementation = "leaseLess" -> targetContext /= 0 + [] Implementation = "revisionLess" -> + /\ acceptance.kind = "active" + /\ update.token = acceptance.token + /\ update /= lastConsumed + [] Implementation = "current" -> + /\ acceptance.kind = "active" + /\ update.token = acceptance.token + /\ update.revision > acceptance.revision + + AcceptsInactive(update) == + /\ update = InactiveUpdate + /\ CASE Implementation = "current" -> + /\ acceptance.kind = "inactive" + /\ ~acceptance.inactiveApplied + [] Implementation \in {"leaseLess", "revisionLess"} -> + /\ targetContext = 0 + /\ update /= lastConsumed + + Quiescent == + /\ finished + /\ lifecyclePhase = "done" + /\ streamBuffer = NoUpdate + /\ streamDelivery = NoUpdate + /\ recoveryUpdate = NoUpdate + /\ pendingFrames = {} + /\ \A token \in Tokens : sourcePhase[token] /= "ready" + + CorrectlySettled == + /\ Quiescent + /\ IF FinalDeactivate + THEN /\ targetContext = 0 + /\ coreToken = 0 + /\ coreContext = 0 + /\ coreRevision = 0 + /\ coreUpdate = InactiveUpdate + /\ acceptance.kind = "inactive" + /\ uiState = "inactive" + /\ contentContext = 0 + /\ contentToken = 0 + /\ contentRevision = 0 + ELSE /\ targetContext = ContextCount + /\ coreToken = ContextCount + /\ coreContext = ContextCount + /\ coreRevision = 2 + /\ coreUpdate = ActiveUpdate(ContextCount, 2, "healthy") + /\ acceptance = ActiveAcceptance(ContextCount, 2) + /\ uiState = "healthy" + /\ contentContext = ContextCount + /\ contentToken = ContextCount + /\ contentRevision = 2 +} + +fair process (Lifecycle = <<"Lifecycle", 0>>) { +BeginNextOperation: + while (operationIndex < Len(Operations)) { + with (nextContext = operationIndex + 2) { + targetContext := nextContext || + lifecyclePhase := "resetting" || + acceptance := AwaitingAcceptance || + lastConsumed := NoUpdate || + stateGeneration := stateGeneration + 1 || + uiState := "loading" || + contentContext := 0 || + contentToken := 0 || + contentRevision := 0 || + sawOldBufferedOrInFlight := + sawOldBufferedOrInFlight \/ + (IsActiveUpdate(streamBuffer) /\ + streamBuffer.context /= nextContext) \/ + (IsActiveUpdate(streamDelivery) /\ + streamDelivery.context /= nextContext); + }; + +ResetOrQueryBoundary: + \* An activation awaits flightsRuntime.reset(). A query-only update + \* skips that call, but still crosses into the coordinator actor. + lifecyclePhase := IF Operations[operationIndex + 1] = "activate" + THEN "callingCore" ELSE "callingUpdate" || + sawUpdateOperation := sawUpdateOperation \/ + (Operations[operationIndex + 1] = "update"); + +CoordinatorMintsToken: + \* activate(...) and update(...) mint before their queued operation runs. + mintedToken := operationIndex + 2 || + lifecyclePhase := "tokenMinted"; + +CoreBeginReplacement: + with (oldToken = coreToken) { + coreToken := 0 || + coreContext := 0 || + coreRevision := 0 || + coreUpdate := InactiveUpdate || + streamBuffer := InactiveUpdate || + lifecyclePhase := "draining" || + sourcePhase := IF oldToken = 0 + THEN sourcePhase + ELSE [sourcePhase EXCEPT ![oldToken] = "done"]; + }; + +CoreDrainOldPoller: + \* replace(...) is suspended at await oldTask.value here. + with (newToken = mintedToken) { + coreToken := newToken || + coreContext := newToken || + coreRevision := 1 || + coreUpdate := ActiveUpdate(newToken, 1, "loading") || + sourcePhase := [sourcePhase EXCEPT ![newToken] = "ready"] || + streamBuffer := ActiveUpdate(newToken, 1, "loading") || + lifecyclePhase := "returningToken"; + }; + +CoordinatorReturnsToken: + \* The runtime resumes after await pollingCoordinator.activate/update. + lifecyclePhase := "installingToken"; + +InstallExpectedToken: + with (newToken = mintedToken) { + acceptance := ActiveAcceptance(newToken, 0) || + lifecyclePhase := "readingCurrent"; + }; + +CaptureCurrentUpdate: + \* currentUpdate() captures Core state before the caller actor resumes. + recoveryUpdate := coreUpdate || + lifecyclePhase := "recoveryHeld"; + +ApplyCurrentUpdate: + if (AcceptsActive(recoveryUpdate)) { + with (newGeneration = stateGeneration + 1) { + revisionOrderPreserved := + revisionOrderPreserved /\ + (acceptance.kind /= "active" \/ + recoveryUpdate.token /= acceptance.token \/ + recoveryUpdate.revision > acceptance.revision) || + acceptance := IF Implementation = "leaseLess" + THEN acceptance + ELSE ActiveAcceptance( + recoveryUpdate.token, + recoveryUpdate.revision) || + lastConsumed := recoveryUpdate || + stateGeneration := newGeneration || + pendingFrames := IF recoveryUpdate.state = "healthy" + THEN pendingFrames \union { + Frame( + recoveryUpdate.token, + recoveryUpdate.context, + recoveryUpdate.revision, + newGeneration) + } + ELSE pendingFrames || + uiState := IF recoveryUpdate.state = "loading" + THEN "loading" ELSE uiState || + sawRecoveryApplied := TRUE; + }; + } else { + sawOldUpdateRejected := + sawOldUpdateRejected \/ IsOldForTarget(recoveryUpdate) || + sawEarlyNewUpdateRejected := + sawEarlyNewUpdateRejected \/ + (IsActiveUpdate(recoveryUpdate) /\ + recoveryUpdate.context = targetContext /\ + acceptance.kind = "awaiting") || + sawOvertakenRecoveryRejected := + sawOvertakenRecoveryRejected \/ + (Implementation = "current" /\ + IsActiveUpdate(recoveryUpdate) /\ + acceptance.kind = "active" /\ + recoveryUpdate.token = acceptance.token /\ + recoveryUpdate.revision < acceptance.revision); + }; + recoveryUpdate := NoUpdate || + operationIndex := operationIndex + 1 || + lifecyclePhase := "stable"; + }; + +BeginFinalDeactivation: + if (FinalDeactivate) { + targetContext := 0 || + lifecyclePhase := "deactivating" || + acceptance := InactiveAcceptance(FALSE) || + lastConsumed := NoUpdate || + stateGeneration := stateGeneration + 1 || + contentContext := 0 || + contentToken := 0 || + contentRevision := 0; + +CoreBeginDeactivation: + with (oldToken = coreToken) { + coreToken := 0 || + coreContext := 0 || + coreRevision := 0 || + coreUpdate := InactiveUpdate || + streamBuffer := InactiveUpdate || + lifecyclePhase := "deactivationDrain" || + sourcePhase := IF oldToken = 0 + THEN sourcePhase + ELSE [sourcePhase EXCEPT ![oldToken] = "done"]; + }; + +CoreDrainForDeactivation: + \* performDeactivate(...) publishes inactive again after the drain. + coreUpdate := InactiveUpdate || + streamBuffer := InactiveUpdate || + lifecyclePhase := "resettingInactive"; + +FinishDeactivation: + \* The runtime resumes after flightsRuntime.reset(). + uiState := "inactive" || + finished := TRUE || + lifecyclePhase := "done"; + } else { + finished := TRUE || + lifecyclePhase := "done"; + }; + +LifecycleDone: + while (TRUE) { + await Quiescent; + skip; + } +} + +fair process (Source \in {<<"Source", token>> : token \in Tokens}) { +PublishHealthySnapshot: + while (TRUE) { + await sourcePhase[self[2]] = "ready" /\ coreToken = self[2]; + coreRevision := 2 || + coreUpdate := ActiveUpdate(self[2], 2, "healthy") || + streamBuffer := ActiveUpdate(self[2], 2, "healthy") || + sourcePhase := [sourcePhase EXCEPT ![self[2]] = "done"]; + } +} + +fair process (Observer = <<"Observer", 0>>) { +TakeBufferedUpdate: + while (TRUE) { + await streamDelivery = NoUpdate /\ streamBuffer /= NoUpdate; + streamDelivery := streamBuffer || + streamBuffer := NoUpdate; + +ApplyStreamUpdate: + if (AcceptsActive(streamDelivery)) { + with (newGeneration = stateGeneration + 1) { + revisionOrderPreserved := + revisionOrderPreserved /\ + (acceptance.kind /= "active" \/ + streamDelivery.token /= acceptance.token \/ + streamDelivery.revision > acceptance.revision) || + acceptance := IF Implementation = "leaseLess" + THEN acceptance + ELSE ActiveAcceptance( + streamDelivery.token, + streamDelivery.revision) || + lastConsumed := streamDelivery || + stateGeneration := newGeneration || + pendingFrames := IF streamDelivery.state = "healthy" + THEN pendingFrames \union { + Frame( + streamDelivery.token, + streamDelivery.context, + streamDelivery.revision, + newGeneration) + } + ELSE pendingFrames || + uiState := IF streamDelivery.state = "loading" + THEN "loading" ELSE uiState; + }; + } else if (AcceptsInactive(streamDelivery)) { + acceptance := IF Implementation = "current" + THEN InactiveAcceptance(TRUE) + ELSE acceptance || + lastConsumed := streamDelivery || + stateGeneration := stateGeneration + 1 || + uiState := "inactive" || + contentContext := 0 || + contentToken := 0 || + contentRevision := 0 || + sawInactiveApplied := TRUE; + } else { + sawOldUpdateRejected := + sawOldUpdateRejected \/ IsOldForTarget(streamDelivery) || + sawEarlyNewUpdateRejected := + sawEarlyNewUpdateRejected \/ + (IsActiveUpdate(streamDelivery) /\ + streamDelivery.context = targetContext /\ + acceptance.kind = "awaiting"); + }; + streamDelivery := NoUpdate; + } +} + +fair process (FrameWorker = <<"FrameWorker", 0>>) { +CompleteFrame: + while (TRUE) { + await pendingFrames /= {}; + with (frame \in pendingFrames) { + pendingFrames := pendingFrames \ {frame}; + if (frame.generation = stateGeneration) { + uiState := "healthy" || + contentContext := frame.context || + contentToken := frame.token || + contentRevision := frame.revision; + } else { + sawStaleFrameRejected := TRUE; + }; + }; + } +} +} *) + +TypeOK == + /\ operationIndex \in 0..Len(Operations) + /\ lifecyclePhase \in LifecyclePhases + /\ targetContext \in 0..ContextCount + /\ mintedToken \in Tokens + /\ coreToken \in 0..ContextCount + /\ coreContext \in 0..ContextCount + /\ coreRevision \in Revisions + /\ coreUpdate \in Updates + /\ sourcePhase \in [Tokens -> SourcePhases] + /\ streamBuffer \in Updates + /\ streamDelivery \in Updates + /\ recoveryUpdate \in Updates + /\ acceptance \in AcceptanceStates + /\ lastConsumed \in Updates + /\ stateGeneration \in 0..MaxGeneration + /\ pendingFrames \subseteq Frames + /\ uiState \in {"inactive", "loading", "healthy"} + /\ contentContext \in 0..ContextCount + /\ contentToken \in 0..ContextCount + /\ contentRevision \in Revisions + /\ revisionOrderPreserved \in BOOLEAN + /\ finished \in BOOLEAN + /\ sawOldBufferedOrInFlight \in BOOLEAN + /\ sawOldUpdateRejected \in BOOLEAN + /\ sawEarlyNewUpdateRejected \in BOOLEAN + /\ sawRecoveryApplied \in BOOLEAN + /\ sawOvertakenRecoveryRejected \in BOOLEAN + /\ sawStaleFrameRejected \in BOOLEAN + /\ sawInactiveApplied \in BOOLEAN + /\ sawUpdateOperation \in BOOLEAN + +AcceptanceShape == + /\ (acceptance.kind = "active" => + /\ acceptance.token \in Tokens + /\ acceptance.revision \in Revisions + /\ acceptance.token = targetContext) + /\ (acceptance.kind /= "active" => + /\ acceptance.token = 0 + /\ acceptance.revision = 0) + +CorePublicationShape == + /\ (coreToken = 0 => + /\ coreContext = 0 + /\ coreRevision = 0 + /\ coreUpdate = InactiveUpdate) + /\ (coreToken /= 0 => + /\ coreContext = coreToken + /\ coreRevision \in 1..2 + /\ coreUpdate = ActiveUpdate( + coreToken, + coreRevision, + IF coreRevision = 1 THEN "loading" ELSE "healthy")) + +ExactTokenAcceptanceSafety == + lastConsumed.kind = "active" => + /\ targetContext /= 0 + /\ lastConsumed.token = targetContext + /\ lastConsumed.context = targetContext + +ExactTokenPublicationSafety == + /\ (lastConsumed.kind = "active" /\ lastConsumed.state = "healthy" => + /\ targetContext /= 0 + /\ lastConsumed.token = targetContext + /\ lastConsumed.context = targetContext) + /\ (contentContext /= 0 => + /\ targetContext /= 0 + /\ contentToken = targetContext + /\ contentContext = targetContext) + +AcceptedRevisionsNeverRegress == revisionOrderPreserved + +CorrectAtQuiescence == Quiescent => CorrectlySettled + +EventuallyConverges == finished ~> CorrectlySettled + +OldBufferedOrInFlightNotReached == ~sawOldBufferedOrInFlight +OldUpdateRejectionNotReached == ~sawOldUpdateRejected +EarlyNewUpdateRejectionNotReached == ~sawEarlyNewUpdateRejected +RecoveryApplicationNotReached == ~sawRecoveryApplied +OvertakenRecoveryRejectionNotReached == ~sawOvertakenRecoveryRejected +StaleFrameRejectionNotReached == ~sawStaleFrameRejected +InactiveApplicationNotReached == ~sawInactiveApplied +UpdateOperationNotReached == ~sawUpdateOperation + +==== diff --git a/Throw/Specifications/PollingPublication/README.md b/Throw/Specifications/PollingPublication/README.md new file mode 100644 index 000000000..89b64a0d8 --- /dev/null +++ b/Throw/Specifications/PollingPublication/README.md @@ -0,0 +1,203 @@ +# Polling publication + +This model checks one question: + +> After Throw replaces aircraft polling, can an old or out-of-order update become the new activation's visible state? + +The model represents production commit `60c2540189c899a9efca542c157d6b0686710d06`. +That revision includes the final physical-polling lifecycle and ordered polling publications. + +This result is design evidence for the stated bounds and assumptions. +It is not proof that the Swift implementation is correct. +Relevant source changes invalidate this result until the mapping is checked again. + +The tracked module contains only PlusCal source. +`./tla-check` translates a copy in its retained run directory. +Do not run `pcal.trans` on the tracked file. + +Run this concern from the repository root: + +```sh +./tla-check PollingPublication +``` + +## Source correspondence + +| Model state or action | Production authority | +| --- | --- | +| `targetContext` | [`AirAndSpacePhysicalPollingLease`](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift#L14-L19) identifies one runtime-owned physical polling incarnation. The model gives each replacement or resume a new context. | +| `mintedToken` | `AircraftPollingCoordinator.activate(...)` and `update(...)` mint a typed token from `lifecycleRequestGeneration`. | +| `coreToken` and `coreContext` | [`AircraftPollingCoordinator.activePolling`](../../ThrowCore/Sources/AircraftPollingCoordinator.swift#L157-L163) owns the accepted Core token, configuration, and query for that incarnation. | +| `coreRevision` | `AircraftPollingCoordinator.activePublicationRevision` orders publications within one token. | +| `coreUpdate` | The coordinator's private `update` field is the value returned by `currentUpdate()`. | +| `sourcePhase` and `PublishHealthySnapshot` | The poll task returns from `snapshot(for:)`, checks its generation, and calls `publish(...)`. | +| `streamBuffer` | The coordinator's `AsyncStream` uses `.bufferingNewest(1)`. A new yield replaces the pending value. | +| `streamDelivery` | The observation task has received a value, but `AirAndSpaceRuntime.apply(...)` has not run. | +| `recoveryUpdate` | `currentUpdate()` captured Core state before the activation task resumed on the runtime actor. | +| `acceptance` | [`AirAndSpaceRuntime.PhysicalPollingLifecycle`](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift#L99-L199) is stopped, activating, or active with one exact Core token and revision cursor. | +| `lastConsumed` | The last active update that passed `PhysicalPollingLifecycle.accept(_:)`. | +| `stateGeneration` | `AirAndSpaceRuntime.stateGeneration` invalidates semantic work started from older state. | +| `pendingFrames` and `CompleteFrame` | `makeLayerFrame(...)` is suspended, or its result has returned to the runtime actor. | +| `uiState` and content fields | `health`, `currentSnapshot`, and `currentLayerFrame` supply the published runtime update. | + +The production sources are +[`AircraftPollingCoordinator.swift`](../../ThrowCore/Sources/AircraftPollingCoordinator.swift) +and [`AirAndSpaceRuntime.swift`](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift). + +The model separates each relevant suspension or delivery boundary: + +1. `BeginNextOperation` clears the expected token and presentation before replacement work. +2. `ResetOrQueryBoundary` represents the frame reset suspension for a new source or lease. +3. The same label marks the direct coordinator path for a query-only change that skips the reset. +4. `CoordinatorMintsToken` runs before the queued lifecycle operation replaces the old poller. +5. `CoreBeginReplacement` publishes inactive, cancels the old task, and starts its drain. +6. `CoreDrainOldPoller` resumes after `oldTask.value`, accepts the new token, and publishes loading revision 1. +7. `CoordinatorReturnsToken` and `InstallExpectedToken` are the two sides of the cross-actor call. +8. `CaptureCurrentUpdate` and `ApplyCurrentUpdate` are the two sides of the recovery call. +9. `TakeBufferedUpdate` receives one stream value before `ApplyStreamUpdate` enters the runtime actor. +10. `PublishHealthySnapshot` represents a source result returning while its token is still current. +11. `CompleteFrame` represents the route-cache and frame-builder suspensions returning. +12. The three deactivation labels cover Core cancellation, drain, and the runtime frame reset. + +`targetContext` is a physical polling incarnation, not a `PollingSignature`. +The signature contains request data and does not grant publication authority. + +Suspension preserves the experience lease and clears physical polling state. +A same-experience resume can use an identical signature. +The runtime still mints a new physical lease, and Core mints a fresh token. +The model represents that resume with a new `targetContext`. + +The separate [`ProjectionActivation`](../ProjectionActivation/README.md) model checks demand suspension and experience lease retention. +It also checks that the resumed poller uses a newer physical attempt. + +The `update` operation represents a query-only replacement that skips `flightsRuntime.reset()`. +It also covers the shared Core `performUpdate(...)` to `replace(...)` path. +No production caller invokes `AircraftPollingCoordinator.update(...)` at this source revision. + +## Properties + +- `TypeOK` checks every variable domain. +- `AcceptanceShape` checks the closed inactive, awaiting, and active acceptance forms. +- `CorePublicationShape` binds Core's current update, token, context, state, and revision. +- `ExactTokenAcceptanceSafety` rejects every active update from another context. +- `ExactTokenPublicationSafety` rejects a healthy state or visible frame from another context. +- `AcceptedRevisionsNeverRegress` requires strict revision growth within an accepted token. +- `CorrectAtQuiescence` requires Core, acceptance, health, and visible content to agree after all work drains. +- `EventuallyConverges` requires that agreement after the finite lifecycle plan finishes. + +Every current configuration also checks deadlock freedom. +The explicit quiescent action models a live process after finite work settles. + +## Reachability + +Expected-failure reachability cases prove that TLC visits these required branches: + +- An old update is buffered or in flight when a replacement begins. +- The current design rejects an old-token delivery. +- A new-token update arrives before the runtime installs that token. +- `currentUpdate()` recovers an update that the one-slot stream lost or rejected. +- A newer stream update overtakes an older recovery capture. +- The revision cursor rejects that older recovery capture. +- A new activation invalidates an older frame build. +- Deactivation applies the closed inactive update. +- The query-update path runs. + +These controls prevent a clean result that avoids the important races. + +## Bounds, fairness, and exclusions + +The initial state has active token 1 and loading revision 1. +The stream buffer also holds that update, and token 1 has one pending source result. + +The current configurations exhaust these finite plans: + +- `CurrentSingle.cfg` replaces context 1 with context 2 through an activation. +- `CurrentUpdate.cfg` replaces context 1 with context 2 through a query update. +- `CurrentRepeated.cfg` activates context 2, then updates to context 3. +- `CurrentDeactivation.cfg` activates context 2, then deactivates it. + +Each active token publishes loading revision 1 and at most one healthy revision 2. +The stream has one pending slot and one independent delivery in flight. +Frame completion order is unrestricted. + +Weak fairness makes the finite lifecycle plan progress. +It also schedules a continuously enabled source, observer, recovery, and frame completion. +These assumptions match the progress required for `EventuallyConverges`. + +The source fairness assumption means that the final active source returns one healthy result. +A provider can hang indefinitely in production. +The liveness result does not cover that behavior. +The safety properties do not require source completion. + +The model excludes source failures, retries, quiet state, task cancellation, and process termination. +It also excludes route enrichment, frame-builder failure, and provider polls after the first healthy result. +The separate [`ProjectionActivation`](../ProjectionActivation/README.md) model covers overlapping lifecycle commands, demand generations, physical suspension, and experience lease tombstones. + +The model assumes finite counters do not overflow. +It assumes a Core update caller installs the returned token before it applies recovery state. +The bounded plans do not cover an independent caller that discards that token. + +## Historical controls + +`BrokenLeaseLess.cfg` models the state before commit `6492d2d5`. +That design streamed bare polling state and had no activation token. +TLC finds this trace: + +1. Context 2 begins and clears its visible content while reset is suspended. +2. The source for context 1 publishes its healthy result. +3. The observation task receives that old result. +4. The token-less runtime consumes context 1 state as context 2 state. +5. `ExactTokenPublicationSafety` fails at depth 5. + +`BrokenRevisionLess.cfg` models commit `6492d2d5` before the ordered envelope. +That design checked exact tokens and unequal states, but it had no publication cursor. +TLC finds this trace: + +1. `currentUpdate()` captures token 2 loading state. +2. Token 2 healthy state reaches the stream and starts a frame build. +3. The older loading capture resumes and passes the unequal-state check. +4. Loading increments `stateGeneration` and invalidates the healthy frame. +5. The buffer drains while Core remains healthy and the runtime remains loading. +6. `CorrectAtQuiescence` fails at depth 16. + +Both controls use a property that every current configuration checks. +The manifest requires the named failure, so another TLC error does not count. + +The deterministic Swift guard is +[`AirAndSpaceRuntimeTests.currentUpdateRecoveryCannotRegressANewerStreamPublication`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift). +It parks the recovery capture and the healthy frame build without timing delays. + +[`AirAndSpaceRuntimeTests.suspendedPollingRejectsAnOldPublicationAndResumesTheSameLease`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift) +checks an identical-signature resume. It requires a fresh physical lease and Core token. + +## Result + +**Verified for these model bounds and assumptions.** + +TLC exhausted all current state spaces without an invariant, temporal, or deadlock error. +Both historical controls failed for the mapped reason. +All reachability controls reached their required branch. + +| Configuration | Result | Generated | Distinct | Depth | +| --- | ---: | ---: | ---: | ---: | +| `BrokenLeaseLess.cfg` | expected failure | 40 | 27 | 5 | +| `BrokenRevisionLess.cfg` | expected failure | 1,381 | 805 | 16 | +| `OldInFlightReachability.cfg` | expected failure | 2 | 2 | 2 | +| `OldRejectionReachability.cfg` | expected failure | 17 | 14 | 4 | +| `EarlyUpdateReachability.cfg` | expected failure | 100 | 67 | 8 | +| `RecoveryReachability.cfg` | expected failure | 178 | 116 | 10 | +| `OvertakeReachability.cfg` | expected failure | 539 | 329 | 13 | +| `StaleFrameReachability.cfg` | expected failure | 66 | 44 | 6 | +| `InactiveReachability.cfg` | expected failure | 807 | 486 | 14 | +| `UpdateReachability.cfg` | expected failure | 5 | 5 | 3 | +| `CurrentSingle.cfg` | pass | 3,393 | 1,705 | 23 | +| `CurrentUpdate.cfg` | pass | 3,393 | 1,705 | 23 | +| `CurrentRepeated.cfg` | pass | 79,350 | 30,644 | 37 | +| `CurrentDeactivation.cfg` | pass | 11,639 | 5,588 | 29 | + +The check used tla2tools 1.7.4, TLC2 2.19 at revision `5a47802`, and PlusCal 1.11. +It used Eclipse Temurin Java 21.0.8+9. +The pinned JAR SHA-256 is +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`. + +Changes to token minting, publication revision, buffering, acceptance, recovery, or frame invalidation require a new check. diff --git a/Throw/Specifications/PollingPublication/RecoveryReachability.cfg b/Throw/Specifications/PollingPublication/RecoveryReachability.cfg new file mode 100644 index 000000000..935722cfb --- /dev/null +++ b/Throw/Specifications/PollingPublication/RecoveryReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + RecoveryApplicationNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/StaleFrameReachability.cfg b/Throw/Specifications/PollingPublication/StaleFrameReachability.cfg new file mode 100644 index 000000000..ae08ac671 --- /dev/null +++ b/Throw/Specifications/PollingPublication/StaleFrameReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneActivation + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + StaleFrameRejectionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/UpdateReachability.cfg b/Throw/Specifications/PollingPublication/UpdateReachability.cfg new file mode 100644 index 000000000..595f23116 --- /dev/null +++ b/Throw/Specifications/PollingPublication/UpdateReachability.cfg @@ -0,0 +1,12 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + Operations <- OneUpdate + FinalDeactivate = FALSE + +INVARIANTS + TypeOK + UpdateOperationNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PollingPublication/manifest.json b/Throw/Specifications/PollingPublication/manifest.json new file mode 100644 index 000000000..190ce76e5 --- /dev/null +++ b/Throw/Specifications/PollingPublication/manifest.json @@ -0,0 +1,86 @@ +{ + "source": "pluscal", + "module": "PollingPublication.tla", + "cases": [ + { + "name": "broken-lease-less", + "config": "BrokenLeaseLess.cfg", + "expect": "fail", + "outputContains": "Invariant ExactTokenPublicationSafety is violated." + }, + { + "name": "broken-revision-less", + "config": "BrokenRevisionLess.cfg", + "expect": "fail", + "outputContains": "Invariant CorrectAtQuiescence is violated." + }, + { + "name": "old-in-flight-reachability", + "config": "OldInFlightReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OldBufferedOrInFlightNotReached is violated." + }, + { + "name": "old-rejection-reachability", + "config": "OldRejectionReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OldUpdateRejectionNotReached is violated." + }, + { + "name": "early-update-reachability", + "config": "EarlyUpdateReachability.cfg", + "expect": "fail", + "outputContains": "Invariant EarlyNewUpdateRejectionNotReached is violated." + }, + { + "name": "recovery-reachability", + "config": "RecoveryReachability.cfg", + "expect": "fail", + "outputContains": "Invariant RecoveryApplicationNotReached is violated." + }, + { + "name": "overtake-reachability", + "config": "OvertakeReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OvertakenRecoveryRejectionNotReached is violated." + }, + { + "name": "stale-frame-reachability", + "config": "StaleFrameReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleFrameRejectionNotReached is violated." + }, + { + "name": "inactive-reachability", + "config": "InactiveReachability.cfg", + "expect": "fail", + "outputContains": "Invariant InactiveApplicationNotReached is violated." + }, + { + "name": "update-reachability", + "config": "UpdateReachability.cfg", + "expect": "fail", + "outputContains": "Invariant UpdateOperationNotReached is violated." + }, + { + "name": "current-single", + "config": "CurrentSingle.cfg", + "expect": "pass" + }, + { + "name": "current-update", + "config": "CurrentUpdate.cfg", + "expect": "pass" + }, + { + "name": "current-repeated", + "config": "CurrentRepeated.cfg", + "expect": "pass" + }, + { + "name": "current-deactivation", + "config": "CurrentDeactivation.cfg", + "expect": "pass" + } + ] +} diff --git a/Throw/Specifications/PreferenceTransactions/BrokenInvalidation.cfg b/Throw/Specifications/PreferenceTransactions/BrokenInvalidation.cfg new file mode 100644 index 000000000..fade73a02 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/BrokenInvalidation.cfg @@ -0,0 +1,21 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "brokenInvalidation" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + OldCallbacksPreserveSuccessor + InvalidationCompletionFollowsRequiredWork + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PreferenceTransactions/BrokenObserver.cfg b/Throw/Specifications/PreferenceTransactions/BrokenObserver.cfg new file mode 100644 index 000000000..68be87edd --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/BrokenObserver.cfg @@ -0,0 +1,21 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "brokenObserver" + MutationKinds = {"location"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PreferenceTransactions/BrokenRetry.cfg b/Throw/Specifications/PreferenceTransactions/BrokenRetry.cfg new file mode 100644 index 000000000..c006adf87 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/BrokenRetry.cfg @@ -0,0 +1,21 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "brokenRetry" + MutationKinds = {"source"} + MaxForegroundEdits = 1 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentDelayedRuntimeTeardownReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentDelayedRuntimeTeardownReachability.cfg new file mode 100644 index 000000000..7c4ee6ae9 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentDelayedRuntimeTeardownReachability.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + DelayedRuntimeTeardownAfterSuccessorNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentExpanded.cfg b/Throw/Specifications/PreferenceTransactions/CurrentExpanded.cfg new file mode 100644 index 000000000..00f36795f --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentExpanded.cfg @@ -0,0 +1,25 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source", "location"} + MaxForegroundEdits = 2 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + +PROPERTIES + EventuallyReports + EventuallyQuiescent + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentInvalidationReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentInvalidationReachability.cfg new file mode 100644 index 000000000..e459fef30 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentInvalidationReachability.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + RequiredInvalidationPathNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentOldCallbackReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentOldCallbackReachability.cfg new file mode 100644 index 000000000..d228cacf0 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentOldCallbackReachability.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + OldCallbacksAfterSuccessorSyncNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentRenewalReplacedReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentRenewalReplacedReachability.cfg new file mode 100644 index 000000000..92df261b1 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentRenewalReplacedReachability.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReplacedRenewalNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentRenewalRetiredReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentRenewalRetiredReachability.cfg new file mode 100644 index 000000000..cce9730c9 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentRenewalRetiredReachability.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + RetiredRenewalNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentRenewalSupersededReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentRenewalSupersededReachability.cfg new file mode 100644 index 000000000..9d3f35abd --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentRenewalSupersededReachability.cfg @@ -0,0 +1,17 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + SupersededRenewalNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentRetryReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentRetryReachability.cfg new file mode 100644 index 000000000..59fe62419 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentRetryReachability.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source"} + MaxForegroundEdits = 1 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + RetryRecoveryWasNotQueued + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentSmall.cfg b/Throw/Specifications/PreferenceTransactions/CurrentSmall.cfg new file mode 100644 index 000000000..f21bd07cd --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentSmall.cfg @@ -0,0 +1,25 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"source", "location"} + MaxForegroundEdits = 1 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + +PROPERTIES + EventuallyReports + EventuallyQuiescent + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/PreferenceTransactions/CurrentStaleRenderReachability.cfg b/Throw/Specifications/PreferenceTransactions/CurrentStaleRenderReachability.cfg new file mode 100644 index 000000000..1a9c6f163 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/CurrentStaleRenderReachability.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + MutationKinds = {"location"} + MaxForegroundEdits = 0 + +INVARIANTS + TypeOK + LeaseLifecycleShape + RenewalResultMatchesAuthority + CapturedLeaseRetirementIsExact + InvalidationCompletionFollowsRequiredWork + OldCallbacksPreserveSuccessor + ReportedFailureKeepsDurableSetup + PersistedSourceHasAlignedCredential + VisibleFrameMatchesPublishedObserver + ActiveProjectionMatchesPublishedObserver + PublicationFollowsDurableCommit + StaleRenderWasNotRejected + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/PreferenceTransactions/PreferenceTransactions.tla b/Throw/Specifications/PreferenceTransactions/PreferenceTransactions.tla new file mode 100644 index 000000000..9207f1a95 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/PreferenceTransactions.tla @@ -0,0 +1,920 @@ +---- MODULE PreferenceTransactions ---- +EXTENDS Integers, Sequences, FiniteSets + +CONSTANTS Implementation, MutationKinds, MaxForegroundEdits + +ASSUME /\ Implementation \in { + "current", "brokenRetry", "brokenObserver", "brokenInvalidation" + } + /\ MutationKinds \subseteq {"source", "location"} + /\ MutationKinds # {} + /\ MaxForegroundEdits \in 0..2 + /\ (Implementation = "brokenRetry" => MutationKinds = {"source"}) + /\ (Implementation = "brokenObserver" => MutationKinds = {"location"}) + /\ (Implementation = "brokenInvalidation" => MutationKinds = {"source"}) + +Sources == {0, 1} +Observers == {0, 1} +EditRevisions == 0..MaxForegroundEdits +CredentialIDs == {1} +NoLease == 0 +CapturedLeaseEpoch == 1 +SuccessorLeaseEpoch == 2 +LeaseEpochs == NoLease..SuccessorLeaseEpoch +RenewalResults == {"none", "replaced", "retired", "superseded"} +CallbackKinds == {"activate", "deactivate"} +CallbackPhases == {"idle", "waitingWorker", "waitingRuntime"} + +LifecycleCommand(kind, lease) == [kind |-> kind, lease |-> lease] +LifecycleCommands == [kind: CallbackKinds, lease: LeaseEpochs \ {NoLease}] + +PreferenceValues == [ + source: Sources, + observer: Observers, + edit: EditRevisions +] + +InitialPreferences == [source |-> 0, observer |-> 0, edit |-> 0] + +MutatedPreferences(preferences, kind) == + [source |-> IF kind = "source" THEN 1 ELSE preferences.source, + observer |-> IF kind = "location" THEN 1 ELSE preferences.observer, + edit |-> preferences.edit] + +Phases == { + "selecting", + "waitingInitialWorker", + "validatingSource", + "waitingCredentialRead", + "waitingCredentialSave", + "brokenEarlyPublication", + "buildingCandidate", + "waitingPreferenceResult", + "waitingLeaseRenewal", + "waitingRuntimeDeactivation", + "waitingProjectionWorkerReset", + "waitingDiscardFade", + "waitingDiscardWorkerReset", + "waitingCoordinatorConfigure", + "waitingCoordinatorState", + "waitingCoordinatorLease", + "completingInvalidation", + "restoringCredential", + "finishing", + "done" +} + +AwaitPhases == { + "waitingInitialWorker", + "waitingCredentialRead", + "waitingCredentialSave", + "waitingPreferenceResult", + "waitingLeaseRenewal", + "waitingRuntimeDeactivation", + "waitingProjectionWorkerReset", + "waitingDiscardFade", + "waitingDiscardWorkerReset", + "waitingCoordinatorConfigure", + "waitingCoordinatorState", + "waitingCoordinatorLease", + "restoringCredential" +} + +Outcomes == {"none", "success", "failure"} +RequestKinds == {"none", "mutation", "foreground"} +WorkerPhases == {"initialBusy", "idle", "saving"} +PublicationStates == {"notPublished", "publishedBeforeSave", "published"} +ActivationStates == {"none", "preparing", "active"} +RenderStates == {"idle", "rendering"} + +(* --algorithm PreferenceTransactionsAlgorithm { +variables mutationKind = "none", + mutationPhase = "selecting", + livePreferences = InitialPreferences, + durablePreferences = InitialPreferences, + credentials = {}, + credentialMutationAttempted = FALSE, + candidateBase = InitialPreferences, + candidatePreferences = InitialPreferences, + committedCandidate = InitialPreferences, + commitKnown = FALSE, + pendingOutcome = "none", + reportedOutcome = "none", + foregroundEditCount = 0, + foregroundEditQueued = FALSE, + deferredSaveNeeded = FALSE, + queuedForegroundSnapshot = InitialPreferences, + requestKind = "none", + requestPreferences = InitialPreferences, + requestResult = "none", + workerPhase = "initialBusy", + workerKind = "none", + workerPreferences = InitialPreferences, + saveAttempts = 0, + publicationState = "notPublished", + invalidationActive = FALSE, + invalidationCompleted = FALSE, + cleanupComplete = FALSE, + coordinatorConfigured = FALSE, + coordinatorStateRead = FALSE, + leaseSynchronized = FALSE, + contextGeneration = 0, + observerGeneration = 0, + publishedObserverGeneration = 0, + capturedLease = NoLease, + renewalResult = "none", + coordinatorLease = CapturedLeaseEpoch, + sessionLease = CapturedLeaseEpoch, + latestSessionLease = CapturedLeaseEpoch, + runtimeLease = CapturedLeaseEpoch, + latestRuntimeLease = CapturedLeaseEpoch, + directRetirementLease = NoLease, + actionQueue = <<>>, + callbackPhase = "idle", + callbackLease = NoLease, + activationState = "active", + activationLease = CapturedLeaseEpoch, + activationContextGeneration = 0, + activationObserverGeneration = 0, + activationObserver = 0, + preparedActivationLease = CapturedLeaseEpoch, + preparedActivationContextGeneration = 0, + preparedActivationObserverGeneration = 0, + preparedActivationObserver = 0, + renderState = "idle", + renderContextGeneration = 0, + renderObserverGeneration = 0, + renderObserver = 0, + visibleFramePresent = TRUE, + visibleFrameObserverGeneration = 0, + visibleFrameObserver = 0, + sawDrift = FALSE, + sawCommittedRetryFailure = FALSE, + sawRetryRecoveryQueued = FALSE, + sawObserverEarlyPublication = FALSE, + sawStaleRenderRejected = FALSE, + sawStaleActivationRejected = FALSE, + sawForegroundSaveComplete = FALSE, + sawCapturedLeaseRetirement = FALSE, + sawOldActivationAfterSuccessorSync = FALSE, + sawOldDeactivationAfterSuccessorSync = FALSE, + sawDelayedRuntimeTeardownAfterSuccessor = FALSE, + sawSuccessorRuntimeActivation = FALSE; + +define { + NextContextGeneration == contextGeneration + 1 + + NextObserverGeneration == + observerGeneration + IF mutationKind = "location" THEN 1 ELSE 0 + + UsesCommittedRecovery == + Implementation \in {"current", "brokenInvalidation"} + + RuntimeAccepts(lease) == + IF runtimeLease = NoLease + THEN lease > latestRuntimeLease + ELSE lease >= runtimeLease + + Quiescent == + /\ mutationPhase = "done" + /\ requestKind = "none" + /\ requestResult = "none" + /\ workerPhase = "idle" + /\ workerKind = "none" +} + +fair process (Mutation = "Mutation") { +MutationStep: + while (TRUE) { + if (mutationPhase = "selecting") { + with (kind \in MutationKinds) { + mutationKind := kind || + mutationPhase := + IF kind = "source" + THEN "waitingInitialWorker" + ELSE IF Implementation = "brokenObserver" + THEN "brokenEarlyPublication" + ELSE "buildingCandidate"; + }; + } else if (mutationPhase = "waitingInitialWorker") { + await workerPhase # "initialBusy"; + mutationPhase := "validatingSource"; + } else if (mutationPhase = "validatingSource") { + if (Implementation = "brokenRetry") { + mutationPhase := "waitingCredentialRead"; + } else { + either { + mutationPhase := "waitingCredentialRead"; + } or { + pendingOutcome := "failure" || + mutationPhase := "finishing"; + }; + }; + } else if (mutationPhase = "waitingCredentialRead") { + if (Implementation = "brokenRetry") { + credentialMutationAttempted := TRUE || + mutationPhase := "waitingCredentialSave"; + } else { + either { + credentialMutationAttempted := TRUE || + mutationPhase := "waitingCredentialSave"; + } or { + pendingOutcome := "failure" || + mutationPhase := "finishing"; + }; + }; + } else if (mutationPhase = "waitingCredentialSave") { + if (Implementation = "brokenRetry") { + credentials := credentials \cup {1} || + mutationPhase := "buildingCandidate"; + } else { + either { + credentials := credentials \cup {1} || + mutationPhase := "buildingCandidate"; + } or { + pendingOutcome := "failure" || + mutationPhase := "restoringCredential"; + }; + }; + } else if (mutationPhase = "brokenEarlyPublication") { + livePreferences := MutatedPreferences(livePreferences, mutationKind) || + contextGeneration := NextContextGeneration || + observerGeneration := NextObserverGeneration || + publishedObserverGeneration := NextObserverGeneration || + publicationState := "publishedBeforeSave" || + candidateBase := MutatedPreferences(livePreferences, mutationKind) || + candidatePreferences := MutatedPreferences(livePreferences, mutationKind) || + requestKind := "mutation" || + requestPreferences := MutatedPreferences(livePreferences, mutationKind) || + mutationPhase := "waitingPreferenceResult" || + sawObserverEarlyPublication := TRUE; + } else if (mutationPhase = "buildingCandidate") { + if (Implementation = "brokenRetry") { + candidateBase := livePreferences || + candidatePreferences := MutatedPreferences(livePreferences, mutationKind) || + requestKind := "mutation" || + requestPreferences := MutatedPreferences(livePreferences, mutationKind) || + mutationPhase := "waitingPreferenceResult"; + } else { + either { + candidateBase := livePreferences || + candidatePreferences := MutatedPreferences(livePreferences, mutationKind) || + requestKind := "mutation" || + requestPreferences := MutatedPreferences(livePreferences, mutationKind) || + mutationPhase := "waitingPreferenceResult"; + } or { + if (commitKnown /\ UsesCommittedRecovery) { + livePreferences := committedCandidate || + contextGeneration := NextContextGeneration || + observerGeneration := NextObserverGeneration || + publishedObserverGeneration := NextObserverGeneration || + capturedLease := sessionLease || + sessionLease := NoLease || + latestSessionLease := sessionLease || + activationState := "none" || + activationLease := NoLease || + activationContextGeneration := -1 || + activationObserverGeneration := -1 || + activationObserver := 0 || + preparedActivationLease := NoLease || + visibleFramePresent := + IF mutationKind = "location" THEN FALSE ELSE visibleFramePresent || + publicationState := "published" || + invalidationActive := TRUE || + invalidationCompleted := FALSE || + cleanupComplete := FALSE || + coordinatorConfigured := FALSE || + coordinatorStateRead := FALSE || + leaseSynchronized := FALSE || + renewalResult := "none" || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", sessionLease) + ) || + deferredSaveNeeded := TRUE || + pendingOutcome := "success" || + mutationPhase := "waitingLeaseRenewal"; + } else { + pendingOutcome := "failure" || + mutationPhase := + IF mutationKind = "source" /\ credentialMutationAttempted + THEN "restoringCredential" + ELSE "finishing"; + }; + }; + }; + } else if (mutationPhase = "waitingPreferenceResult") { + await requestResult # "none"; + if (requestResult = "success") { + if (Implementation = "brokenObserver") { + requestResult := "none" || + commitKnown := TRUE || + committedCandidate := candidatePreferences || + pendingOutcome := "success" || + mutationPhase := "finishing"; + } else if (candidateBase # livePreferences) { + requestResult := "none" || + commitKnown := TRUE || + committedCandidate := candidatePreferences || + sawDrift := TRUE || + mutationPhase := "buildingCandidate"; + } else { + requestResult := "none" || + commitKnown := TRUE || + committedCandidate := candidatePreferences || + livePreferences := candidatePreferences || + contextGeneration := NextContextGeneration || + observerGeneration := NextObserverGeneration || + publishedObserverGeneration := NextObserverGeneration || + capturedLease := sessionLease || + sessionLease := NoLease || + latestSessionLease := sessionLease || + activationState := "none" || + activationLease := NoLease || + activationContextGeneration := -1 || + activationObserverGeneration := -1 || + activationObserver := 0 || + preparedActivationLease := NoLease || + visibleFramePresent := + IF mutationKind = "location" THEN FALSE ELSE visibleFramePresent || + publicationState := "published" || + invalidationActive := TRUE || + invalidationCompleted := FALSE || + cleanupComplete := FALSE || + coordinatorConfigured := FALSE || + coordinatorStateRead := FALSE || + leaseSynchronized := FALSE || + renewalResult := "none" || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", sessionLease) + ) || + foregroundEditQueued := FALSE || + deferredSaveNeeded := FALSE || + pendingOutcome := "success" || + mutationPhase := "waitingLeaseRenewal"; + }; + } else if (~commitKnown) { + requestResult := "none" || + pendingOutcome := "failure" || + mutationPhase := + IF mutationKind = "source" /\ credentialMutationAttempted + THEN "restoringCredential" + ELSE "finishing"; + } else if (UsesCommittedRecovery) { + either { + requestResult := "none" || + livePreferences := MutatedPreferences(livePreferences, mutationKind) || + contextGeneration := NextContextGeneration || + observerGeneration := NextObserverGeneration || + publishedObserverGeneration := NextObserverGeneration || + capturedLease := sessionLease || + sessionLease := NoLease || + latestSessionLease := sessionLease || + activationState := "none" || + activationLease := NoLease || + activationContextGeneration := -1 || + activationObserverGeneration := -1 || + activationObserver := 0 || + preparedActivationLease := NoLease || + visibleFramePresent := + IF mutationKind = "location" THEN FALSE ELSE visibleFramePresent || + publicationState := "published" || + invalidationActive := TRUE || + invalidationCompleted := FALSE || + cleanupComplete := FALSE || + coordinatorConfigured := FALSE || + coordinatorStateRead := FALSE || + leaseSynchronized := FALSE || + renewalResult := "none" || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", sessionLease) + ) || + deferredSaveNeeded := TRUE || + pendingOutcome := "success" || + mutationPhase := "waitingLeaseRenewal" || + sawCommittedRetryFailure := TRUE; + } or { + requestResult := "none" || + livePreferences := committedCandidate || + contextGeneration := NextContextGeneration || + observerGeneration := NextObserverGeneration || + publishedObserverGeneration := NextObserverGeneration || + capturedLease := sessionLease || + sessionLease := NoLease || + latestSessionLease := sessionLease || + activationState := "none" || + activationLease := NoLease || + activationContextGeneration := -1 || + activationObserverGeneration := -1 || + activationObserver := 0 || + preparedActivationLease := NoLease || + visibleFramePresent := + IF mutationKind = "location" THEN FALSE ELSE visibleFramePresent || + publicationState := "published" || + invalidationActive := TRUE || + invalidationCompleted := FALSE || + cleanupComplete := FALSE || + coordinatorConfigured := FALSE || + coordinatorStateRead := FALSE || + leaseSynchronized := FALSE || + renewalResult := "none" || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", sessionLease) + ) || + deferredSaveNeeded := TRUE || + pendingOutcome := "success" || + mutationPhase := "waitingLeaseRenewal" || + sawCommittedRetryFailure := TRUE; + }; + } else { + requestResult := "none" || + pendingOutcome := "failure" || + mutationPhase := + IF mutationKind = "source" /\ credentialMutationAttempted + THEN "restoringCredential" + ELSE "finishing" || + sawCommittedRetryFailure := TRUE; + }; + } else if (mutationPhase = "waitingLeaseRenewal") { + either { + renewalResult := "replaced" || + coordinatorLease := SuccessorLeaseEpoch || + actionQueue := Append( + Append( + actionQueue, + LifecycleCommand("deactivate", capturedLease) + ), + LifecycleCommand("activate", SuccessorLeaseEpoch) + ); + } or { + renewalResult := "retired" || + coordinatorLease := NoLease || + actionQueue := Append( + actionQueue, + LifecycleCommand("deactivate", capturedLease) + ); + } or { + renewalResult := "superseded" || + coordinatorLease := SuccessorLeaseEpoch || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", SuccessorLeaseEpoch) + ); + }; + mutationPhase := "waitingRuntimeDeactivation"; + } else if (mutationPhase = "waitingRuntimeDeactivation") { + if (capturedLease >= latestRuntimeLease) { + latestRuntimeLease := capturedLease || + runtimeLease := + IF runtimeLease /= NoLease /\ runtimeLease <= capturedLease + THEN NoLease + ELSE runtimeLease; + }; + directRetirementLease := capturedLease || + sawCapturedLeaseRetirement := TRUE || + mutationPhase := + IF mutationKind = "location" + THEN "waitingProjectionWorkerReset" + ELSE "waitingDiscardFade"; + } else if (mutationPhase = "waitingProjectionWorkerReset") { + cleanupComplete := TRUE || + mutationPhase := "waitingCoordinatorConfigure"; + } else if (mutationPhase = "waitingDiscardFade") { + visibleFramePresent := FALSE || + mutationPhase := "waitingDiscardWorkerReset"; + } else if (mutationPhase = "waitingDiscardWorkerReset") { + cleanupComplete := TRUE || + mutationPhase := "waitingCoordinatorConfigure"; + } else if (mutationPhase = "waitingCoordinatorConfigure") { + coordinatorConfigured := TRUE || + mutationPhase := "waitingCoordinatorState"; + } else if (mutationPhase = "waitingCoordinatorState") { + coordinatorStateRead := TRUE || + mutationPhase := + IF Implementation = "brokenInvalidation" + THEN "completingInvalidation" + ELSE "waitingCoordinatorLease"; + } else if (mutationPhase = "waitingCoordinatorLease") { + sessionLease := coordinatorLease || + latestSessionLease := + IF coordinatorLease > latestSessionLease + THEN coordinatorLease + ELSE latestSessionLease || + leaseSynchronized := TRUE || + mutationPhase := "completingInvalidation"; + } else if (mutationPhase = "completingInvalidation") { + invalidationActive := FALSE || + invalidationCompleted := TRUE || + mutationPhase := "finishing"; + } else if (mutationPhase = "restoringCredential") { + either { + credentials := credentials \ {1} || + mutationPhase := "finishing"; + } or { + mutationPhase := "finishing"; + }; + } else if (mutationPhase = "finishing") { + if (deferredSaveNeeded) { + reportedOutcome := pendingOutcome || + queuedForegroundSnapshot := livePreferences || + requestKind := "foreground" || + requestPreferences := livePreferences || + foregroundEditQueued := FALSE || + deferredSaveNeeded := FALSE || + sawRetryRecoveryQueued := sawRetryRecoveryQueued \/ + (pendingOutcome = "success" /\ sawCommittedRetryFailure) || + mutationPhase := "done"; + } else { + reportedOutcome := pendingOutcome || + mutationPhase := "done"; + }; + } else { + await mutationPhase = "done"; + skip; + }; + } +} + +fair process (PreferenceWorker = "PreferenceWorker") { +PreferenceWorkerStep: + while (TRUE) { + await workerPhase = "initialBusy" \/ + (workerPhase = "idle" /\ requestKind # "none") \/ + workerPhase = "saving"; + if (workerPhase = "initialBusy") { + workerPhase := "idle"; + } else if (workerPhase = "idle") { + workerPhase := "saving" || + workerKind := requestKind || + workerPreferences := requestPreferences || + requestKind := "none"; + } else if (workerKind = "mutation") { + if (Implementation = "brokenRetry" /\ saveAttempts = 0) { + durablePreferences := workerPreferences || + requestResult := "success" || + workerPhase := "idle" || + workerKind := "none" || + saveAttempts := saveAttempts + 1; + } else if (Implementation = "brokenRetry") { + requestResult := "failure" || + workerPhase := "idle" || + workerKind := "none" || + saveAttempts := saveAttempts + 1; + } else { + either { + durablePreferences := workerPreferences || + requestResult := "success" || + workerPhase := "idle" || + workerKind := "none" || + saveAttempts := saveAttempts + 1; + } or { + requestResult := "failure" || + workerPhase := "idle" || + workerKind := "none" || + saveAttempts := saveAttempts + 1; + }; + }; + } else { + either { + durablePreferences := workerPreferences || + workerPhase := "idle" || + workerKind := "none" || + sawForegroundSaveComplete := TRUE; + } or { + workerPhase := "idle" || + workerKind := "none" || + sawForegroundSaveComplete := TRUE; + }; + }; + } +} + +fair process (ForegroundEditor = "ForegroundEditor") { +ForegroundEditStep: + while (TRUE) { + await foregroundEditCount < MaxForegroundEdits /\ mutationPhase \in AwaitPhases; + with (nextEdit = foregroundEditCount + 1) { + livePreferences := [ + source |-> livePreferences.source, + observer |-> livePreferences.observer, + edit |-> nextEdit + ] || + foregroundEditCount := nextEdit || + foregroundEditQueued := TRUE || + deferredSaveNeeded := TRUE; + }; + } +} + +fair process (ActionDispatcher = "ActionDispatcher") { +DispatchCoordinatorAction: + while (TRUE) { + await callbackPhase = "idle" /\ Len(actionQueue) > 0; + with (command = Head(actionQueue)) { + actionQueue := Tail(actionQueue); + if (command.kind = "activate") { + if (command.lease = capturedLease /\ + leaseSynchronized /\ + coordinatorLease = SuccessorLeaseEpoch) { + sawOldActivationAfterSuccessorSync := TRUE; + }; + if (~invalidationActive) { + if (sessionLease /= NoLease /\ + command.lease >= latestSessionLease) { + sessionLease := command.lease || + latestSessionLease := command.lease; + } else if (sessionLease = NoLease /\ + (latestSessionLease = NoLease \/ + command.lease > latestSessionLease)) { + sessionLease := command.lease || + latestSessionLease := command.lease; + }; + }; + } else { + if (command.lease = capturedLease /\ + leaseSynchronized /\ + coordinatorLease = SuccessorLeaseEpoch) { + sawOldDeactivationAfterSuccessorSync := TRUE; + }; + if (command.lease >= latestSessionLease /\ + (sessionLease = NoLease \/ sessionLease <= command.lease)) { + sessionLease := NoLease || + latestSessionLease := command.lease || + callbackLease := command.lease || + callbackPhase := "waitingWorker"; + }; + }; + }; + } +} + +fair process (CallbackCompletion = "CallbackCompletion") { +CompleteCoordinatorWorkerReset: + while (TRUE) { + await callbackPhase = "waitingWorker"; + callbackPhase := "waitingRuntime"; + +CompleteCoordinatorRuntimeDeactivation: + await callbackPhase = "waitingRuntime"; + if (runtimeLease > callbackLease) { + sawDelayedRuntimeTeardownAfterSuccessor := TRUE; + }; + if (callbackLease >= latestRuntimeLease) { + latestRuntimeLease := callbackLease || + runtimeLease := + IF runtimeLease /= NoLease /\ runtimeLease <= callbackLease + THEN NoLease + ELSE runtimeLease; + }; + callbackLease := NoLease || + callbackPhase := "idle"; + } +} + +fair process (Activation = "Activation") { +ActivationStep: + while (TRUE) { + await (activationState = "none" /\ + mutationPhase = "done" /\ + reportedOutcome = "success" /\ + sessionLease /= NoLease /\ + ~invalidationActive) \/ + activationState = "preparing"; + if (activationState = "none") { + preparedActivationLease := sessionLease || + preparedActivationContextGeneration := contextGeneration || + preparedActivationObserverGeneration := publishedObserverGeneration || + preparedActivationObserver := livePreferences.observer || + activationState := "preparing"; + } else if (preparedActivationLease = sessionLease /\ + preparedActivationLease = coordinatorLease /\ + RuntimeAccepts(preparedActivationLease) /\ + preparedActivationContextGeneration = contextGeneration /\ + preparedActivationObserverGeneration = publishedObserverGeneration /\ + preparedActivationObserver = livePreferences.observer /\ + ~invalidationActive) { + runtimeLease := preparedActivationLease || + latestRuntimeLease := + IF preparedActivationLease > latestRuntimeLease + THEN preparedActivationLease + ELSE latestRuntimeLease || + activationLease := preparedActivationLease || + activationContextGeneration := preparedActivationContextGeneration || + activationObserverGeneration := preparedActivationObserverGeneration || + activationObserver := preparedActivationObserver || + activationState := "active" || + sawSuccessorRuntimeActivation := + sawSuccessorRuntimeActivation \/ + (preparedActivationLease = SuccessorLeaseEpoch); + } else { + activationState := "none" || + activationLease := NoLease || + preparedActivationLease := NoLease || + sawStaleActivationRejected := TRUE; + }; + } +} + +fair process (Renderer = "Renderer") { +RendererStep: + while (TRUE) { + await (renderState = "idle" /\ activationState = "active" /\ + ~invalidationActive) \/ renderState = "rendering"; + if (renderState = "idle") { + renderContextGeneration := activationContextGeneration || + renderObserverGeneration := activationObserverGeneration || + renderObserver := activationObserver || + renderState := "rendering"; + } else if (renderContextGeneration = contextGeneration /\ + renderContextGeneration = activationContextGeneration /\ + renderObserverGeneration = publishedObserverGeneration /\ + renderObserverGeneration = activationObserverGeneration /\ + renderObserver = livePreferences.observer /\ + renderObserver = activationObserver /\ + activationState = "active" /\ + ~invalidationActive) { + visibleFramePresent := TRUE || + visibleFrameObserverGeneration := renderObserverGeneration || + visibleFrameObserver := renderObserver || + renderState := "idle"; + } else { + renderState := "idle" || + sawStaleRenderRejected := TRUE; + }; + } +} + +process (Done = "Done") { +DoneStep: + while (TRUE) { + await Quiescent; + skip; + } +} +} *) + +TypeOK == + /\ mutationKind \in {"none", "source", "location"} + /\ mutationPhase \in Phases + /\ livePreferences \in PreferenceValues + /\ durablePreferences \in PreferenceValues + /\ credentials \in SUBSET CredentialIDs + /\ credentialMutationAttempted \in BOOLEAN + /\ candidateBase \in PreferenceValues + /\ candidatePreferences \in PreferenceValues + /\ committedCandidate \in PreferenceValues + /\ commitKnown \in BOOLEAN + /\ pendingOutcome \in Outcomes + /\ reportedOutcome \in Outcomes + /\ foregroundEditCount \in EditRevisions + /\ foregroundEditQueued \in BOOLEAN + /\ deferredSaveNeeded \in BOOLEAN + /\ queuedForegroundSnapshot \in PreferenceValues + /\ requestKind \in RequestKinds + /\ requestPreferences \in PreferenceValues + /\ requestResult \in Outcomes + /\ workerPhase \in WorkerPhases + /\ workerKind \in RequestKinds + /\ workerPreferences \in PreferenceValues + /\ saveAttempts \in 0..(MaxForegroundEdits + 1) + /\ publicationState \in PublicationStates + /\ invalidationActive \in BOOLEAN + /\ invalidationCompleted \in BOOLEAN + /\ cleanupComplete \in BOOLEAN + /\ coordinatorConfigured \in BOOLEAN + /\ coordinatorStateRead \in BOOLEAN + /\ leaseSynchronized \in BOOLEAN + /\ contextGeneration \in 0..1 + /\ observerGeneration \in 0..1 + /\ publishedObserverGeneration \in 0..1 + /\ capturedLease \in LeaseEpochs + /\ renewalResult \in RenewalResults + /\ coordinatorLease \in LeaseEpochs + /\ sessionLease \in LeaseEpochs + /\ latestSessionLease \in LeaseEpochs + /\ runtimeLease \in LeaseEpochs + /\ latestRuntimeLease \in LeaseEpochs + /\ directRetirementLease \in LeaseEpochs + /\ actionQueue \in Seq(LifecycleCommands) + /\ callbackPhase \in CallbackPhases + /\ callbackLease \in LeaseEpochs + /\ activationState \in ActivationStates + /\ activationLease \in LeaseEpochs + /\ activationContextGeneration \in -1..1 + /\ activationObserverGeneration \in -1..1 + /\ activationObserver \in Observers + /\ preparedActivationLease \in LeaseEpochs + /\ preparedActivationContextGeneration \in 0..1 + /\ preparedActivationObserverGeneration \in 0..1 + /\ preparedActivationObserver \in Observers + /\ renderState \in RenderStates + /\ renderContextGeneration \in 0..1 + /\ renderObserverGeneration \in 0..1 + /\ renderObserver \in Observers + /\ visibleFramePresent \in BOOLEAN + /\ visibleFrameObserverGeneration \in 0..1 + /\ visibleFrameObserver \in Observers + /\ sawDrift \in BOOLEAN + /\ sawCommittedRetryFailure \in BOOLEAN + /\ sawRetryRecoveryQueued \in BOOLEAN + /\ sawObserverEarlyPublication \in BOOLEAN + /\ sawStaleRenderRejected \in BOOLEAN + /\ sawStaleActivationRejected \in BOOLEAN + /\ sawForegroundSaveComplete \in BOOLEAN + /\ sawCapturedLeaseRetirement \in BOOLEAN + /\ sawOldActivationAfterSuccessorSync \in BOOLEAN + /\ sawOldDeactivationAfterSuccessorSync \in BOOLEAN + /\ sawDelayedRuntimeTeardownAfterSuccessor \in BOOLEAN + /\ sawSuccessorRuntimeActivation \in BOOLEAN + +LeaseLifecycleShape == + /\ (sessionLease = NoLease \/ sessionLease = latestSessionLease) + /\ (runtimeLease = NoLease \/ runtimeLease = latestRuntimeLease) + /\ (activationState = "active" => activationLease /= NoLease) + /\ (activationState /= "active" => activationLease = NoLease) + /\ (callbackPhase = "idle" => callbackLease = NoLease) + /\ (callbackPhase /= "idle" => callbackLease /= NoLease) + +RenewalResultMatchesAuthority == + /\ (renewalResult = "replaced" => + /\ capturedLease = CapturedLeaseEpoch + /\ coordinatorLease = SuccessorLeaseEpoch) + /\ (renewalResult = "retired" => + /\ capturedLease = CapturedLeaseEpoch + /\ coordinatorLease = NoLease) + /\ (renewalResult = "superseded" => + /\ capturedLease = CapturedLeaseEpoch + /\ coordinatorLease = SuccessorLeaseEpoch) + +CapturedLeaseRetirementIsExact == + sawCapturedLeaseRetirement => + /\ capturedLease = CapturedLeaseEpoch + /\ directRetirementLease = capturedLease + /\ (runtimeLease = NoLease \/ runtimeLease > capturedLease) + +InvalidationCompletionFollowsRequiredWork == + invalidationCompleted => + /\ ~invalidationActive + /\ renewalResult /= "none" + /\ sawCapturedLeaseRetirement + /\ cleanupComplete + /\ coordinatorConfigured + /\ coordinatorStateRead + /\ leaseSynchronized + +OldCallbacksPreserveSuccessor == + /\ (leaseSynchronized /\ coordinatorLease = SuccessorLeaseEpoch => + /\ sessionLease = SuccessorLeaseEpoch + /\ latestSessionLease = SuccessorLeaseEpoch) + /\ (sawSuccessorRuntimeActivation => + /\ runtimeLease = SuccessorLeaseEpoch + /\ latestRuntimeLease = SuccessorLeaseEpoch) + +ReportedFailureKeepsDurableSetup == + reportedOutcome = "failure" => + /\ durablePreferences.source = livePreferences.source + /\ durablePreferences.observer = livePreferences.observer + +PersistedSourceHasAlignedCredential == + durablePreferences.source = 0 \/ durablePreferences.source \in credentials + +VisibleFrameMatchesPublishedObserver == + visibleFramePresent => + /\ visibleFrameObserverGeneration = publishedObserverGeneration + /\ visibleFrameObserver = livePreferences.observer + +ActiveProjectionMatchesPublishedObserver == + activationState = "active" => + /\ activationLease = sessionLease + /\ activationLease = coordinatorLease + /\ activationLease = runtimeLease + /\ activationContextGeneration = contextGeneration + /\ activationObserverGeneration = publishedObserverGeneration + /\ activationObserver = livePreferences.observer + +PublicationFollowsDurableCommit == + publicationState = "notPublished" \/ commitKnown + +EventuallyReports == + mutationKind # "none" ~> reportedOutcome # "none" + +EventuallyQuiescent == + mutationKind # "none" ~> Quiescent + +RetryRecoveryWasNotQueued == ~sawRetryRecoveryQueued +ObserverEarlyPublicationWasNotReached == ~sawObserverEarlyPublication +StaleRenderWasNotRejected == ~sawStaleRenderRejected +RequiredInvalidationPathNotReached == + ~(invalidationCompleted /\ sawCapturedLeaseRetirement /\ leaseSynchronized) +ReplacedRenewalNotReached == renewalResult /= "replaced" +RetiredRenewalNotReached == renewalResult /= "retired" +SupersededRenewalNotReached == renewalResult /= "superseded" +OldCallbacksAfterSuccessorSyncNotReached == + ~(sawOldActivationAfterSuccessorSync /\ sawOldDeactivationAfterSuccessorSync) +DelayedRuntimeTeardownAfterSuccessorNotReached == + ~sawDelayedRuntimeTeardownAfterSuccessor + +==== diff --git a/Throw/Specifications/PreferenceTransactions/README.md b/Throw/Specifications/PreferenceTransactions/README.md new file mode 100644 index 000000000..38dd336c2 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/README.md @@ -0,0 +1,254 @@ +# Preference transactions + +This model checks one question: + +> Can a source or observer mutation preserve durable state, renew its exact lease, and reject delayed work from the replaced context? + +The model represents the protocol at production commit +`eec278ee956f631f442db46b49e5c534499a3e66`. A relevant source change +invalidates this result until the mapping is checked again. + +The tracked source contains only PlusCal. `./tla-check` translates it in the +retained run directory. Do not run `pcal.trans` directly. + +Run the model from the repository root: + +```sh +./tla-check PreferenceTransactions +``` + +## Source correspondence + +| Model state or action | Production authority | +| --- | --- | +| `livePreferences` | [`ThrowSession.preferenceSnapshot`](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift) is the complete live setup. | +| `durablePreferences` | `ThrowPreferenceStore.save(_:)` owns the last successful stored setup. | +| `credentials` | [`AircraftCredentialStore`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) owns stored credential identities. | +| `candidateBase` | `persistReconciledPreferenceMutation` captures `preferenceSnapshot` at line 221. | +| `candidatePreferences` | `PersistableThrowPreferenceMutation.preferences` is the validated storage value. | +| `commitKnown` and `committedCandidate` | `ThrowPreferenceMutationCommitState.committed` records a successful durable write. | +| `foregroundEditQueued` | `schedulePreferencesSave` defers a typed edit while the mutation producer owns persistence. | +| `queuedForegroundSnapshot` | `finishPreferenceMutation` queues the complete live snapshot after the producer finishes. | +| `requestKind` and `workerPhase` | `ThrowPreferencePersistenceState` and `drainPreferenceSaveQueue()` serialize preference writes. | +| Credential read, save, and restore phases | [`useSource(_:)` lines 166-258](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) contain the three credential-store suspension boundaries. | +| `publicationState` | [`persistReconciledPreferenceMutation` lines 278-285](../../ThrowUI/Sources/Model/ThrowSession+Preferences.swift) publishes only after a successful write. | +| `invalidationActive` and `contextGeneration` | [`prepareProjectionPreferencePublication` lines 484-510](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) opens the gate and replaces the context generation. | +| `capturedLease` | The invalidation captures `airAndSpaceActivation.activeLease` before it tombstones the session tracker. | +| `renewalResult` and `coordinatorLease` | [`renewActivationLease` lines 415-452](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift) returns `replaced`, `retired`, or `superseded`. | +| `sessionLease` and `latestSessionLease` | [`ProjectionActivationLeaseTracker` lines 71-131](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift) stores active ownership or an inactive generation tombstone. | +| `runtimeLease` and `latestRuntimeLease` | [`AirAndSpaceRuntime.ActivationLifecycle` lines 202-243](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift) stores runtime ownership or a generation tombstone. | +| `directRetirementLease` | [`finishProjectionPreferenceInvalidation` lines 513-547](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) passes the captured lease to direct runtime deactivation. | +| `actionQueue` | The coordinator action stream and [`applyExperienceCoordinatorAction(_:)` lines 152-197](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift) form one FIFO callback lane. | +| `callbackPhase` and `callbackLease` | A deactivation can suspend during `projectionWorker.experienceBecameInactive` and `airAndSpaceRuntime.deactivate`. | +| Coordinator configuration, state, and lease phases | [`configureExperienceCoordinator` lines 232-240](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift) performs these operations in that order. | +| Source cleanup phases | [`discardOldFrame` lines 1065-1097](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) fades the old frame before the worker reset. | +| Observer cleanup phase | `finishProjectionPreferenceInvalidation` resets the projection worker for an observer mutation. | +| Activation fields | The active or prepared projection context carries its exact coordinator, session, and runtime lease. | +| Render fields | The renderer captures its context and observer before the worker suspension. | +| Visible-frame fields | A visible projection carries the observer and context generation that produced it. | + +The source mutation first waits for the existing preference worker. It then +reads and saves a credential before it queues the candidate preferences. +A failed uncommitted source write restores the previous credential. + +The preference worker separates dequeue from store completion. Each store +completion can succeed or fail. A successful completion changes durable state +before the mutation learns the result. + +A foreground edit can run while the main-actor mutation is suspended. The +mutation retries against the latest complete snapshot after it detects drift. +If an earlier attempt committed, later failure publishes a committed candidate +and queues reconciliation. + +Publication is one atomic main-actor step. It prepares invalidation, publishes +the complete snapshot, publishes the mutation payload, and records commit +resolution. + +The source path then performs these operations: + +1. Renew the captured coordinator lease. +2. Retire the exact captured runtime lease. +3. Fade and discard the old frame. +4. Reset the projection worker. +5. Configure the coordinator. +6. Read and apply coordinator state. +7. Read and synchronize the authoritative lease. +8. Complete the invalidation gate. + +The observer path clears its visible frame during publication. It then performs +lease renewal, runtime retirement, and projection-worker reset. The final +configuration, state, lease, and gate order matches the source path. + +The direct coordinator lease read can overtake queued action callbacks. The +model therefore permits old callbacks before or after direct successor +synchronization. It also permits a callback to suspend across that +synchronization. + +The gate rejects delayed activation during invalidation. The session and +runtime generation tombstones reject obsolete work after invalidation. A +delayed old runtime teardown cannot retire successor lease 2. + +## Properties + +- `TypeOK` checks every variable domain. +- `LeaseLifecycleShape` checks active leases and inactive tombstones. +- `RenewalResultMatchesAuthority` checks each renewal result against the + coordinator lease. +- `CapturedLeaseRetirementIsExact` requires direct runtime retirement to target + captured lease 1 only. +- `InvalidationCompletionFollowsRequiredWork` requires renewal, retirement, + cleanup, configuration, state read, and lease synchronization before gate + completion. +- `OldCallbacksPreserveSuccessor` keeps successor session and runtime lease 2 + after delayed lease 1 callbacks. +- `ReportedFailureKeepsDurableSetup` prevents a reported failure from hiding a + committed source or observer change. +- `PersistedSourceHasAlignedCredential` requires each stored credential-backed + source to have its credential identity. +- `VisibleFrameMatchesPublishedObserver` checks each visible frame against the + published observer and generation. +- `ActiveProjectionMatchesPublishedObserver` checks context, observer, and all + three lease owners for an active projection. +- `PublicationFollowsDurableCommit` prevents setup publication before a + successful preference write. +- `EventuallyReports` requires the finite mutation to return success or + failure. +- `EventuallyQuiescent` requires the transaction and its deferred save to + finish. + +Reachability controls prove that TLC visits these states: + +- A committed retry failure queues foreground reconciliation. +- An old render finishes after observer publication and is rejected. +- Invalidation completes after every required phase. +- Renewal returns `replaced`, `retired`, and `superseded`. +- Old activation and deactivation callbacks run after direct successor sync. +- An accepted old deactivation suspends while successor runtime lease 2 starts. + +## Bounds, fairness, and exclusions + +The initial source and observer use value zero. The target source and observer +use value one. The target source uses one credential identity. + +Lease 0 means no active lease. Lease 1 is the captured lease. Lease 2 is the +only successor lease. The model contains one runnable experience. + +The model includes one preference mutation and one coalesced foreground save. +`CurrentSmall.cfg` allows one foreground edit. `CurrentExpanded.cfg` allows two +foreground edits. Both configurations check source and observer mutations. + +The bounded model queues one old activation callback when publication starts. +A renewal then queues its source-faithful sequence: + +- `replaced` queues old deactivation and successor activation. +- `retired` queues old deactivation and leaves no authoritative lease. +- `superseded` leaves successor lease 2 authoritative and queues its pending + activation. + +The bounded `retired` branch includes a pending old deactivation. The model +excludes retirement without a pending callback. + +The FIFO callback lane can hold all three bounded commands. It completes one +accepted deactivation before it dispatches the next command. The direct lease +read is outside that callback lane. + +Weak fairness applies to the finite mutation, persistence worker, callback +lane, activation, renderer, and foreground editor. These assumptions support +the two temporal properties. The safety properties do not depend on fairness. + +The model excludes unbounded edits, process termination, direct credential +deletion, malformed stored data, location acquisition, provider results, and +projection math. It also excludes physical poller and demand lifecycles. + +[`ProjectionActivation`](../ProjectionActivation/README.md) owns the full +coordinator, session, runtime, demand, and physical-poller protocol. Its +`PreFixContextRetainedLease.cfg` control falsifies `FreshContextAtQuiescence` +when context replacement retains the same lease. + +That model also proves gated and delayed callback reachability. Its relevant +probes include `OldActivationWhileGatedAfterSuccessorNotReached`, +`OldActivationAfterSuccessorNotReached`, and +`DelayedRuntimeTeardownAfterSuccessorNotReached`. + +This model checks how one stored mutation crosses that lease protocol. It does +not duplicate the full lifecycle proof. + +## Negative controls + +`BrokenRetry.cfg` removes committed-candidate knowledge. TLC finds this trace: + +1. The first candidate write commits the new source. +2. A foreground edit changes the live snapshot. +3. The retry fails. +4. The mutation restores the credential and reports failure. +5. `PersistedSourceHasAlignedCredential` fails at depth 17. + +This trace represents the success, drift, and retry-failure bug fixed by +`98521ce5`. + +`BrokenObserver.cfg` publishes the observer before its preference write. The +old visible frame remains published. `VisibleFrameMatchesPublishedObserver` +fails at depth three. + +This trace represents the observer transaction bug fixed by `fd79ac53`. + +`BrokenInvalidation.cfg` skips the authoritative lease read and session sync. +It still performs renewal, exact runtime retirement, cleanup, configuration, +and state read. It then completes the gate. + +`InvalidationCompletionFollowsRequiredWork` fails at depth 18. This control is +a narrow transaction mutation. It does not replace the historical same-lease +control in `ProjectionActivation`. + +Each negative control uses the current state and a current-design property. +The manifest requires the named failure. Another error does not count as a +successful control. + +## Result + +**Verified for these model bounds and assumptions.** TLC exhausted both +current configurations without an invariant, temporal, or deadlock error. + +| Configuration | Result | Generated | Distinct | Depth | +| --- | ---: | ---: | ---: | ---: | +| `CurrentSmall.cfg` | pass | 47,249 | 13,971 | 39 | +| `CurrentExpanded.cfg` | pass | 181,368 | 52,032 | 44 | +| `BrokenRetry.cfg` | expected failure | 576 | 302 | 17 | +| `BrokenObserver.cfg` | expected failure | 5 | 5 | 3 | +| `BrokenInvalidation.cfg` | expected failure | 353 | 184 | 18 | +| `CurrentRetryReachability.cfg` | expected failure | 5,923 | 2,762 | 25 | +| `CurrentStaleRenderReachability.cfg` | expected failure | 89 | 46 | 9 | +| `CurrentInvalidationReachability.cfg` | expected failure | 436 | 225 | 19 | +| `CurrentRenewalReplacedReachability.cfg` | expected failure | 97 | 43 | 12 | +| `CurrentRenewalRetiredReachability.cfg` | expected failure | 98 | 44 | 12 | +| `CurrentRenewalSupersededReachability.cfg` | expected failure | 99 | 45 | 12 | +| `CurrentOldCallbackReachability.cfg` | expected failure | 527 | 271 | 20 | +| `CurrentDelayedRuntimeTeardownReachability.cfg` | expected failure | 1,410 | 593 | 26 | + +The old-callback trace performs direct successor sync before it dispatches old +activation and deactivation callbacks. Both callbacks preserve session lease +2. + +The delayed-teardown trace accepts old deactivation while the session tracker +contains the lease 1 tombstone. Its runtime await crosses direct sync and +successor activation. Runtime lease 2 remains active. + +The check used tla2tools 1.7.4, TLC2 2.19 at revision `5a47802`, and PlusCal +1.11. It used Temurin Java 21.0.8+9. The pinned `tla2tools.jar` SHA-256 is +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`. + +Deterministic Swift guards: + +- [`ThrowSessionAircraftTests.sourceInvalidationRejectsAPendingRenderAndRepeatedLease`](../../ThrowUI/Tests/ThrowSession+AircraftTests.swift) +- [`ThrowSessionAircraftTests.samePermitSourceReconfigurationRenewsLeaseAndPhysicalPoller`](../../ThrowUI/Tests/ThrowSession+AircraftTests.swift) +- [`ProjectionExperienceCoordinatorTests.exactActiveRenewalRetiresAndRemintsInOneCoordinatorTurn`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ProjectionExperienceCoordinatorTests.renewingTransitionTargetRetiresItAndRejectsOldCallbacks`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ProjectionExperienceCoordinatorTests.renewingPrewarmRetiresItAndRejectsOldCallbacks`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ThrowSessionExperiencesTests.staleDeactivationCannotReleaseANewerSessionLease`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift) +- [`ThrowSessionExperiencesTests.delayedEqualDeactivationStillStopsRuntimeAfterDirectNilSync`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift) +- [`AirAndSpaceRuntimeTests.staleLeaseQueuedBeforeReplacementCannotDeactivateTheReplacement`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift) +- [`AirAndSpaceRuntimeTests.newerDeactivationTombstonesActivationSuspendedDuringReset`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift) + +This result is not an implementation proof. Changes to preference storage, +publication, invalidation order, lease renewal, action ordering, session +tombstones, runtime tombstones, activation, or rendering invalidate it. diff --git a/Throw/Specifications/PreferenceTransactions/manifest.json b/Throw/Specifications/PreferenceTransactions/manifest.json new file mode 100644 index 000000000..aa88a7a71 --- /dev/null +++ b/Throw/Specifications/PreferenceTransactions/manifest.json @@ -0,0 +1,82 @@ +{ + "source": "pluscal", + "module": "PreferenceTransactions.tla", + "cases": [ + { + "name": "broken-retry", + "config": "BrokenRetry.cfg", + "expect": "fail", + "outputContains": "Invariant PersistedSourceHasAlignedCredential is violated." + }, + { + "name": "broken-observer", + "config": "BrokenObserver.cfg", + "expect": "fail", + "outputContains": "Invariant VisibleFrameMatchesPublishedObserver is violated." + }, + { + "name": "broken-invalidation", + "config": "BrokenInvalidation.cfg", + "expect": "fail", + "outputContains": "Invariant InvalidationCompletionFollowsRequiredWork is violated." + }, + { + "name": "retry-reachability", + "config": "CurrentRetryReachability.cfg", + "expect": "fail", + "outputContains": "Invariant RetryRecoveryWasNotQueued is violated." + }, + { + "name": "stale-render-reachability", + "config": "CurrentStaleRenderReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleRenderWasNotRejected is violated." + }, + { + "name": "invalidation-reachability", + "config": "CurrentInvalidationReachability.cfg", + "expect": "fail", + "outputContains": "Invariant RequiredInvalidationPathNotReached is violated." + }, + { + "name": "renewal-replaced-reachability", + "config": "CurrentRenewalReplacedReachability.cfg", + "expect": "fail", + "outputContains": "Invariant ReplacedRenewalNotReached is violated." + }, + { + "name": "renewal-retired-reachability", + "config": "CurrentRenewalRetiredReachability.cfg", + "expect": "fail", + "outputContains": "Invariant RetiredRenewalNotReached is violated." + }, + { + "name": "renewal-superseded-reachability", + "config": "CurrentRenewalSupersededReachability.cfg", + "expect": "fail", + "outputContains": "Invariant SupersededRenewalNotReached is violated." + }, + { + "name": "old-callback-reachability", + "config": "CurrentOldCallbackReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OldCallbacksAfterSuccessorSyncNotReached is violated." + }, + { + "name": "delayed-runtime-teardown-reachability", + "config": "CurrentDelayedRuntimeTeardownReachability.cfg", + "expect": "fail", + "outputContains": "Invariant DelayedRuntimeTeardownAfterSuccessorNotReached is violated." + }, + { + "name": "current-small", + "config": "CurrentSmall.cfg", + "expect": "pass" + }, + { + "name": "current-expanded", + "config": "CurrentExpanded.cfg", + "expect": "pass" + } + ] +} diff --git a/Throw/Specifications/ProjectionActivation/BrokenIdentityTeardown.cfg b/Throw/Specifications/ProjectionActivation/BrokenIdentityTeardown.cfg new file mode 100644 index 000000000..149251446 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/BrokenIdentityTeardown.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "identityOnly" + SceneCount = 1 + OutputCount = 1 + Events <- LeaseReplacementRace + +INVARIANTS + TypeOK + NoStaleTeardownOfNewerLease + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/CalibrationReachability.cfg b/Throw/Specifications/ProjectionActivation/CalibrationReachability.cfg new file mode 100644 index 000000000..1afd5fd27 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CalibrationReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + CalibrationBlockNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/ContextGateReachability.cfg b/Throw/Specifications/ProjectionActivation/ContextGateReachability.cfg new file mode 100644 index 000000000..1da3b6529 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/ContextGateReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + ContextGateNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/ContextPollerReachability.cfg b/Throw/Specifications/ProjectionActivation/ContextPollerReachability.cfg new file mode 100644 index 000000000..531eb3afe --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/ContextPollerReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + ContextPollerNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/ContextRenewalReachability.cfg b/Throw/Specifications/ProjectionActivation/ContextRenewalReachability.cfg new file mode 100644 index 000000000..a07948cbe --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/ContextRenewalReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + ContextRenewalNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/CurrentContextAndSuspension.cfg b/Throw/Specifications/ProjectionActivation/CurrentContextAndSuspension.cfg new file mode 100644 index 000000000..8428024ab --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CurrentContextAndSuspension.cfg @@ -0,0 +1,23 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextAndSuspensionEvents + +INVARIANTS + TypeOK + OwnershipUsesIssuedLeases + NoStaleTeardownOfNewerLease + AtMostOnePhysicalPoller + SessionLeaseMatchesHighWater + RuntimeLeaseMatchesHighWater + PermitSafety + CorrectAtQuiescence + FreshContextAtQuiescence + FreshPhysicalResumeAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/ProjectionActivation/CurrentContextRenewal.cfg b/Throw/Specifications/ProjectionActivation/CurrentContextRenewal.cfg new file mode 100644 index 000000000..7c2a4d92e --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CurrentContextRenewal.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + OwnershipUsesIssuedLeases + NoStaleTeardownOfNewerLease + AtMostOnePhysicalPoller + SessionLeaseMatchesHighWater + RuntimeLeaseMatchesHighWater + PermitSafety + CorrectAtQuiescence + FreshContextAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/ProjectionActivation/CurrentPhysicalSuspension.cfg b/Throw/Specifications/ProjectionActivation/CurrentPhysicalSuspension.cfg new file mode 100644 index 000000000..5f9b6e623 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CurrentPhysicalSuspension.cfg @@ -0,0 +1,22 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + OwnershipUsesIssuedLeases + NoStaleTeardownOfNewerLease + AtMostOnePhysicalPoller + SessionLeaseMatchesHighWater + RuntimeLeaseMatchesHighWater + PermitSafety + CorrectAtQuiescence + FreshPhysicalResumeAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/ProjectionActivation/CurrentRace.cfg b/Throw/Specifications/ProjectionActivation/CurrentRace.cfg new file mode 100644 index 000000000..9b8343d9c --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CurrentRace.cfg @@ -0,0 +1,21 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- LeaseReplacementRace + +INVARIANTS + TypeOK + OwnershipUsesIssuedLeases + NoStaleTeardownOfNewerLease + AtMostOnePhysicalPoller + SessionLeaseMatchesHighWater + RuntimeLeaseMatchesHighWater + PermitSafety + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/ProjectionActivation/CurrentTwoScenes.cfg b/Throw/Specifications/ProjectionActivation/CurrentTwoScenes.cfg new file mode 100644 index 000000000..140fa06b1 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/CurrentTwoScenes.cfg @@ -0,0 +1,21 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 2 + OutputCount = 2 + Events <- TwoSceneOverlapEvents + +INVARIANTS + TypeOK + OwnershipUsesIssuedLeases + NoStaleTeardownOfNewerLease + AtMostOnePhysicalPoller + SessionLeaseMatchesHighWater + RuntimeLeaseMatchesHighWater + PermitSafety + CorrectAtQuiescence + +PROPERTY EventuallyConverges + +CHECK_DEADLOCK TRUE diff --git a/Throw/Specifications/ProjectionActivation/DelayedRuntimeTeardownReachability.cfg b/Throw/Specifications/ProjectionActivation/DelayedRuntimeTeardownReachability.cfg new file mode 100644 index 000000000..cb490d4f5 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/DelayedRuntimeTeardownReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + DelayedRuntimeTeardownAfterSuccessorNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/DemandOvertakeReachability.cfg b/Throw/Specifications/ProjectionActivation/DemandOvertakeReachability.cfg new file mode 100644 index 000000000..a820b734b --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/DemandOvertakeReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + DemandOvertakeNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/DirectLeaseSyncReachability.cfg b/Throw/Specifications/ProjectionActivation/DirectLeaseSyncReachability.cfg new file mode 100644 index 000000000..d7ed56757 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/DirectLeaseSyncReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + DirectLeaseSyncNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/DrainReachability.cfg b/Throw/Specifications/ProjectionActivation/DrainReachability.cfg new file mode 100644 index 000000000..61629818e --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/DrainReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + PhysicalDrainNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/GatedSuccessorSyncReachability.cfg b/Throw/Specifications/ProjectionActivation/GatedSuccessorSyncReachability.cfg new file mode 100644 index 000000000..a5a2cc216 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/GatedSuccessorSyncReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + GatedSuccessorSyncNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/OldActivationAfterSuccessorReachability.cfg b/Throw/Specifications/ProjectionActivation/OldActivationAfterSuccessorReachability.cfg new file mode 100644 index 000000000..660c251e7 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/OldActivationAfterSuccessorReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + OldActivationAfterSuccessorNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/OldActivationWhileGatedReachability.cfg b/Throw/Specifications/ProjectionActivation/OldActivationWhileGatedReachability.cfg new file mode 100644 index 000000000..f672debda --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/OldActivationWhileGatedReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + OldActivationWhileGatedAfterSuccessorNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PermitGapReachability.cfg b/Throw/Specifications/ProjectionActivation/PermitGapReachability.cfg new file mode 100644 index 000000000..986857680 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PermitGapReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + PermitGapNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PermitTeardownReachability.cfg b/Throw/Specifications/ProjectionActivation/PermitTeardownReachability.cfg new file mode 100644 index 000000000..10566c678 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PermitTeardownReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + PendingPermitTeardownNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PhysicalReachability.cfg b/Throw/Specifications/ProjectionActivation/PhysicalReachability.cfg new file mode 100644 index 000000000..626c32315 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PhysicalReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + PhysicalPollerNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PhysicalSuspensionReachability.cfg b/Throw/Specifications/ProjectionActivation/PhysicalSuspensionReachability.cfg new file mode 100644 index 000000000..0185db61d --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PhysicalSuspensionReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + PhysicalSuspensionNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PreFixContextRetainedLease.cfg b/Throw/Specifications/ProjectionActivation/PreFixContextRetainedLease.cfg new file mode 100644 index 000000000..3d462ba6c --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PreFixContextRetainedLease.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "retainsContextLease" + SceneCount = 1 + OutputCount = 1 + Events <- ContextRenewalEvents + +INVARIANTS + TypeOK + FreshContextAtQuiescence + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PreFixNoDemandTombstone.cfg b/Throw/Specifications/ProjectionActivation/PreFixNoDemandTombstone.cfg new file mode 100644 index 000000000..79e353def --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PreFixNoDemandTombstone.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "noDemandTombstone" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + PermitSafety + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PreFixPollingRetiresLease.cfg b/Throw/Specifications/ProjectionActivation/PreFixPollingRetiresLease.cfg new file mode 100644 index 000000000..d8789c288 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PreFixPollingRetiresLease.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "retiresOnSuspend" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + CorrectAtQuiescence + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PreFixRuntimeTombstone.cfg b/Throw/Specifications/ProjectionActivation/PreFixRuntimeTombstone.cfg new file mode 100644 index 000000000..606325f33 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PreFixRuntimeTombstone.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "noRuntimeTombstone" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + PermitSafety + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/PreFixStoppedLease.cfg b/Throw/Specifications/ProjectionActivation/PreFixStoppedLease.cfg new file mode 100644 index 000000000..8cbe5c12e --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/PreFixStoppedLease.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "exposesStopped" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + CorrectAtQuiescence + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/ProjectionActivation.tla b/Throw/Specifications/ProjectionActivation/ProjectionActivation.tla new file mode 100644 index 000000000..69cae7dbf --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/ProjectionActivation.tla @@ -0,0 +1,876 @@ +---- MODULE ProjectionActivation ---- +EXTENDS FiniteSets, Integers, Sequences + +CONSTANTS Implementation, SceneCount, OutputCount, Events + +AllowedImplementations == { + "current", "exposesStopped", "identityOnly", "noRuntimeTombstone", + "retainsContextLease", "retiresOnSuspend", "noDemandTombstone" +} +AllowedEvents == { + "scene1On", "scene1Off", "scene2On", "scene2Off", + "output1On", "output1Off", "output2On", "output2Off", + "quietOn", "quietOff", "calibrationOn", "calibrationOff", + "contextChange", "pollingBlockOn", "pollingBlockOff" +} + +EventIsValid(event) == + /\ event \in AllowedEvents + /\ (event \in {"scene2On", "scene2Off"} => SceneCount = 2) + /\ (event \in {"output2On", "output2Off"} => OutputCount = 2) + +ASSUME /\ Implementation \in AllowedImplementations + /\ SceneCount \in 1..2 + /\ OutputCount \in 1..2 + /\ Events \in Seq(AllowedEvents) + /\ Len(Events) > 0 + /\ \A index \in 1..Len(Events) : EventIsValid(Events[index]) + +SceneIDs == 1..SceneCount +OutputIDs == 1..OutputCount +LeaseIDs == 1..Len(Events) +OptionalLeaseIDs == 0..Len(Events) +WorkRevisionLimit == 4 * Len(Events) +WorkRevisions == 1..WorkRevisionLimit +OptionalWorkRevisions == 0..WorkRevisionLimit + +CommandKinds == {"activate", "deactivate"} +LifecycleCommand(kind, lease) == [kind |-> kind, lease |-> lease] +LifecycleCommands == [kind : CommandKinds, lease : LeaseIDs] + +PollRequest(kind, lease, revision) == + [kind |-> kind, lease |-> lease, revision |-> revision] +PollRequests == [ + kind : CommandKinds, + lease : LeaseIDs, + revision : WorkRevisions +] +NoPollRequest == [kind |-> "none", lease |-> 0, revision |-> 0] + +DemandKinds == {"activate", "suspend"} +DemandCommand(kind, lease, generation) == + [kind |-> kind, lease |-> lease, generation |-> generation] +DemandCommands == [ + kind : DemandKinds, + lease : LeaseIDs, + generation : 1..Len(Events) +] + +TeardownEffect(command, victim) == [command |-> command, victim |-> victim] +TeardownEffects == [command : LeaseIDs, victim : LeaseIDs] + +ReconcilePhases == {"idle", "captured", "coordinated", "synchronized"} +PollOperationPhases == {"idle", "draining", "starting"} +InvalidationPhases == { + "idle", "prepared", "renewed", "runtimeRetired", "successorSynchronized" +} +PollingDemandStates == {"none", "polling", "stopped"} + +RemoveAt(sequence, index) == + SubSeq(sequence, 1, index - 1) \o + SubSeq(sequence, index + 1, Len(sequence)) + +QueueHasTeardownFor(queue, victims) == + \E index \in 1..Len(queue) : + /\ queue[index].kind = "deactivate" + /\ \A victim \in victims : queue[index].lease >= victim + +PollQueueHasCurrentTeardownFor(queue, victims, currentRevision) == + \E index \in 1..Len(queue) : + /\ queue[index].kind = "deactivate" + /\ queue[index].revision = currentRevision + /\ \A victim \in victims : queue[index].lease >= victim + +DemandQueueHasCurrentSuspensionFor(queue, victims, currentGeneration) == + \E index \in 1..Len(queue) : + /\ queue[index].kind = "suspend" + /\ queue[index].generation = currentGeneration + /\ \A victim \in victims : queue[index].lease >= victim + +SingleSceneEvents == + <<"scene1On", "output1On", "quietOn", "quietOff", + "calibrationOn", "calibrationOff", "scene1Off">> + +TwoSceneEvents == + <<"scene1On", "scene2On", "output1On", "output2On", + "scene1Off", "quietOn", "quietOff", "calibrationOn", + "calibrationOff", "scene2Off", "output1Off", "output2Off">> + +TwoSceneOverlapEvents == + <<"scene1On", "scene2On", "output1On", "scene1Off", "scene2Off">> + +LeaseReplacementRace == + <<"scene1On", "output1On", "quietOn", "quietOff">> + +ContextRenewalEvents == + <<"scene1On", "output1On", "contextChange">> + +PhysicalSuspensionEvents == + <<"scene1On", "output1On", "pollingBlockOn", "pollingBlockOff">> + +ContextAndSuspensionEvents == + <<"scene1On", "output1On", "contextChange", + "pollingBlockOn", "pollingBlockOff">> + +(* --algorithm ProjectionActivationAlgorithm { +variables submitted = 0, + foregroundScenes = {}, + connectedOutputs = {}, + quietRequested = FALSE, + calibrationRequested = FALSE, + pollingBlocked = FALSE, + requestRevision = 0, + reconcilePhase = "idle", + capturedRevision = 0, + capturedPermit = FALSE, + capturedPollingBlocked = FALSE, + capturedSessionLease = 0, + reconciledRevision = 0, + reconciledPermit = FALSE, + coordinatorRunning = FALSE, + coordinatorLease = 0, + nextLease = 0, + issuedLeases = {}, + actionQueue = <<>>, + sessionLease = 0, + latestSessionLease = 0, + runtimeQueue = <<>>, + demandQueue = <<>>, + runtimeLease = 0, + latestRuntimeLease = 0, + pollDemandState = "none", + pollDemandGeneration = 0, + pollRequestRevision = 0, + pollQueue = <<>>, + pollOperationPhase = "idle", + pollOperation = NoPollRequest, + physicalPollers = {}, + lastStartedPollRevision = 0, + lastSuspendedLease = 0, + lastSuspendedPollRevision = 0, + contextRevision = 0, + invalidationPhase = "idle", + invalidationLease = 0, + lastContextLease = 0, + lastContextPollRevision = 0, + teardownHistory = {}, + sawPermitGap = FALSE, + sawPendingPermitTeardown = FALSE, + sawDirectLeaseSync = FALSE, + sawStaleTeardownCommand = FALSE, + sawNewerRuntimeTeardown = FALSE, + sawPhysicalDrain = FALSE, + sawPhysicalPoller = FALSE, + sawTwoForegroundScenes = FALSE, + sawQuietBlock = FALSE, + sawCalibrationBlock = FALSE, + sawContextGate = FALSE, + sawContextRenewal = FALSE, + sawGatedSuccessorSync = FALSE, + sawContextPoller = FALSE, + sawOldActivationWhileGatedAfterSuccessor = FALSE, + sawOldActivationAfterSuccessor = FALSE, + sawDelayedRuntimeTeardownAfterSuccessor = FALSE, + sawPhysicalSuspension = FALSE, + sawSameLeaseResume = FALSE, + sawDemandOvertake = FALSE; + +define { + RawPermit == + /\ foregroundScenes /= {} + /\ connectedOutputs /= {} + /\ ~quietRequested + /\ ~calibrationRequested + + RawPhysicalPermit == + /\ RawPermit + /\ ~pollingBlocked + /\ invalidationPhase = "idle" + + RuntimeAcceptsLease(lease) == + IF runtimeLease /= 0 + THEN lease >= runtimeLease + ELSE \/ latestRuntimeLease = 0 + \/ lease > latestRuntimeLease + \/ /\ Implementation = "noRuntimeTombstone" + /\ lease = latestRuntimeLease + + PollDemandAcceptsActivation(generation) == + \/ pollDemandState = "none" + \/ /\ pollDemandState = "polling" + /\ generation >= pollDemandGeneration + \/ /\ pollDemandState = "stopped" + /\ generation > pollDemandGeneration + + PollDemandAcceptsSuspension(generation) == + \/ pollDemandState = "none" + \/ generation >= pollDemandGeneration + + LatestReconciliationPending == + \/ reconciledRevision /= requestRevision + \/ reconcilePhase /= "idle" + \/ invalidationPhase /= "idle" + + TeardownPending == + \/ QueueHasTeardownFor(actionQueue, physicalPollers) + \/ QueueHasTeardownFor(runtimeQueue, physicalPollers) + \/ DemandQueueHasCurrentSuspensionFor( + demandQueue, + physicalPollers, + requestRevision) + \/ PollQueueHasCurrentTeardownFor( + pollQueue, + physicalPollers, + pollRequestRevision) + \/ pollOperationPhase = "draining" + \/ invalidationPhase \in { + "renewed", "runtimeRetired", "successorSynchronized" + } + + Quiescent == + /\ submitted = Len(Events) + /\ reconcilePhase = "idle" + /\ reconciledRevision = requestRevision + /\ invalidationPhase = "idle" + /\ actionQueue = <<>> + /\ runtimeQueue = <<>> + /\ demandQueue = <<>> + /\ pollQueue = <<>> + /\ pollOperationPhase = "idle" + + QuiescentAgreement == + /\ Quiescent + /\ reconciledPermit = RawPermit + /\ coordinatorRunning = RawPermit + /\ IF RawPermit + THEN /\ coordinatorLease \in LeaseIDs + /\ sessionLease = coordinatorLease + /\ runtimeLease = coordinatorLease + /\ IF pollingBlocked + THEN /\ pollDemandState = "stopped" + /\ physicalPollers = {} + ELSE /\ pollDemandState = "polling" + /\ physicalPollers = {coordinatorLease} + ELSE /\ sessionLease = 0 + /\ runtimeLease = 0 + /\ physicalPollers = {} +} + +fair process (Environment = <<"Environment", 0>>) { +SubmitEvent: + while (TRUE) { + await submitted < Len(Events); + with (index = submitted + 1) { + with (event = Events[index]) { + await event /= "contextChange" \/ invalidationPhase = "idle"; + if (event = "scene1On") { + foregroundScenes := foregroundScenes \cup {1}; + } else if (event = "scene1Off") { + foregroundScenes := foregroundScenes \ {1}; + } else if (event = "scene2On") { + foregroundScenes := foregroundScenes \cup {2}; + } else if (event = "scene2Off") { + foregroundScenes := foregroundScenes \ {2}; + } else if (event = "output1On") { + connectedOutputs := connectedOutputs \cup {1}; + } else if (event = "output1Off") { + connectedOutputs := connectedOutputs \ {1}; + } else if (event = "output2On") { + connectedOutputs := connectedOutputs \cup {2}; + } else if (event = "output2Off") { + connectedOutputs := connectedOutputs \ {2}; + } else if (event = "quietOn") { + quietRequested := TRUE; + } else if (event = "quietOff") { + quietRequested := FALSE; + } else if (event = "calibrationOn") { + calibrationRequested := TRUE; + } else if (event = "calibrationOff") { + calibrationRequested := FALSE; + } else if (event = "pollingBlockOn") { + pollingBlocked := TRUE; + } else if (event = "pollingBlockOff") { + pollingBlocked := FALSE; + } else if (event = "contextChange") { + contextRevision := contextRevision + 1 || + invalidationPhase := "prepared" || + sawContextGate := TRUE; + if (sessionLease /= 0) { + invalidationLease := sessionLease || + lastContextLease := sessionLease || + lastContextPollRevision := lastStartedPollRevision || + latestSessionLease := sessionLease || + sessionLease := 0; + } else { + invalidationLease := 0; + }; + }; + submitted := index || + requestRevision := index; + if (Cardinality(foregroundScenes) = 2) { + sawTwoForegroundScenes := TRUE; + }; + if (quietRequested /\ foregroundScenes /= {} /\ connectedOutputs /= {}) { + sawQuietBlock := TRUE; + }; + if (calibrationRequested /\ foregroundScenes /= {} /\ connectedOutputs /= {}) { + sawCalibrationBlock := TRUE; + }; + if (RawPermit /= reconciledPermit) { + sawPermitGap := TRUE; + }; + if (~RawPhysicalPermit /\ physicalPollers /= {}) { + sawPendingPermitTeardown := TRUE; + }; + }; + }; + } +} + +fair process (Reconciler = <<"Reconciler", 0>>) { +CaptureLatest: + while (TRUE) { + await /\ reconcilePhase = "idle" + /\ capturedRevision /= requestRevision + /\ invalidationPhase = "idle"; + capturedRevision := requestRevision || + capturedPermit := RawPermit || + capturedPollingBlocked := pollingBlocked || + capturedSessionLease := sessionLease || + reconcilePhase := "captured" || + sawPermitGap := sawPermitGap \/ (RawPermit /= reconciledPermit); + +ReconcileCoordinator: + await reconcilePhase = "captured"; + reconciledRevision := capturedRevision || + reconciledPermit := capturedPermit || + reconcilePhase := "coordinated"; + if (capturedPermit /\ ~coordinatorRunning) { + with (lease = nextLease + 1) { + coordinatorRunning := TRUE || + coordinatorLease := lease || + nextLease := lease || + issuedLeases := issuedLeases \cup {lease} || + actionQueue := Append( + actionQueue, + LifecycleCommand("activate", lease) + ); + }; + } else if (~capturedPermit /\ coordinatorRunning) { + coordinatorRunning := FALSE || + actionQueue := Append( + actionQueue, + LifecycleCommand("deactivate", coordinatorLease) + ); + }; + +SynchronizeCurrentLease: + await reconcilePhase = "coordinated"; + if (invalidationPhase = "idle") { + if ((coordinatorRunning \/ Implementation = "exposesStopped") /\ + coordinatorLease /= 0) { + sawDirectLeaseSync := TRUE; + if (sessionLease /= 0 /\ coordinatorLease >= latestSessionLease) { + sessionLease := coordinatorLease || + latestSessionLease := coordinatorLease; + } else if (sessionLease = 0 /\ + (latestSessionLease = 0 \/ + coordinatorLease > latestSessionLease \/ + (Implementation = "exposesStopped" /\ + coordinatorLease = latestSessionLease))) { + sessionLease := coordinatorLease || + latestSessionLease := coordinatorLease; + }; + } else if (~coordinatorRunning /\ sessionLease /= 0) { + latestSessionLease := sessionLease || + sessionLease := 0 || + sawDirectLeaseSync := TRUE; + }; + }; + reconcilePhase := "synchronized"; + +ApplyIfCurrent: + await reconcilePhase = "synchronized"; + if (capturedRevision = requestRevision /\ invalidationPhase = "idle") { + if (capturedPermit /\ sessionLease /= 0) { + demandQueue := Append( + demandQueue, + DemandCommand( + IF capturedPollingBlocked THEN "suspend" ELSE "activate", + sessionLease, + capturedRevision + ) + ); + } else if (~capturedPermit /\ capturedSessionLease /= 0) { + runtimeQueue := Append( + runtimeQueue, + LifecycleCommand("deactivate", capturedSessionLease) + ); + }; + }; + reconcilePhase := "idle"; + } +} + +fair process (ActionDispatcher = <<"ActionDispatcher", 0>>) { +DispatchCoordinatorAction: + while (TRUE) { + await Len(actionQueue) > 0; + with (command = Head(actionQueue)) { + actionQueue := Tail(actionQueue); + if (command.kind = "activate") { + if (invalidationPhase = "successorSynchronized" /\ + command.lease < latestSessionLease) { + sawOldActivationWhileGatedAfterSuccessor := TRUE; + }; + if (invalidationPhase = "idle") { + if (command.lease < latestSessionLease) { + sawOldActivationAfterSuccessor := TRUE; + }; + if (sessionLease /= 0 /\ command.lease >= latestSessionLease) { + sessionLease := command.lease || + latestSessionLease := command.lease; + } else if (sessionLease = 0 /\ + (latestSessionLease = 0 \/ + command.lease > latestSessionLease \/ + (Implementation = "exposesStopped" /\ + command.lease = latestSessionLease))) { + sessionLease := command.lease || + latestSessionLease := command.lease; + }; + }; + } else { + if (command.lease < latestSessionLease) { + sawStaleTeardownCommand := TRUE; + }; + if (Implementation /= "identityOnly") { + if (command.lease >= latestSessionLease /\ + (sessionLease = 0 \/ sessionLease <= command.lease)) { + if (sessionLease /= 0) { + teardownHistory := teardownHistory \cup { + TeardownEffect(command.lease, sessionLease) + }; + }; + latestSessionLease := command.lease || + sessionLease := 0 || + runtimeQueue := Append(runtimeQueue, command); + }; + } else if (sessionLease /= 0) { + with (victim = sessionLease) { + teardownHistory := teardownHistory \cup { + TeardownEffect(command.lease, victim) + } || + sessionLease := 0 || + runtimeQueue := Append( + runtimeQueue, + LifecycleCommand("deactivate", victim) + ); + }; + } else if (command.lease >= latestSessionLease) { + latestSessionLease := command.lease || + runtimeQueue := Append(runtimeQueue, command); + }; + }; + }; + } +} + +fair process (ContextInvalidator = <<"ContextInvalidator", 0>>) { +RenewExactCoordinatorLease: + while (TRUE) { + await invalidationPhase = "prepared"; + if (invalidationLease /= 0 /\ + coordinatorRunning /\ + coordinatorLease = invalidationLease /\ + Implementation /= "retainsContextLease") { + if (reconciledPermit) { + with (replacement = nextLease + 1) { + coordinatorRunning := TRUE || + coordinatorLease := replacement || + nextLease := replacement || + issuedLeases := issuedLeases \cup {replacement} || + actionQueue := Append( + Append( + actionQueue, + LifecycleCommand("deactivate", invalidationLease) + ), + LifecycleCommand("activate", replacement) + ) || + sawContextRenewal := TRUE; + }; + } else { + coordinatorRunning := FALSE || + actionQueue := Append( + actionQueue, + LifecycleCommand("deactivate", invalidationLease) + ); + }; + }; + invalidationPhase := "renewed"; + +RetireInvalidatedRuntime: + await invalidationPhase = "renewed"; + if (invalidationLease /= 0 /\ + invalidationLease >= latestRuntimeLease) { + with (victim = runtimeLease) { + latestRuntimeLease := invalidationLease; + if (victim /= 0 /\ victim <= invalidationLease) { + with (revision = pollRequestRevision + 1) { + runtimeLease := 0 || + pollDemandState := + IF pollDemandState = "polling" + THEN "stopped" + ELSE pollDemandState || + pollRequestRevision := revision || + pollQueue := Append( + pollQueue, + PollRequest("deactivate", invalidationLease, revision) + ); + }; + }; + }; + }; + invalidationPhase := "runtimeRetired"; + +SynchronizeSuccessorWhileGated: + await /\ invalidationPhase = "runtimeRetired" + /\ physicalPollers = {} + /\ pollQueue = <<>> + /\ pollOperationPhase = "idle"; + if (coordinatorRunning /\ coordinatorLease /= 0) { + sawDirectLeaseSync := TRUE; + if (sessionLease /= 0 /\ coordinatorLease >= latestSessionLease) { + sessionLease := coordinatorLease || + latestSessionLease := coordinatorLease || + sawGatedSuccessorSync := sawGatedSuccessorSync \/ + (invalidationLease /= 0 /\ + coordinatorLease > invalidationLease); + } else if (sessionLease = 0 /\ + (latestSessionLease = 0 \/ + coordinatorLease > latestSessionLease \/ + (Implementation = "exposesStopped" /\ + coordinatorLease = latestSessionLease))) { + sessionLease := coordinatorLease || + latestSessionLease := coordinatorLease || + sawGatedSuccessorSync := sawGatedSuccessorSync \/ + (invalidationLease /= 0 /\ + coordinatorLease > invalidationLease); + }; + } else if (~coordinatorRunning /\ sessionLease /= 0) { + latestSessionLease := sessionLease || + sessionLease := 0 || + sawDirectLeaseSync := TRUE; + }; + invalidationPhase := "successorSynchronized"; + +CompleteInvalidation: + await invalidationPhase = "successorSynchronized"; + invalidationLease := 0 || + invalidationPhase := "idle"; + } +} + +fair process (DemandDispatcher = <<"DemandDispatcher", 0>>) { +DispatchPhysicalDemand: + while (TRUE) { + await Len(demandQueue) > 0; + with (index \in 1..Len(demandQueue)) { + with (command = demandQueue[index]) { + demandQueue := RemoveAt(demandQueue, index); + if (command.kind = "activate") { + if (pollDemandState = "stopped" /\ + command.generation < pollDemandGeneration) { + sawDemandOvertake := TRUE; + }; + if (RuntimeAcceptsLease(command.lease) /\ + PollDemandAcceptsActivation(command.generation)) { + with (revision = pollRequestRevision + 1) { + runtimeLease := command.lease || + latestRuntimeLease := + IF command.lease > latestRuntimeLease + THEN command.lease + ELSE latestRuntimeLease || + pollDemandState := "polling" || + pollDemandGeneration := command.generation || + pollRequestRevision := revision || + pollQueue := Append( + pollQueue, + PollRequest("activate", command.lease, revision) + ); + }; + }; + } else if (Implementation = "noDemandTombstone" /\ + pollDemandState = "none") { + skip; + } else if (RuntimeAcceptsLease(command.lease) /\ + PollDemandAcceptsSuspension(command.generation)) { + if (physicalPollers /= {}) { + lastSuspendedLease := command.lease || + lastSuspendedPollRevision := lastStartedPollRevision || + sawPhysicalSuspension := TRUE; + }; + with (revision = pollRequestRevision + 1) { + runtimeLease := + IF Implementation = "retiresOnSuspend" + THEN 0 + ELSE command.lease || + latestRuntimeLease := + IF command.lease > latestRuntimeLease + THEN command.lease + ELSE latestRuntimeLease || + pollDemandState := "stopped" || + pollDemandGeneration := command.generation || + pollRequestRevision := revision || + pollQueue := Append( + pollQueue, + PollRequest("deactivate", command.lease, revision) + ); + }; + }; + }; + }; + } +} + +fair process (RuntimeDispatcher = <<"RuntimeDispatcher", 0>>) { +DispatchRuntimeCommand: + while (TRUE) { + await Len(runtimeQueue) > 0; + with (command = Head(runtimeQueue)) { + runtimeQueue := Tail(runtimeQueue); + if (command.kind = "deactivate") { + if (runtimeLease > command.lease) { + sawDelayedRuntimeTeardownAfterSuccessor := TRUE; + }; + if (Implementation = "noRuntimeTombstone") { + if (runtimeLease = command.lease) { + with (revision = pollRequestRevision + 1) { + runtimeLease := 0 || + pollDemandState := + IF pollDemandState = "polling" + THEN "stopped" + ELSE pollDemandState || + pollRequestRevision := revision || + pollQueue := Append( + pollQueue, + PollRequest("deactivate", command.lease, revision) + ); + }; + }; + } else if (command.lease >= latestRuntimeLease) { + with (victim = runtimeLease) { + sawNewerRuntimeTeardown := sawNewerRuntimeTeardown \/ + (command.lease > latestRuntimeLease) || + latestRuntimeLease := command.lease; + if (victim /= 0 /\ victim <= command.lease) { + with (revision = pollRequestRevision + 1) { + runtimeLease := 0 || + pollDemandState := + IF pollDemandState = "polling" + THEN "stopped" + ELSE pollDemandState || + pollRequestRevision := revision || + pollQueue := Append( + pollQueue, + PollRequest("deactivate", command.lease, revision) + ); + }; + }; + }; + }; + }; + }; + } +} + +fair process (PollBegin = <<"PollBegin", 0>>) { +BeginPollLifecycle: + while (TRUE) { + await pollOperationPhase = "idle" /\ Len(pollQueue) > 0; + with (request = Head(pollQueue)) { + pollQueue := Tail(pollQueue); + if (request.revision = pollRequestRevision) { + pollOperation := request; + if (physicalPollers /= {}) { + pollOperationPhase := "draining" || + sawPhysicalDrain := TRUE; + } else if (request.kind = "activate") { + pollOperationPhase := "starting"; + }; + }; + }; + } +} + +fair process (PollDrain = <<"PollDrain", 0>>) { +DrainPhysicalPoller: + while (TRUE) { + await pollOperationPhase = "draining"; + physicalPollers := {}; + if (pollOperation.kind = "activate" /\ + pollOperation.revision = pollRequestRevision) { + pollOperationPhase := "starting"; + } else { + pollOperationPhase := "idle"; + }; + } +} + +fair process (PollStart = <<"PollStart", 0>>) { +StartPhysicalPoller: + while (TRUE) { + await pollOperationPhase = "starting"; + if (pollOperation.revision = pollRequestRevision) { + physicalPollers := {pollOperation.lease} || + lastStartedPollRevision := pollOperation.revision || + sawPhysicalPoller := TRUE || + sawPendingPermitTeardown := + sawPendingPermitTeardown \/ ~RawPhysicalPermit || + sawContextPoller := sawContextPoller \/ + (lastContextLease /= 0 /\ + pollOperation.lease > lastContextLease /\ + pollOperation.revision > lastContextPollRevision) || + sawSameLeaseResume := sawSameLeaseResume \/ + (lastSuspendedLease = pollOperation.lease /\ + pollOperation.revision > lastSuspendedPollRevision); + }; + pollOperationPhase := "idle"; + } +} + +process (Done = <<"Done", 0>>) { +QuiescentStutter: + while (TRUE) { + await Quiescent; + skip; + } +} +} *) + +TypeOK == + /\ submitted \in 0..Len(Events) + /\ foregroundScenes \subseteq SceneIDs + /\ connectedOutputs \subseteq OutputIDs + /\ quietRequested \in BOOLEAN + /\ calibrationRequested \in BOOLEAN + /\ pollingBlocked \in BOOLEAN + /\ requestRevision \in 0..Len(Events) + /\ reconcilePhase \in ReconcilePhases + /\ capturedRevision \in 0..Len(Events) + /\ capturedPermit \in BOOLEAN + /\ capturedPollingBlocked \in BOOLEAN + /\ capturedSessionLease \in OptionalLeaseIDs + /\ reconciledRevision \in 0..Len(Events) + /\ reconciledPermit \in BOOLEAN + /\ coordinatorRunning \in BOOLEAN + /\ coordinatorLease \in OptionalLeaseIDs + /\ nextLease \in 0..Len(Events) + /\ issuedLeases \subseteq LeaseIDs + /\ actionQueue \in Seq(LifecycleCommands) + /\ sessionLease \in OptionalLeaseIDs + /\ latestSessionLease \in OptionalLeaseIDs + /\ runtimeQueue \in Seq(LifecycleCommands) + /\ demandQueue \in Seq(DemandCommands) + /\ runtimeLease \in OptionalLeaseIDs + /\ latestRuntimeLease \in OptionalLeaseIDs + /\ pollDemandState \in PollingDemandStates + /\ pollDemandGeneration \in 0..Len(Events) + /\ pollRequestRevision \in OptionalWorkRevisions + /\ pollQueue \in Seq(PollRequests) + /\ pollOperationPhase \in PollOperationPhases + /\ pollOperation \in PollRequests \cup {NoPollRequest} + /\ physicalPollers \subseteq LeaseIDs + /\ lastStartedPollRevision \in OptionalWorkRevisions + /\ lastSuspendedLease \in OptionalLeaseIDs + /\ lastSuspendedPollRevision \in OptionalWorkRevisions + /\ contextRevision \in 0..Len(Events) + /\ invalidationPhase \in InvalidationPhases + /\ invalidationLease \in OptionalLeaseIDs + /\ lastContextLease \in OptionalLeaseIDs + /\ lastContextPollRevision \in OptionalWorkRevisions + /\ teardownHistory \subseteq TeardownEffects + /\ sawPermitGap \in BOOLEAN + /\ sawPendingPermitTeardown \in BOOLEAN + /\ sawDirectLeaseSync \in BOOLEAN + /\ sawStaleTeardownCommand \in BOOLEAN + /\ sawNewerRuntimeTeardown \in BOOLEAN + /\ sawPhysicalDrain \in BOOLEAN + /\ sawPhysicalPoller \in BOOLEAN + /\ sawTwoForegroundScenes \in BOOLEAN + /\ sawQuietBlock \in BOOLEAN + /\ sawCalibrationBlock \in BOOLEAN + /\ sawContextGate \in BOOLEAN + /\ sawContextRenewal \in BOOLEAN + /\ sawGatedSuccessorSync \in BOOLEAN + /\ sawContextPoller \in BOOLEAN + /\ sawOldActivationWhileGatedAfterSuccessor \in BOOLEAN + /\ sawOldActivationAfterSuccessor \in BOOLEAN + /\ sawDelayedRuntimeTeardownAfterSuccessor \in BOOLEAN + /\ sawPhysicalSuspension \in BOOLEAN + /\ sawSameLeaseResume \in BOOLEAN + /\ sawDemandOvertake \in BOOLEAN + +OwnershipUsesIssuedLeases == + /\ (coordinatorLease = 0 \/ coordinatorLease \in issuedLeases) + /\ (sessionLease = 0 \/ sessionLease \in issuedLeases) + /\ (runtimeLease = 0 \/ runtimeLease \in issuedLeases) + /\ physicalPollers \subseteq issuedLeases + +NoStaleTeardownOfNewerLease == + \A effect \in teardownHistory : effect.command >= effect.victim + +AtMostOnePhysicalPoller == Cardinality(physicalPollers) <= 1 + +SessionLeaseMatchesHighWater == + sessionLease = 0 \/ sessionLease = latestSessionLease + +RuntimeLeaseMatchesHighWater == + runtimeLease = 0 \/ runtimeLease = latestRuntimeLease + +PermitSafety == + physicalPollers /= {} /\ ~RawPhysicalPermit => + LatestReconciliationPending \/ TeardownPending + +FreshContextAtQuiescence == + Quiescent /\ RawPermit /\ lastContextLease /= 0 => + /\ coordinatorLease > lastContextLease + /\ sessionLease = coordinatorLease + /\ runtimeLease = coordinatorLease + /\ IF pollingBlocked + THEN physicalPollers = {} + ELSE /\ physicalPollers = {coordinatorLease} + /\ lastStartedPollRevision > lastContextPollRevision + +FreshPhysicalResumeAtQuiescence == + Quiescent /\ RawPermit /\ ~pollingBlocked /\ + lastSuspendedLease = coordinatorLease => + lastStartedPollRevision > lastSuspendedPollRevision + +CorrectAtQuiescence == Quiescent => QuiescentAgreement + +EventuallyConverges == submitted = Len(Events) ~> QuiescentAgreement + +PermitGapNotReached == ~sawPermitGap +PendingPermitTeardownNotReached == ~sawPendingPermitTeardown +DirectLeaseSyncNotReached == ~sawDirectLeaseSync +StaleTeardownCommandNotReached == ~sawStaleTeardownCommand +NewerRuntimeTeardownNotReached == ~sawNewerRuntimeTeardown +PhysicalDrainNotReached == ~sawPhysicalDrain +PhysicalPollerNotReached == ~sawPhysicalPoller +TwoForegroundScenesNotReached == ~sawTwoForegroundScenes +QuietBlockNotReached == ~sawQuietBlock +CalibrationBlockNotReached == ~sawCalibrationBlock +ContextGateNotReached == ~sawContextGate +ContextRenewalNotReached == ~sawContextRenewal +GatedSuccessorSyncNotReached == ~sawGatedSuccessorSync +ContextPollerNotReached == ~sawContextPoller +OldActivationWhileGatedAfterSuccessorNotReached == + ~sawOldActivationWhileGatedAfterSuccessor +OldActivationAfterSuccessorNotReached == ~sawOldActivationAfterSuccessor +DelayedRuntimeTeardownAfterSuccessorNotReached == + ~sawDelayedRuntimeTeardownAfterSuccessor +PhysicalSuspensionNotReached == ~sawPhysicalSuspension +SameLeaseResumeNotReached == ~sawSameLeaseResume +DemandOvertakeNotReached == ~sawDemandOvertake + +==== diff --git a/Throw/Specifications/ProjectionActivation/QuietReachability.cfg b/Throw/Specifications/ProjectionActivation/QuietReachability.cfg new file mode 100644 index 000000000..82b917782 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/QuietReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + QuietBlockNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/README.md b/Throw/Specifications/ProjectionActivation/README.md new file mode 100644 index 000000000..c56d97b3f --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/README.md @@ -0,0 +1,227 @@ +# Projection activation + +This model checks one question: + +> Does Throw preserve the correct experience lease while context and physical polling demand change, without allowing stale work to regain authority? + +The model represents production commit +`32f5f22fe8bf335133bc5bc465c6de0c4322cf72`. + +The tracked source contains only PlusCal. `./tla-check` translates it in the +retained run directory. Do not run `pcal.trans` directly. + +Run the model from the repository root: + +```sh +./tla-check ProjectionActivation +``` + +## Source correspondence + +| Model state or action | Production authority | +| --- | --- | +| `foregroundScenes` | [`ThrowRuntime.controllerScene(_:didReceive:)`](../../Throw/Sources/ThrowRuntime.swift) owns aggregate controller-scene foreground presence. | +| `connectedOutputs` and calibration events | [`ThrowSession.projectionOutputConnected(_:)`, `projectionOutputDisconnected(_:)`, and `updateCalibrationState()`](../../ThrowUI/Sources/Model/ThrowSession.swift) own output demand and calibration. | +| `quietRequested` | [`ThrowSession+Quiet.swift`](../../ThrowUI/Sources/Model/ThrowSession+Quiet.swift) schedules demand changes at quiet boundaries. | +| `pollingBlocked` | Session-only blockers in [`reconcileDemand(generation:)`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) stop physical polling without changing coordinator permission. | +| `requestRevision` and `capturedRevision` | [`ProjectionDemandGeneration`](../../ThrowUI/Sources/Model/ThrowSession.swift) and `scheduleDemandReconciliation()` coalesce session demand. | +| `RawPermit` | Raw scene, output, quiet, and calibration inputs supplied by `ThrowSession`. | +| `reconciledPermit` | [`ProjectionExperienceDemand.permitsProjection`](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift) is the last demand accepted by the coordinator. | +| `coordinatorLease` and `issuedLeases` | `activateRuntime(_:role:)` is the only lease issuer in `ProjectionExperienceCoordinator.swift`. | +| `invalidationPhase` | [`prepareProjectionPreferencePublication(_:)`, `finishProjectionPreferenceInvalidation(_:)`, and `completeProjectionPreferenceInvalidation(_:)`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift) keep the invalidation gate active through replacement. | +| `RenewExactCoordinatorLease` | [`renewActivationLease(_:)`](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift) retires one exact running lease and can mint a successor atomically. | +| `actionQueue` | The coordinator action stream and [`applyExperienceCoordinatorAction(_:)`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift) form one FIFO action lane. | +| `SynchronizeCurrentLease` | [`reconcileExperienceDemand(isQuiet:)`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift) reads the authoritative optional running lease in both directions. | +| `SynchronizeSuccessorWhileGated` | [`configureExperienceCoordinator(with:)`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift) installs the successor before `completeProjectionPreferenceInvalidation(_:)` opens the gate. | +| `sessionLease` | [`ProjectionActivationLeaseTracker`](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift) has active and equality-tombstoned inactive states. | +| `demandQueue` | Calls from demand tasks can reach `AirAndSpaceRuntime` in either order before actor admission. Each call carries `ProjectionDemandGeneration`. | +| `runtimeLease` and `latestRuntimeLease` | [`AirAndSpaceRuntime.ActivationLifecycle`](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift) owns active experience authority and its monotonic tombstone. | +| `pollDemandState` and `pollDemandGeneration` | [`AirAndSpaceRuntime.PollingDemandLifecycle`](../../ThrowUI/Sources/Model/AirAndSpaceRuntime.swift) owns physical polling, stopped demand, and its equality tombstone. | +| `pollRequestRevision` | One model revision identifies each runtime-minted physical polling attempt. Production uses `AirAndSpacePhysicalPollingLease`. | +| `pollQueue` and poll phases | [`AircraftPollingCoordinator`](../../ThrowCore/Sources/AircraftPollingCoordinator.swift) serializes lifecycle work through `lifecycleTail`. | +| `DrainPhysicalPoller` and `StartPhysicalPoller` | Core cancels and drains the old task before a current request starts a replacement. | + +`RawPermit` is requested coordinator permission. `reconciledPermit` is accepted +coordinator permission. The model keeps these facts separate while work is +pending. + +`pollingBlocked` is requested session-only suspension. It abstracts flights +disabled, no enabled layer, a non-operational launch, and unavailable polling +inputs. It does not retire the coordinator lease. + +`contextChange` abstracts a committed aircraft-source or observer-location +change. Preparation tombstones the session lease and closes the gate. The +coordinator then renews the exact running lease. Runtime retirement completes +before the successor synchronizes. The gate opens after synchronization. + +Each labeled PlusCal action represents one atomic region or one suspension +boundary. The coordinator action lane stays FIFO. The physical demand lane can +overtake before actor admission, as Swift tasks can suspend before an actor +call. Runtime state rejects stale work after admission. + +## Properties + +- `TypeOK` checks every variable domain. +- `OwnershipUsesIssuedLeases` rejects experience authority not minted by the coordinator. +- `NoStaleTeardownOfNewerLease` rejects a teardown generation older than its victim. +- `AtMostOnePhysicalPoller` limits the physical poller set to one. +- `SessionLeaseMatchesHighWater` makes an active session lease equal its generation high-water mark. +- `RuntimeLeaseMatchesHighWater` makes an active runtime lease equal its generation high-water mark. +- `PermitSafety` allows a poller without requested physical permission only while current reconciliation or teardown remains pending. +- `CorrectAtQuiescence` requires requested and reconciled permission, leases, physical demand, and the poller to agree. +- `FreshContextAtQuiescence` requires a same-permit context change to converge on a lease and physical attempt newer than the retired context. +- `FreshPhysicalResumeAtQuiescence` requires polling to resume with a newer physical attempt under the preserved experience lease. +- `EventuallyConverges` requires quiescent agreement after all finite events finish. + +Reachability controls prove that TLC visits these states: + +- Requested and reconciled coordinator permission differ. +- Permission loss has a pending physical teardown. +- Direct synchronization reads the current optional coordinator lease. +- A stale teardown waits behind a newer session lease. +- Runtime installs a newer experience tombstone. +- Physical replacement drains an existing poller. +- A physical poller starts. +- Two controller scenes overlap. +- Quiet time and calibration block coordinator permission. +- A context invalidation gate closes, renews an exact lease, synchronizes it while gated, and starts its replacement poller. +- A delayed old activation runs before and after the gate opens following successor synchronization. +- An accepted old teardown resumes after the successor runtime starts. +- A session-only suspension drains a poller and resumes the same lease with a new attempt. +- A delayed older activation reaches runtime after a newer stopped demand. + +## Bounds, fairness, and exclusions + +Current cases use these finite event plans: + +- `LeaseReplacementRace` uses one scene, one output, and four events. +- `TwoSceneOverlapEvents` uses two scenes, one output, and five events. +- `ContextRenewalEvents` uses one scene, one output, and three events. +- `PhysicalSuspensionEvents` uses one scene, one output, and four events. +- `ContextAndSuspensionEvents` uses one scene, one output, and five events. + +The reachability cases also use seven-event single-scene and twelve-event +two-scene plans. These cases stop at the first reached witness. + +The finite environment eventually submits each configured event. Each enabled +reconciliation, invalidation, action, runtime, demand, and Core lane eventually +takes a step. These fairness assumptions support `EventuallyConverges`. + +The model assumes one available Air & Space experience. It models the exact +active-runtime renewal path. Swift tests cover requested, prewarming, and +transition renewal outcomes. + +The model assumes that context transactions are serialized. A context event +represents a successful preference commit. Persistence rollback is outside the +protocol. + +One `contextChange` event combines the preparation and final demand-generation +bumps. The closed gate prevents demand application between those production +bumps. + +The model combines all session-only polling blockers into one Boolean. It does +not distinguish geography rendering from launch, source, credential, GPS, or +query readiness. + +The model checks lifecycle ownership, not provider publications. The separate +`PollingPublication` model checks token and publication-revision ordering. + +The model excludes provider failures, network results, projection math, +rendering, preference staging, process termination, playlist rotation details, +and infinite event streams. + +## Negative controls + +`BrokenIdentityTeardown.cfg` models teardown by experience identity. A delayed +lease 1 command clears session lease 2. `NoStaleTeardownOfNewerLease` fails at +depth 18. + +`PreFixStoppedLease.cfg` exposes a stopped coordinator lease and permits an +inactive equality resurrection. `CorrectAtQuiescence` fails at depth 20. + +`PreFixRuntimeTombstone.cfg` omits the inactive runtime tombstone. A delayed +lease 1 activation starts after lease 2 teardown. `PermitSafety` fails at depth +19. + +`PreFixContextRetainedLease.cfg` keeps lease 1 during a same-permit context +change. The session and runtime tombstone lease 1. Direct synchronization +cannot reactivate the equal lease. `FreshContextAtQuiescence` fails at depth +17. + +`PreFixPollingRetiresLease.cfg` uses full experience deactivation for a +session-only stop. The coordinator keeps lease 1, but runtime tombstones it. +The later enabled demand cannot resume lease 1. `CorrectAtQuiescence` fails at +depth 17. + +`PreFixNoDemandTombstone.cfg` ignores a stopped demand while runtime is stopped. +The held generation 2 activation then enters after generation 3 stopped demand +and starts a poller. `PermitSafety` fails at depth 16. + +Each control uses the same state and current-design property that it must +falsify. The manifest requires the named failure. Another error does not count +as a successful control. + +## Result + +**Verified for these model bounds and assumptions.** TLC exhausted every +current configuration with no invariant, temporal, or deadlock error. + +| Configuration | Result | Generated | Distinct | Depth | +| --- | ---: | ---: | ---: | ---: | +| `CurrentRace.cfg` | pass | 6,769 | 2,871 | 30 | +| `CurrentTwoScenes.cfg` | pass | 3,583 | 1,592 | 29 | +| `CurrentContextRenewal.cfg` | pass | 2,095 | 1,066 | 28 | +| `CurrentPhysicalSuspension.cfg` | pass | 1,629 | 681 | 27 | +| `CurrentContextAndSuspension.cfg` | pass | 82,983 | 29,182 | 44 | +| `BrokenIdentityTeardown.cfg` | expected failure | 1,238 | 546 | 18 | +| `PreFixStoppedLease.cfg` | expected failure | 11,742 | 4,350 | 20 | +| `PreFixRuntimeTombstone.cfg` | expected failure | 11,087 | 4,100 | 19 | +| `PreFixContextRetainedLease.cfg` | expected failure | 408 | 235 | 17 | +| `PreFixPollingRetiresLease.cfg` | expected failure | 846 | 368 | 17 | +| `PreFixNoDemandTombstone.cfg` | expected failure | 740 | 316 | 16 | +| `PermitGapReachability.cfg` | expected failure | 7 | 5 | 4 | +| `PermitTeardownReachability.cfg` | expected failure | 466 | 211 | 11 | +| `DirectLeaseSyncReachability.cfg` | expected failure | 36 | 22 | 6 | +| `RaceReachability.cfg` | expected failure | 1,238 | 546 | 18 | +| `PhysicalReachability.cfg` | expected failure | 315 | 147 | 10 | +| `RuntimeTombstoneReachability.cfg` | expected failure | 881 | 386 | 13 | +| `DrainReachability.cfg` | expected failure | 5,222 | 1,998 | 17 | +| `TwoSceneReachability.cfg` | expected failure | 4 | 3 | 3 | +| `QuietReachability.cfg` | expected failure | 7 | 5 | 4 | +| `CalibrationReachability.cfg` | expected failure | 25 | 16 | 6 | +| `ContextGateReachability.cfg` | expected failure | 7 | 5 | 4 | +| `ContextRenewalReachability.cfg` | expected failure | 65 | 46 | 8 | +| `GatedSuccessorSyncReachability.cfg` | expected failure | 151 | 98 | 10 | +| `OldActivationAfterSuccessorReachability.cfg` | expected failure | 308 | 187 | 12 | +| `OldActivationWhileGatedReachability.cfg` | expected failure | 217 | 136 | 11 | +| `DelayedRuntimeTeardownReachability.cfg` | expected failure | 1,340 | 694 | 20 | +| `ContextPollerReachability.cfg` | expected failure | 1,220 | 633 | 19 | +| `PhysicalSuspensionReachability.cfg` | expected failure | 824 | 360 | 16 | +| `SameLeaseResumeReachability.cfg` | expected failure | 1,618 | 677 | 26 | +| `DemandOvertakeReachability.cfg` | expected failure | 551 | 252 | 14 | + +The check used tla2tools 1.7.4, TLC2 2.19 at revision `5a47802`, and +PlusCal 1.11. It used Temurin Java 21.0.8+9. The pinned `tla2tools.jar` +SHA-256 is `936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`. + +Deterministic Swift guards: + +- [`ProjectionExperienceCoordinatorTests.exactActiveRenewalRetiresAndRemintsInOneCoordinatorTurn`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ProjectionExperienceCoordinatorTests.renewingTransitionTargetRetiresItAndRejectsOldCallbacks`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ProjectionExperienceCoordinatorTests.renewingPrewarmRetiresItAndRejectsOldCallbacks`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ProjectionExperienceCoordinatorTests.renewingCommittedTargetRemintsItAndInvalidatesOldCompletion`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift) +- [`ThrowSessionAircraftTests.samePermitSourceReconfigurationRenewsLeaseAndPhysicalPoller`](../../ThrowUI/Tests/ThrowSession+AircraftTests.swift) +- [`ThrowSessionExperiencesTests.staleDeactivationCannotReleaseANewerSessionLease`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift) +- [`AirAndSpaceRuntimeTests.suspendedPollingRejectsAnOldPublicationAndResumesTheSameLease`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift) +- [`AirAndSpaceRuntimeTests.newerDeactivationTombstonesActivationSuspendedDuringReset`](../../ThrowUI/Tests/AirAndSpaceRuntimeTests.swift) +- [`ThrowSessionTests.geographyKeepsItsLeaseWhilePhysicalPollingStopsAndResumes`](../../ThrowUI/Tests/ThrowSessionTests.swift) +- [`ThrowSessionTests.disablingEveryLayerSuspendsWithoutRetiringTheCoordinatorLease`](../../ThrowUI/Tests/ThrowSessionTests.swift) +- [`ThrowSessionTests.nonOperationalLaunchSuspendsWithoutRetiringTheCoordinatorLease`](../../ThrowUI/Tests/ThrowSessionTests.swift) +- [`ThrowSessionTests.stoppedDemandRejectsADelayedOlderActivationUnderTheSameLease`](../../ThrowUI/Tests/ThrowSessionTests.swift) +- [`ThrowRuntimeTests.twoControllerScenesOwnAggregateForegroundPresence`](../../Throw/Tests/ThrowRuntimeTests.swift) +- [`AircraftPollingCoordinatorTests.replacementCancelsAndDrainsBeforeStartingTheNewSource`](../../ThrowCore/Tests/AircraftPollingCoordinatorTests.swift) + +This result is not an implementation proof. A change to demand scheduling, +context invalidation, lease renewal, direct synchronization, actor admission, +runtime tombstones, physical demand generations, or Core replacement invalidates +it. diff --git a/Throw/Specifications/ProjectionActivation/RaceReachability.cfg b/Throw/Specifications/ProjectionActivation/RaceReachability.cfg new file mode 100644 index 000000000..dc25a17e2 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/RaceReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- LeaseReplacementRace + +INVARIANTS + TypeOK + StaleTeardownCommandNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/RuntimeTombstoneReachability.cfg b/Throw/Specifications/ProjectionActivation/RuntimeTombstoneReachability.cfg new file mode 100644 index 000000000..22e600c99 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/RuntimeTombstoneReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- SingleSceneEvents + +INVARIANTS + TypeOK + NewerRuntimeTeardownNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/SameLeaseResumeReachability.cfg b/Throw/Specifications/ProjectionActivation/SameLeaseResumeReachability.cfg new file mode 100644 index 000000000..043644826 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/SameLeaseResumeReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 1 + OutputCount = 1 + Events <- PhysicalSuspensionEvents + +INVARIANTS + TypeOK + SameLeaseResumeNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/TwoSceneReachability.cfg b/Throw/Specifications/ProjectionActivation/TwoSceneReachability.cfg new file mode 100644 index 000000000..adc1d5bcb --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/TwoSceneReachability.cfg @@ -0,0 +1,13 @@ +SPECIFICATION Spec + +CONSTANTS + Implementation = "current" + SceneCount = 2 + OutputCount = 2 + Events <- TwoSceneEvents + +INVARIANTS + TypeOK + TwoForegroundScenesNotReached + +CHECK_DEADLOCK FALSE diff --git a/Throw/Specifications/ProjectionActivation/manifest.json b/Throw/Specifications/ProjectionActivation/manifest.json new file mode 100644 index 000000000..e5281ac92 --- /dev/null +++ b/Throw/Specifications/ProjectionActivation/manifest.json @@ -0,0 +1,187 @@ +{ + "source": "pluscal", + "module": "ProjectionActivation.tla", + "cases": [ + { + "name": "broken-identity-teardown", + "config": "BrokenIdentityTeardown.cfg", + "expect": "fail", + "outputContains": "Invariant NoStaleTeardownOfNewerLease is violated." + }, + { + "name": "pre-fix-stopped-lease", + "config": "PreFixStoppedLease.cfg", + "expect": "fail", + "outputContains": "Invariant CorrectAtQuiescence is violated." + }, + { + "name": "pre-fix-runtime-tombstone", + "config": "PreFixRuntimeTombstone.cfg", + "expect": "fail", + "outputContains": "Invariant PermitSafety is violated." + }, + { + "name": "pre-fix-context-retained-lease", + "config": "PreFixContextRetainedLease.cfg", + "expect": "fail", + "outputContains": "Invariant FreshContextAtQuiescence is violated." + }, + { + "name": "pre-fix-polling-retires-lease", + "config": "PreFixPollingRetiresLease.cfg", + "expect": "fail", + "outputContains": "Invariant CorrectAtQuiescence is violated." + }, + { + "name": "pre-fix-no-demand-tombstone", + "config": "PreFixNoDemandTombstone.cfg", + "expect": "fail", + "outputContains": "Invariant PermitSafety is violated." + }, + { + "name": "permit-gap-reachability", + "config": "PermitGapReachability.cfg", + "expect": "fail", + "outputContains": "Invariant PermitGapNotReached is violated." + }, + { + "name": "permit-teardown-reachability", + "config": "PermitTeardownReachability.cfg", + "expect": "fail", + "outputContains": "Invariant PendingPermitTeardownNotReached is violated." + }, + { + "name": "direct-lease-sync-reachability", + "config": "DirectLeaseSyncReachability.cfg", + "expect": "fail", + "outputContains": "Invariant DirectLeaseSyncNotReached is violated." + }, + { + "name": "stale-teardown-reachability", + "config": "RaceReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleTeardownCommandNotReached is violated." + }, + { + "name": "physical-poller-reachability", + "config": "PhysicalReachability.cfg", + "expect": "fail", + "outputContains": "Invariant PhysicalPollerNotReached is violated." + }, + { + "name": "runtime-tombstone-reachability", + "config": "RuntimeTombstoneReachability.cfg", + "expect": "fail", + "outputContains": "Invariant NewerRuntimeTeardownNotReached is violated." + }, + { + "name": "physical-drain-reachability", + "config": "DrainReachability.cfg", + "expect": "fail", + "outputContains": "Invariant PhysicalDrainNotReached is violated." + }, + { + "name": "two-scene-reachability", + "config": "TwoSceneReachability.cfg", + "expect": "fail", + "outputContains": "Invariant TwoForegroundScenesNotReached is violated." + }, + { + "name": "quiet-reachability", + "config": "QuietReachability.cfg", + "expect": "fail", + "outputContains": "Invariant QuietBlockNotReached is violated." + }, + { + "name": "calibration-reachability", + "config": "CalibrationReachability.cfg", + "expect": "fail", + "outputContains": "Invariant CalibrationBlockNotReached is violated." + }, + { + "name": "context-gate-reachability", + "config": "ContextGateReachability.cfg", + "expect": "fail", + "outputContains": "Invariant ContextGateNotReached is violated." + }, + { + "name": "context-renewal-reachability", + "config": "ContextRenewalReachability.cfg", + "expect": "fail", + "outputContains": "Invariant ContextRenewalNotReached is violated." + }, + { + "name": "gated-successor-sync-reachability", + "config": "GatedSuccessorSyncReachability.cfg", + "expect": "fail", + "outputContains": "Invariant GatedSuccessorSyncNotReached is violated." + }, + { + "name": "old-activation-after-successor-reachability", + "config": "OldActivationAfterSuccessorReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OldActivationAfterSuccessorNotReached is violated." + }, + { + "name": "old-activation-while-gated-reachability", + "config": "OldActivationWhileGatedReachability.cfg", + "expect": "fail", + "outputContains": "Invariant OldActivationWhileGatedAfterSuccessorNotReached is violated." + }, + { + "name": "delayed-runtime-teardown-reachability", + "config": "DelayedRuntimeTeardownReachability.cfg", + "expect": "fail", + "outputContains": "Invariant DelayedRuntimeTeardownAfterSuccessorNotReached is violated." + }, + { + "name": "context-poller-reachability", + "config": "ContextPollerReachability.cfg", + "expect": "fail", + "outputContains": "Invariant ContextPollerNotReached is violated." + }, + { + "name": "physical-suspension-reachability", + "config": "PhysicalSuspensionReachability.cfg", + "expect": "fail", + "outputContains": "Invariant PhysicalSuspensionNotReached is violated." + }, + { + "name": "same-lease-resume-reachability", + "config": "SameLeaseResumeReachability.cfg", + "expect": "fail", + "outputContains": "Invariant SameLeaseResumeNotReached is violated." + }, + { + "name": "demand-overtake-reachability", + "config": "DemandOvertakeReachability.cfg", + "expect": "fail", + "outputContains": "Invariant DemandOvertakeNotReached is violated." + }, + { + "name": "current-race", + "config": "CurrentRace.cfg", + "expect": "pass" + }, + { + "name": "current-two-scenes", + "config": "CurrentTwoScenes.cfg", + "expect": "pass" + }, + { + "name": "current-context-renewal", + "config": "CurrentContextRenewal.cfg", + "expect": "pass" + }, + { + "name": "current-physical-suspension", + "config": "CurrentPhysicalSuspension.cfg", + "expect": "pass" + }, + { + "name": "current-context-and-suspension", + "config": "CurrentContextAndSuspension.cfg", + "expect": "pass" + } + ] +} diff --git a/Throw/Specifications/ProjectionContextTransition/BrokenContext.cfg b/Throw/Specifications/ProjectionContextTransition/BrokenContext.cfg new file mode 100644 index 000000000..81f7a0f8b --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/BrokenContext.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "brokenContext" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT NoInvalidatedContextCommit diff --git a/Throw/Specifications/ProjectionContextTransition/BrokenEarlyInvalidationFinish.cfg b/Throw/Specifications/ProjectionContextTransition/BrokenEarlyInvalidationFinish.cfg new file mode 100644 index 000000000..356d45f7b --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/BrokenEarlyInvalidationFinish.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "brokenEarlyInvalidationFinish" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT InvalidationGateHoldsUntilCleanupAndLeaseSync diff --git a/Throw/Specifications/ProjectionContextTransition/BrokenFreshness.cfg b/Throw/Specifications/ProjectionContextTransition/BrokenFreshness.cfg new file mode 100644 index 000000000..2bcf00c50 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/BrokenFreshness.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "brokenFreshness" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT NoStaleInputAcceptance diff --git a/Throw/Specifications/ProjectionContextTransition/BrokenPair.cfg b/Throw/Specifications/ProjectionContextTransition/BrokenPair.cfg new file mode 100644 index 000000000..07a149247 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/BrokenPair.cfg @@ -0,0 +1,9 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "brokenPair" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT ExactVisiblePair +INVARIANT NoMismatchedCommit diff --git a/Throw/Specifications/ProjectionContextTransition/BrokenWriter.cfg b/Throw/Specifications/ProjectionContextTransition/BrokenWriter.cfg new file mode 100644 index 000000000..b4447faf8 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/BrokenWriter.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "brokenWriter" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT NoWriterDuringFadeIn diff --git a/Throw/Specifications/ProjectionContextTransition/CurrentLarger.cfg b/Throw/Specifications/ProjectionContextTransition/CurrentLarger.cfg new file mode 100644 index 000000000..80f958a1f --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/CurrentLarger.cfg @@ -0,0 +1,15 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 2 + MaxRevision = 2 + MaxLease = 3 +INVARIANT TypeOK +INVARIANT StagingShape +INVARIANT OperationalVisibleIdentity +INVARIANT ExactVisiblePair +INVARIANT NoInvalidatedContextCommit +INVARIANT NoMismatchedCommit +INVARIANT NoStaleInputAcceptance +INVARIANT NoWriterDuringFadeIn +INVARIANT InvalidationGateHoldsUntilCleanupAndLeaseSync diff --git a/Throw/Specifications/ProjectionContextTransition/CurrentSmall.cfg b/Throw/Specifications/ProjectionContextTransition/CurrentSmall.cfg new file mode 100644 index 000000000..36a9c75f5 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/CurrentSmall.cfg @@ -0,0 +1,15 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT StagingShape +INVARIANT OperationalVisibleIdentity +INVARIANT ExactVisiblePair +INVARIANT NoInvalidatedContextCommit +INVARIANT NoMismatchedCommit +INVARIANT NoStaleInputAcceptance +INVARIANT NoWriterDuringFadeIn +INVARIANT InvalidationGateHoldsUntilCleanupAndLeaseSync diff --git a/Throw/Specifications/ProjectionContextTransition/ProjectionContextTransition.tla b/Throw/Specifications/ProjectionContextTransition/ProjectionContextTransition.tla new file mode 100644 index 000000000..746a55506 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/ProjectionContextTransition.tla @@ -0,0 +1,876 @@ +---- MODULE ProjectionContextTransition ---- +EXTENDS Integers + +CONSTANTS Implementation, MaxContext, MaxRevision, MaxLease + +Implementations == { + "current", + "brokenContext", + "brokenPair", + "brokenFreshness", + "brokenWriter", + "brokenEarlyInvalidationFinish" +} +ASSUME Implementation \in Implementations +ASSUME MaxContext \in Nat \ {0} +ASSUME MaxRevision \in Nat \ {0} +ASSUME MaxLease \in Nat \ {0, 1} +ASSUME MaxLease > MaxContext + +NoValue == -1 +\* NoLease means that no operational writer or marks use a lease. A cleared +\* Swift placeholder can retain old lease metadata without operational content. +NoLease == 0 +Contexts == 0..MaxContext +Revisions == 0..MaxRevision +Leases == 1..MaxLease +StagePhases == { + "none", + "preparing", + "prepared", + "reporting", + "reported", + "fadingOut", + "committing", + "fadingIn" +} +InvalidationPhases == { + "idle", + "contextRevoked", + "leaseRenewed", + "runtimeDrained", + "cleanupComplete", + "coordinatorConfigured", + "stateRead", + "leaseSynchronized" +} +CompletionKinds == {"worker", "report", "fade"} + +NextLease(lease) == lease + 1 + +RevokedCompletions(phase) == + IF phase = "preparing" THEN {"worker"} + ELSE IF phase = "reporting" THEN {"report"} + ELSE IF phase \in {"fadingOut", "committing", "fadingIn"} THEN {"fade"} + ELSE {} + +VARIABLES + context, + inputRevision, + coordinatorLease, + sessionLease, + invalidating, + invalidationPhase, + oldRuntimeDrained, + cleanupComplete, + leaseSynchronized, + staleCompletions, + stagePhase, + stageContext, + stageLease, + stageSemanticRevision, + stageProjectedRevision, + bufferedRevision, + visibleContext, + visibleLease, + visibleSemanticRevision, + visibleProjectedRevision, + invalidatedCommit, + mismatchedCommit, + staleInputAccepted, + writerDuringFadeIn, + reachedPrepared, + reachedBlackCommit, + reachedBufferedRevision, + reachedInvalidationDuringPreparation, + reachedInvalidationDuringFade, + reachedWorkerInputRejection, + reachedReportInputRejection, + reachedContextRevoke, + reachedLeaseRenewal, + reachedRuntimeDrain, + reachedCleanup, + reachedCoordinatorConfigure, + reachedStateRead, + reachedLeaseSync, + reachedStaleWorker, + reachedStaleReport, + reachedStaleFade + +identityState == <> + +gateState == << + invalidating, + invalidationPhase, + oldRuntimeDrained, + cleanupComplete, + leaseSynchronized, + staleCompletions +>> + +stageState == << + stagePhase, + stageContext, + stageLease, + stageSemanticRevision, + stageProjectedRevision, + bufferedRevision +>> + +visibleState == << + visibleContext, + visibleLease, + visibleSemanticRevision, + visibleProjectedRevision +>> + +violationState == << + invalidatedCommit, + mismatchedCommit, + staleInputAccepted, + writerDuringFadeIn +>> + +transitionReachState == << + reachedPrepared, + reachedBlackCommit, + reachedBufferedRevision, + reachedInvalidationDuringPreparation, + reachedInvalidationDuringFade, + reachedWorkerInputRejection, + reachedReportInputRejection +>> + +invalidationReachState == << + reachedContextRevoke, + reachedLeaseRenewal, + reachedRuntimeDrain, + reachedCleanup, + reachedCoordinatorConfigure, + reachedStateRead, + reachedLeaseSync +>> + +staleReachState == <> + +vars == << + identityState, + gateState, + stageState, + visibleState, + violationState, + transitionReachState, + invalidationReachState, + staleReachState +>> + +Init == + /\ context = 0 + /\ inputRevision = 0 + /\ coordinatorLease = 1 + /\ sessionLease = 1 + /\ invalidating = FALSE + /\ invalidationPhase = "idle" + /\ oldRuntimeDrained = TRUE + /\ cleanupComplete = TRUE + /\ leaseSynchronized = TRUE + /\ staleCompletions = {} + /\ stagePhase = "none" + /\ stageContext = NoValue + /\ stageLease = NoLease + /\ stageSemanticRevision = NoValue + /\ stageProjectedRevision = NoValue + /\ bufferedRevision = NoValue + /\ visibleContext = 0 + /\ visibleLease = 1 + /\ visibleSemanticRevision = 0 + /\ visibleProjectedRevision = 0 + /\ invalidatedCommit = FALSE + /\ mismatchedCommit = FALSE + /\ staleInputAccepted = FALSE + /\ writerDuringFadeIn = FALSE + /\ reachedPrepared = FALSE + /\ reachedBlackCommit = FALSE + /\ reachedBufferedRevision = FALSE + /\ reachedInvalidationDuringPreparation = FALSE + /\ reachedInvalidationDuringFade = FALSE + /\ reachedWorkerInputRejection = FALSE + /\ reachedReportInputRejection = FALSE + /\ reachedContextRevoke = FALSE + /\ reachedLeaseRenewal = FALSE + /\ reachedRuntimeDrain = FALSE + /\ reachedCleanup = FALSE + /\ reachedCoordinatorConfigure = FALSE + /\ reachedStateRead = FALSE + /\ reachedLeaseSync = FALSE + /\ reachedStaleWorker = FALSE + /\ reachedStaleReport = FALSE + /\ reachedStaleFade = FALSE + +StartPreparation == + /\ ~invalidating + /\ invalidationPhase = "idle" + /\ sessionLease = coordinatorLease + /\ stagePhase = "none" + /\ stagePhase' = "preparing" + /\ stageContext' = context + /\ stageLease' = sessionLease + /\ stageSemanticRevision' = inputRevision + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +CompleteWorker == + /\ stagePhase = "preparing" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = sessionLease + /\ stageSemanticRevision = inputRevision \/ Implementation = "brokenFreshness" + /\ stagePhase' = "prepared" + /\ stageProjectedRevision' = stageSemanticRevision + /\ staleInputAccepted' = ( + staleInputAccepted \/ stageSemanticRevision # inputRevision + ) + /\ reachedPrepared' = TRUE + /\ UNCHANGED << + identityState, gateState, visibleState, + invalidatedCommit, mismatchedCommit, writerDuringFadeIn, + stageContext, stageLease, stageSemanticRevision, bufferedRevision, + reachedBlackCommit, reachedBufferedRevision, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedWorkerInputRejection, reachedReportInputRejection, + invalidationReachState, staleReachState + >> + +RejectSupersededWorkerOutput == + /\ Implementation # "brokenFreshness" + /\ stagePhase = "preparing" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = sessionLease + /\ stageSemanticRevision # inputRevision + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ reachedWorkerInputRejection' = TRUE + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + reachedPrepared, reachedBlackCommit, reachedBufferedRevision, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedReportInputRejection, invalidationReachState, staleReachState + >> + +CompleteStaleWorker == + /\ Implementation = "brokenContext" + /\ stagePhase = "preparing" + /\ invalidating \/ stageContext # context \/ stageLease # sessionLease + /\ stagePhase' = "prepared" + /\ stageProjectedRevision' = stageSemanticRevision + /\ reachedPrepared' = TRUE + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + stageContext, stageLease, stageSemanticRevision, bufferedRevision, + reachedBlackCommit, reachedBufferedRevision, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedWorkerInputRejection, reachedReportInputRejection, + invalidationReachState, staleReachState + >> + +BeginPreparedReport == + /\ stagePhase = "prepared" + /\ stagePhase' = "reporting" + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, bufferedRevision, transitionReachState, + invalidationReachState, staleReachState + >> + +AcceptPreparedReport == + /\ stagePhase = "reporting" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = sessionLease + /\ stageSemanticRevision = inputRevision \/ Implementation = "brokenFreshness" + /\ stagePhase' = "reported" + /\ staleInputAccepted' = ( + staleInputAccepted \/ stageSemanticRevision # inputRevision + ) + /\ UNCHANGED << + identityState, gateState, visibleState, + invalidatedCommit, mismatchedCommit, writerDuringFadeIn, + stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, bufferedRevision, transitionReachState, + invalidationReachState, staleReachState + >> + +RejectSupersededPreparedReport == + /\ Implementation # "brokenFreshness" + /\ stagePhase = "reporting" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = sessionLease + /\ stageSemanticRevision # inputRevision + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ reachedReportInputRejection' = TRUE + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + reachedPrepared, reachedBlackCommit, reachedBufferedRevision, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedWorkerInputRejection, invalidationReachState, staleReachState + >> + +AcceptStalePreparedReport == + /\ Implementation = "brokenContext" + /\ stagePhase = "reporting" + /\ invalidating \/ stageContext # context \/ stageLease # sessionLease + /\ stagePhase' = "reported" + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, bufferedRevision, transitionReachState, + invalidationReachState, staleReachState + >> + +RejectStalePreparedReport == + /\ Implementation # "brokenContext" + /\ stagePhase = "reporting" + /\ invalidating \/ stageContext # context \/ stageLease # sessionLease + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +BeginFadeOut == + /\ stagePhase = "reported" + /\ stagePhase' = "fadingOut" + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, bufferedRevision, transitionReachState, + invalidationReachState, staleReachState + >> + +BeginCoordinatorCommit == + /\ stagePhase = "fadingOut" + /\ stagePhase' = "committing" + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, bufferedRevision, transitionReachState, + invalidationReachState, staleReachState + >> + +UpdateTargetInput == + /\ ~invalidating + /\ inputRevision < MaxRevision + /\ inputRevision' = inputRevision + 1 + /\ bufferedRevision' = + IF stagePhase \in {"fadingOut", "committing", "fadingIn"} + THEN inputRevision + 1 + ELSE bufferedRevision + /\ reachedBufferedRevision' = ( + reachedBufferedRevision \/ + stagePhase \in {"fadingOut", "committing", "fadingIn"} + ) + /\ UNCHANGED << + context, coordinatorLease, sessionLease, gateState, + stagePhase, stageContext, stageLease, stageSemanticRevision, + stageProjectedRevision, visibleState, violationState, + reachedPrepared, reachedBlackCommit, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedWorkerInputRejection, reachedReportInputRejection, + invalidationReachState, staleReachState + >> + +InvalidateProjectionContext == + /\ ~invalidating + /\ invalidationPhase = "idle" + /\ context < MaxContext + /\ context' = context + 1 + /\ inputRevision' = 0 + /\ sessionLease' = NoLease + /\ invalidating' = TRUE + /\ invalidationPhase' = "contextRevoked" + /\ oldRuntimeDrained' = FALSE + /\ cleanupComplete' = FALSE + /\ leaseSynchronized' = FALSE + /\ staleCompletions' = staleCompletions \cup ( + IF Implementation = "brokenContext" + THEN {} + ELSE RevokedCompletions(stagePhase) + ) + /\ IF Implementation = "brokenContext" + THEN UNCHANGED stageState + ELSE ( + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + ) + /\ reachedInvalidationDuringPreparation' = ( + reachedInvalidationDuringPreparation \/ + stagePhase \in {"preparing", "prepared", "reporting", "reported"} + ) + /\ reachedInvalidationDuringFade' = ( + reachedInvalidationDuringFade \/ + stagePhase \in {"fadingOut", "committing", "fadingIn"} + ) + /\ reachedContextRevoke' = TRUE + /\ UNCHANGED << + coordinatorLease, visibleState, violationState, + reachedPrepared, reachedBlackCommit, reachedBufferedRevision, + reachedWorkerInputRejection, reachedReportInputRejection, + reachedLeaseRenewal, reachedRuntimeDrain, reachedCleanup, + reachedCoordinatorConfigure, reachedStateRead, reachedLeaseSync, + staleReachState + >> + +RenewExactActivationLease == + /\ invalidating + /\ invalidationPhase = "contextRevoked" + /\ coordinatorLease < MaxLease + /\ coordinatorLease' = NextLease(coordinatorLease) + /\ invalidationPhase' = "leaseRenewed" + /\ reachedLeaseRenewal' = TRUE + /\ UNCHANGED << + context, inputRevision, sessionLease, invalidating, + oldRuntimeDrained, cleanupComplete, leaseSynchronized, + staleCompletions, stageState, visibleState, violationState, + transitionReachState, reachedContextRevoke, reachedRuntimeDrain, + reachedCleanup, reachedCoordinatorConfigure, reachedStateRead, + reachedLeaseSync, staleReachState + >> + +DrainOldRuntime == + /\ invalidating + /\ invalidationPhase = "leaseRenewed" + /\ invalidationPhase' = "runtimeDrained" + /\ oldRuntimeDrained' = TRUE + /\ reachedRuntimeDrain' = TRUE + /\ UNCHANGED << + identityState, invalidating, cleanupComplete, leaseSynchronized, + staleCompletions, stageState, visibleState, violationState, + transitionReachState, reachedContextRevoke, reachedLeaseRenewal, + reachedCleanup, reachedCoordinatorConfigure, reachedStateRead, + reachedLeaseSync, staleReachState + >> + +CompleteObserverOrSourceCleanup == + /\ invalidating + /\ invalidationPhase = "runtimeDrained" + /\ invalidationPhase' = "cleanupComplete" + /\ cleanupComplete' = TRUE + /\ visibleContext' = context + /\ visibleLease' = NoLease + /\ visibleSemanticRevision' = inputRevision + /\ visibleProjectedRevision' = inputRevision + /\ reachedCleanup' = TRUE + /\ UNCHANGED << + identityState, invalidating, oldRuntimeDrained, leaseSynchronized, + staleCompletions, stageState, violationState, transitionReachState, + reachedContextRevoke, reachedLeaseRenewal, reachedRuntimeDrain, + reachedCoordinatorConfigure, reachedStateRead, reachedLeaseSync, + staleReachState + >> + +ConfigureCoordinator == + /\ invalidating + /\ invalidationPhase = "cleanupComplete" + /\ invalidationPhase' = "coordinatorConfigured" + /\ reachedCoordinatorConfigure' = TRUE + /\ UNCHANGED << + identityState, invalidating, oldRuntimeDrained, cleanupComplete, + leaseSynchronized, staleCompletions, stageState, visibleState, + violationState, transitionReachState, reachedContextRevoke, + reachedLeaseRenewal, reachedRuntimeDrain, reachedCleanup, + reachedStateRead, reachedLeaseSync, staleReachState + >> + +ReadCoordinatorState == + /\ invalidating + /\ invalidationPhase = "coordinatorConfigured" + /\ invalidationPhase' = "stateRead" + /\ reachedStateRead' = TRUE + /\ UNCHANGED << + identityState, invalidating, oldRuntimeDrained, cleanupComplete, + leaseSynchronized, staleCompletions, stageState, visibleState, + violationState, transitionReachState, reachedContextRevoke, + reachedLeaseRenewal, reachedRuntimeDrain, reachedCleanup, + reachedCoordinatorConfigure, reachedLeaseSync, staleReachState + >> + +SynchronizeAuthoritativeLease == + /\ invalidating + /\ invalidationPhase = "stateRead" + /\ sessionLease' = coordinatorLease + /\ invalidationPhase' = "leaseSynchronized" + /\ leaseSynchronized' = TRUE + /\ reachedLeaseSync' = TRUE + /\ UNCHANGED << + context, inputRevision, coordinatorLease, invalidating, + oldRuntimeDrained, cleanupComplete, staleCompletions, + stageState, visibleState, violationState, transitionReachState, + reachedContextRevoke, reachedLeaseRenewal, reachedRuntimeDrain, + reachedCleanup, reachedCoordinatorConfigure, reachedStateRead, + staleReachState + >> + +FinishInvalidation == + /\ invalidating + /\ invalidationPhase = "leaseSynchronized" + /\ oldRuntimeDrained + /\ cleanupComplete + /\ leaseSynchronized + /\ sessionLease = coordinatorLease + /\ invalidating' = FALSE + /\ invalidationPhase' = "idle" + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ UNCHANGED << + identityState, oldRuntimeDrained, cleanupComplete, + leaseSynchronized, staleCompletions, visibleState, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +FinishInvalidationEarly == + /\ Implementation = "brokenEarlyInvalidationFinish" + /\ invalidating + /\ invalidationPhase = "runtimeDrained" + /\ invalidating' = FALSE + /\ invalidationPhase' = "idle" + /\ UNCHANGED << + identityState, oldRuntimeDrained, cleanupComplete, + leaseSynchronized, staleCompletions, stageState, + visibleState, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +CompleteRevokedWorker == + /\ "worker" \in staleCompletions + /\ staleCompletions' = staleCompletions \ {"worker"} + /\ reachedStaleWorker' = TRUE + /\ UNCHANGED << + identityState, invalidating, invalidationPhase, + oldRuntimeDrained, cleanupComplete, leaseSynchronized, + stageState, visibleState, violationState, transitionReachState, + invalidationReachState, reachedStaleReport, reachedStaleFade + >> + +CompleteRevokedReport == + /\ "report" \in staleCompletions + /\ staleCompletions' = staleCompletions \ {"report"} + /\ reachedStaleReport' = TRUE + /\ UNCHANGED << + identityState, invalidating, invalidationPhase, + oldRuntimeDrained, cleanupComplete, leaseSynchronized, + stageState, visibleState, violationState, transitionReachState, + invalidationReachState, reachedStaleWorker, reachedStaleFade + >> + +CompleteRevokedFade == + /\ "fade" \in staleCompletions + /\ staleCompletions' = staleCompletions \ {"fade"} + /\ reachedStaleFade' = TRUE + /\ UNCHANGED << + identityState, invalidating, invalidationPhase, + oldRuntimeDrained, cleanupComplete, leaseSynchronized, + stageState, visibleState, violationState, transitionReachState, + invalidationReachState, reachedStaleWorker, reachedStaleReport + >> + +CommitCurrentPreparedPair == + /\ Implementation # "brokenPair" + /\ stagePhase = "committing" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = coordinatorLease + /\ stageLease = sessionLease + /\ stagePhase' = "fadingIn" + /\ visibleContext' = stageContext + /\ visibleLease' = stageLease + /\ visibleSemanticRevision' = stageSemanticRevision + /\ visibleProjectedRevision' = stageProjectedRevision + /\ reachedBlackCommit' = TRUE + /\ UNCHANGED << + identityState, gateState, stageContext, stageLease, + stageSemanticRevision, stageProjectedRevision, bufferedRevision, + violationState, reachedPrepared, reachedBufferedRevision, + reachedInvalidationDuringPreparation, reachedInvalidationDuringFade, + reachedWorkerInputRejection, reachedReportInputRejection, + invalidationReachState, staleReachState + >> + +CommitBrokenPair == + /\ Implementation = "brokenPair" + /\ stagePhase = "committing" + /\ ~invalidating + /\ stageContext = context + /\ stageLease = coordinatorLease + /\ stageLease = sessionLease + /\ stagePhase' = "fadingIn" + /\ visibleContext' = stageContext + /\ visibleLease' = stageLease + /\ visibleSemanticRevision' = inputRevision + /\ visibleProjectedRevision' = stageProjectedRevision + /\ mismatchedCommit' = ( + mismatchedCommit \/ inputRevision # stageProjectedRevision + ) + /\ reachedBlackCommit' = TRUE + /\ UNCHANGED << + identityState, gateState, stageContext, stageLease, + stageSemanticRevision, stageProjectedRevision, bufferedRevision, + invalidatedCommit, staleInputAccepted, writerDuringFadeIn, reachedPrepared, + reachedBufferedRevision, reachedInvalidationDuringPreparation, + reachedInvalidationDuringFade, reachedWorkerInputRejection, + reachedReportInputRejection, invalidationReachState, staleReachState + >> + +CommitBrokenContext == + /\ Implementation = "brokenContext" + /\ stagePhase = "committing" + /\ invalidating \/ + stageContext # context \/ + stageLease # coordinatorLease \/ + stageLease # sessionLease + /\ stagePhase' = "fadingIn" + /\ visibleContext' = stageContext + /\ visibleLease' = stageLease + /\ visibleSemanticRevision' = stageSemanticRevision + /\ visibleProjectedRevision' = stageProjectedRevision + /\ invalidatedCommit' = TRUE + /\ reachedBlackCommit' = TRUE + /\ UNCHANGED << + identityState, gateState, stageContext, stageLease, + stageSemanticRevision, stageProjectedRevision, bufferedRevision, + mismatchedCommit, staleInputAccepted, writerDuringFadeIn, reachedPrepared, + reachedBufferedRevision, reachedInvalidationDuringPreparation, + reachedInvalidationDuringFade, reachedWorkerInputRejection, + reachedReportInputRejection, invalidationReachState, staleReachState + >> + +RejectInvalidatedCommit == + /\ Implementation # "brokenContext" + /\ stagePhase = "committing" + /\ invalidating \/ + stageContext # context \/ + stageLease # coordinatorLease \/ + stageLease # sessionLease + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ UNCHANGED << + identityState, gateState, visibleState, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +PublishDuringFadeIn == + /\ Implementation = "brokenWriter" + /\ stagePhase = "fadingIn" + /\ bufferedRevision # NoValue + /\ bufferedRevision' = NoValue + /\ visibleContext' = context + /\ visibleLease' = coordinatorLease + /\ visibleSemanticRevision' = bufferedRevision + /\ visibleProjectedRevision' = bufferedRevision + /\ writerDuringFadeIn' = TRUE + /\ UNCHANGED << + identityState, gateState, stagePhase, stageContext, stageLease, + stageSemanticRevision, stageProjectedRevision, + invalidatedCommit, mismatchedCommit, staleInputAccepted, + transitionReachState, + invalidationReachState, staleReachState + >> + +CompleteFadeIn == + /\ stagePhase = "fadingIn" + /\ stagePhase' = "none" + /\ stageContext' = NoValue + /\ stageLease' = NoLease + /\ stageSemanticRevision' = NoValue + /\ stageProjectedRevision' = NoValue + /\ bufferedRevision' = NoValue + /\ visibleSemanticRevision' = + IF bufferedRevision = NoValue + THEN visibleSemanticRevision + ELSE bufferedRevision + /\ visibleProjectedRevision' = + IF bufferedRevision = NoValue + THEN visibleProjectedRevision + ELSE bufferedRevision + /\ UNCHANGED << + identityState, gateState, visibleContext, visibleLease, violationState, + transitionReachState, invalidationReachState, staleReachState + >> + +Next == + \/ StartPreparation + \/ CompleteWorker + \/ RejectSupersededWorkerOutput + \/ CompleteStaleWorker + \/ BeginPreparedReport + \/ AcceptPreparedReport + \/ RejectSupersededPreparedReport + \/ AcceptStalePreparedReport + \/ RejectStalePreparedReport + \/ BeginFadeOut + \/ BeginCoordinatorCommit + \/ UpdateTargetInput + \/ InvalidateProjectionContext + \/ RenewExactActivationLease + \/ DrainOldRuntime + \/ CompleteObserverOrSourceCleanup + \/ ConfigureCoordinator + \/ ReadCoordinatorState + \/ SynchronizeAuthoritativeLease + \/ FinishInvalidation + \/ FinishInvalidationEarly + \/ CompleteRevokedWorker + \/ CompleteRevokedReport + \/ CompleteRevokedFade + \/ CommitCurrentPreparedPair + \/ CommitBrokenPair + \/ CommitBrokenContext + \/ RejectInvalidatedCommit + \/ PublishDuringFadeIn + \/ CompleteFadeIn + +Spec == Init /\ [][Next]_vars + +TypeOK == + /\ context \in Contexts + /\ inputRevision \in Revisions + /\ coordinatorLease \in Leases + /\ sessionLease \in Leases \cup {NoLease} + /\ invalidating \in BOOLEAN + /\ invalidationPhase \in InvalidationPhases + /\ oldRuntimeDrained \in BOOLEAN + /\ cleanupComplete \in BOOLEAN + /\ leaseSynchronized \in BOOLEAN + /\ staleCompletions \in SUBSET CompletionKinds + /\ stagePhase \in StagePhases + /\ stageContext \in Contexts \cup {NoValue} + /\ stageLease \in Leases \cup {NoLease} + /\ stageSemanticRevision \in Revisions \cup {NoValue} + /\ stageProjectedRevision \in Revisions \cup {NoValue} + /\ bufferedRevision \in Revisions \cup {NoValue} + /\ visibleContext \in Contexts + /\ visibleLease \in Leases \cup {NoLease} + /\ visibleSemanticRevision \in Revisions + /\ visibleProjectedRevision \in Revisions + /\ invalidatedCommit \in BOOLEAN + /\ mismatchedCommit \in BOOLEAN + /\ staleInputAccepted \in BOOLEAN + /\ writerDuringFadeIn \in BOOLEAN + /\ reachedPrepared \in BOOLEAN + /\ reachedBlackCommit \in BOOLEAN + /\ reachedBufferedRevision \in BOOLEAN + /\ reachedInvalidationDuringPreparation \in BOOLEAN + /\ reachedInvalidationDuringFade \in BOOLEAN + /\ reachedWorkerInputRejection \in BOOLEAN + /\ reachedReportInputRejection \in BOOLEAN + /\ reachedContextRevoke \in BOOLEAN + /\ reachedLeaseRenewal \in BOOLEAN + /\ reachedRuntimeDrain \in BOOLEAN + /\ reachedCleanup \in BOOLEAN + /\ reachedCoordinatorConfigure \in BOOLEAN + /\ reachedStateRead \in BOOLEAN + /\ reachedLeaseSync \in BOOLEAN + /\ reachedStaleWorker \in BOOLEAN + /\ reachedStaleReport \in BOOLEAN + /\ reachedStaleFade \in BOOLEAN + +StagingShape == + /\ (stagePhase = "none") => + /\ stageContext = NoValue + /\ stageLease = NoLease + /\ stageSemanticRevision = NoValue + /\ stageProjectedRevision = NoValue + /\ (stagePhase = "preparing") => + /\ stageContext \in Contexts + /\ stageLease \in Leases + /\ stageSemanticRevision \in Revisions + /\ stageProjectedRevision = NoValue + /\ (stagePhase \in StagePhases \ {"none", "preparing"}) => + /\ stageContext \in Contexts + /\ stageLease \in Leases + /\ stageSemanticRevision \in Revisions + /\ stageProjectedRevision = stageSemanticRevision + +OperationalVisibleIdentity == + ~invalidating /\ visibleLease # NoLease => + /\ visibleContext = context + /\ visibleLease = coordinatorLease + /\ sessionLease = coordinatorLease + +ExactVisiblePair == + visibleSemanticRevision = visibleProjectedRevision + +NoInvalidatedContextCommit == + ~invalidatedCommit + +NoMismatchedCommit == + ~mismatchedCommit + +NoStaleInputAcceptance == + ~staleInputAccepted + +NoWriterDuringFadeIn == + ~writerDuringFadeIn + +InvalidationGateHoldsUntilCleanupAndLeaseSync == + /\ (invalidationPhase # "idle") => invalidating + /\ ~invalidating => + /\ invalidationPhase = "idle" + /\ oldRuntimeDrained + /\ cleanupComplete + /\ leaseSynchronized + /\ sessionLease = coordinatorLease + +RequiredPathsNotAllReached == + ~(reachedPrepared /\ + reachedBlackCommit /\ + reachedBufferedRevision /\ + reachedInvalidationDuringPreparation /\ + reachedInvalidationDuringFade /\ + reachedContextRevoke /\ + reachedLeaseRenewal /\ + reachedRuntimeDrain /\ + reachedCleanup /\ + reachedCoordinatorConfigure /\ + reachedStateRead /\ + reachedLeaseSync) + +StaleWorkerCompletionNotReached == ~reachedStaleWorker +StaleReportCompletionNotReached == ~reachedStaleReport +StaleFadeCompletionNotReached == ~reachedStaleFade +WorkerInputRejectionNotReached == ~reachedWorkerInputRejection +ReportInputRejectionNotReached == ~reachedReportInputRejection + +==== diff --git a/Throw/Specifications/ProjectionContextTransition/README.md b/Throw/Specifications/ProjectionContextTransition/README.md new file mode 100644 index 000000000..d0826ab56 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/README.md @@ -0,0 +1,207 @@ +# Projection context transition + +This model checks one question: + +> Can Throw reject obsolete projection work and keep the invalidation gate closed until cleanup and authoritative lease synchronization finish? + +The model represents the projection-context code at production commit +`60c25401`. + +The model uses raw TLA+. Each action represents one suspension boundary or one +main-actor mutation. This form lets TLC insert invalidation and late completion +between independent worker, coordinator, cleanup, and animation actions. + +Run the model from the repository root: + +```sh +./tla-check ProjectionContextTransition +``` + +## Source correspondence + +| Model state or action | Production authority | +| --- | --- | +| `context` | `projectionContextGeneration` in [`ThrowSession+Aircraft.swift`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L484-L510) | +| `inputRevision` | `projectionInputRevision` and the pending semantic frame in [`ThrowSession+Aircraft.swift`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L351-L405) | +| `coordinatorLease` | The lease from [`ProjectionExperienceCoordinator`](../../ThrowUI/Sources/Model/ProjectionExperienceCoordinator.swift#L408-L452) | +| `sessionLease` | The local `airAndSpaceActivation` lease, cleared during revoke and synchronized after configuration | +| `invalidationPhase` | The ordered invalidation work in the aircraft and observer publication paths | +| `staleCompletions` | Worker, report, or fade work that resumes after its context was revoked | +| `stagePhase` and staged fields | `ProjectionPresentationStaging` and `PreparedProjectionPresentation` | +| visible fields | The closed `ProjectionPresentationState` and `VisibleProjection` | +| worker completion and rejection actions | The two sides of `projectedOutput(...)` and its current-request guard in [`ThrowSession+Aircraft.swift`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L379-L405) | +| prepared report completion and rejection actions | The two sides of `reportRuntimePrepared(_:)` and its later guards in [`ThrowSession+Aircraft.swift`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L406-L424) | +| fade and coordinator commit actions | The awaits in [`transitionExperience(from:to:)`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L295-L420) | +| `CommitCurrentPreparedPair` | The single black-frame exchange in [`publishPreparedProjection`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L438-L457) | +| `UpdateTargetInput` | A newer runtime update buffered by `ProjectionPresentationStaging` | +| `InvalidateProjectionContext` | Context, stage, local lease, and renderer revoke in [`prepareProjectionPreferencePublication`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L484-L510) | +| `RenewExactActivationLease` | The exact active renewal await in [`finishProjectionPreferenceInvalidation`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L513-L533) | +| `DrainOldRuntime` | Old-lease runtime deactivation in [`finishProjectionPreferenceInvalidation`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L534-L540) | +| `CompleteObserverOrSourceCleanup` | Observer worker reset or aircraft [`discardOldFrame`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L1065-L1097) | +| `ConfigureCoordinator`, `ReadCoordinatorState`, and `SynchronizeAuthoritativeLease` | Three awaits and the local synchronization in [`configureExperienceCoordinator`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L232-L240) | +| `FinishInvalidation` | The identity checks and gate removal in [`completeProjectionPreferenceInvalidation`](../../ThrowUI/Sources/Model/ThrowSession+Aircraft.swift#L550-L562) | +| `CompleteFadeIn` | Buffered publication in [`finishProjectionPresentationTransition`](../../ThrowUI/Sources/Model/ThrowSession+Experiences.swift#L460-L473) | + +Every modeled production `await` ends one action. Another enabled action can +run before the next action begins. Task cancellation does not complete pending +work in the model. + +Worker revision equality represents `output.request == currentRequest`. Report +revision equality represents the later prepared-value identity check. + +The observer path clears visible content during context revoke. Its cleanup +phase completes after the worker reset. The aircraft path completes cleanup +after old-frame removal and worker reset. + +## Invalidation phases + +The model keeps these phases separate and ordered: + +1. Revoke the context, staged presentation, local lease, and renderer. +2. Renew the exact active coordinator lease. +3. Drain the runtime that used the old lease. +4. Finish observer or aircraft cleanup. +5. Configure the coordinator. +6. Read the coordinator state. +7. Synchronize the local lease with the authoritative lease. +8. Remove the invalidation gate. + +A stale worker, report, or fade can complete between any two phases. Each stale +completion consumes only its pending token. It cannot restore staged or visible +content. + +## Operational lease abstraction + +`NoLease` means that no operational writer or marks use a lease. It does not +mean that every Swift value contains empty lease metadata. + +[`VisibleProjection.cleared`](../../ThrowUI/Sources/Projection/VisibleProjection.swift#L356-L368) +keeps the old lease metadata in a placeholder. That placeholder has no marks +and gives no runtime permission. The model represents this state as +`visibleLease = NoLease`. + +## Properties + +- `TypeOK` checks every variable domain. +- `StagingShape` checks the closed staging lifecycle. +- `OperationalVisibleIdentity` binds operational visible content to both lease authorities. +- `ExactVisiblePair` requires matching semantic and projected revisions. +- `NoInvalidatedContextCommit` rejects a black commit from an invalid context. +- `NoMismatchedCommit` rejects the old mixed-revision commit design. +- `NoStaleInputAcceptance` rejects worker or report output for an older input revision. +- `NoWriterDuringFadeIn` keeps runtime writers out of the fade-in phase. +- `InvalidationGateHoldsUntilCleanupAndLeaseSync` keeps the gate closed through every required phase. +- `RequiredPathsNotAllReached` is the main anti-vacuity probe. +- Five focused probes check the three late completions and both freshness rejections. + +The main reachability trace visits preparation, a black commit, a buffered +revision, both invalidation sites, and all seven gated work phases. + +## Bounds and assumptions + +| Configuration | Context changes | Later input revisions | Activation leases | +| --- | ---: | ---: | ---: | +| `CurrentSmall.cfg` | 1 | 1 | 2 | +| `CurrentLarger.cfg` | 2 | 2 | 3 | + +The model uses these assumptions: + +- One target projection can be staged at a time. +- Source and observer changes use the same invalidation gate. +- The checked renewal replaces one exact active Air and Space lease. +- Lease generations increase within each finite configuration. +- The coordinator lease and the session lease remain separate authorities. +- Environment actions can resume stale worker, report, and fade work in any invalidation phase. +- Cleanup success has one atomic completion action after its internal awaits. + +The model checks safety only. It has no fairness rule. It does not require a +timer, provider, worker, coordinator, or animation callback to return. + +The model excludes invalidation without an active lease. It also excludes +retired and superseded renewal results. Coordinator tests cover those results. +The model excludes cleanup failure details after the source selects its +clear-state recovery. + +Other exclusions include projection math, pixels, route enrichment, polling, +playlist policy, preference persistence, and application scene admission. + +## Negative controls + +`BrokenContext.cfg` keeps staged work during invalidation. It also omits the +current context and lease checks. TLC finds this nine-state trace: + +1. The worker prepares context 0 with lease 1. +2. The presentation reaches the coordinator commit boundary. +3. Invalidation advances the context to 1 and clears the session lease. +4. The old prepared frame commits at black. +5. `NoInvalidatedContextCommit` fails. + +`BrokenPair.cfg` reproduces the old mixed-frame shortcut. A later semantic +revision arrives while the coordinator commit suspends. The black exchange +combines semantic revision 1 with projected revision 0. `ExactVisiblePair` +fails after nine states. + +`BrokenFreshness.cfg` accepts a worker result for an older input. Input revision +1 replaces revision 0 while its worker is suspended. The old result resumes +and becomes prepared. `NoStaleInputAcceptance` fails after four states. + +`BrokenWriter.cfg` publishes a buffered update during fade-in. TLC reaches the +write after ten states. `NoWriterDuringFadeIn` then fails. + +`BrokenEarlyInvalidationFinish.cfg` removes the gate after runtime drain. It +skips cleanup, coordinator configuration, state read, and lease synchronization. +`InvalidationGateHoldsUntilCleanupAndLeaseSync` fails after five states. + +These controls use the current state variables and the same named safety +properties. The `BrokenContext`, `BrokenPair`, and `BrokenWriter` controls +preserve their prior source event timelines. + +## Reachability controls + +`Reachability.cfg` reaches every main lifecycle flag after 19 states. The +three stale controls also produce direct traces: + +- A worker resumes after context revoke in four states. +- A prepared report resumes after context revoke in six states. +- A fade resumes after context revoke in eight states. +- A superseded worker result reaches its rejection in four states. +- A superseded prepared report reaches its rejection in six states. + +Each late-completion trace leaves staging empty and keeps the invalidation gate +active. Each freshness trace leaves staging empty during normal operation. + +## Result + +**Verified for these model bounds and assumptions.** TLC exhausted both current +configurations with no error. + +| Configuration | Result | Generated | Distinct | Depth | +| --- | ---: | ---: | ---: | ---: | +| `CurrentSmall.cfg` | pass | 5,556 | 3,491 | 41 | +| `CurrentLarger.cfg` | pass | 102,734 | 45,101 | 57 | +| `BrokenContext.cfg` | expected failure | 162 | 96 | 9 | +| `BrokenPair.cfg` | expected failure | 104 | 81 | 9 | +| `BrokenFreshness.cfg` | expected failure | 15 | 13 | 4 | +| `BrokenWriter.cfg` | expected failure | 140 | 109 | 10 | +| `BrokenEarlyInvalidationFinish.cfg` | expected failure | 35 | 28 | 5 | +| `Reachability.cfg` | expected failure | 1,554 | 1,000 | 19 | +| `StaleWorkerReachability.cfg` | expected failure | 17 | 15 | 4 | +| `StaleReportReachability.cfg` | expected failure | 41 | 32 | 6 | +| `StaleFadeReachability.cfg` | expected failure | 80 | 63 | 8 | +| `WorkerInputRejectionReachability.cfg` | expected failure | 14 | 13 | 4 | +| `ReportInputRejectionReachability.cfg` | expected failure | 38 | 30 | 6 | + +The check used tla2tools 1.7.4 with TLC2 2.19 at revision `5a47802`. It used +Temurin Java 21.0.8+9. The pinned `tla2tools.jar` SHA-256 is +`936a262061c914694dfd669a543be24573c45d5aa0ff20a8b96b23d01e050e88`. + +Deterministic Swift guards: + +- [`ThrowSessionExperiencesTests.blackCommitKeepsPreparedIdentityAndRevisionAheadOfBufferedInput`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift#L8) +- [`ThrowSessionExperiencesTests.contextInvalidationWhileRuntimePreparationSuspendsRejectsThePreparedOutput`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift#L201) +- [`ThrowSessionExperiencesTests.contextInvalidationDuringFadeRevokesTheBlackCommit`](../../ThrowUI/Tests/ThrowSession+ExperiencesTests.swift#L276) +- [`ThrowSessionAircraftTests.samePermitSourceReconfigurationRenewsLeaseAndPhysicalPoller`](../../ThrowUI/Tests/ThrowSession+AircraftTests.swift#L130) +- [`ProjectionExperienceCoordinatorTests.exactActiveRenewalRetiresAndRemintsInOneCoordinatorTurn`](../../ThrowUI/Tests/ProjectionExperienceCoordinatorTests.swift#L91) + +A change to the listed production boundaries invalidates this result. Check +the mapping and rerun TLC after such a change. diff --git a/Throw/Specifications/ProjectionContextTransition/Reachability.cfg b/Throw/Specifications/ProjectionContextTransition/Reachability.cfg new file mode 100644 index 000000000..a9decff62 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/Reachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 2 + MaxRevision = 1 + MaxLease = 3 +INVARIANT TypeOK +INVARIANT RequiredPathsNotAllReached diff --git a/Throw/Specifications/ProjectionContextTransition/ReportInputRejectionReachability.cfg b/Throw/Specifications/ProjectionContextTransition/ReportInputRejectionReachability.cfg new file mode 100644 index 000000000..4ecf174fb --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/ReportInputRejectionReachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT ReportInputRejectionNotReached diff --git a/Throw/Specifications/ProjectionContextTransition/StaleFadeReachability.cfg b/Throw/Specifications/ProjectionContextTransition/StaleFadeReachability.cfg new file mode 100644 index 000000000..851a64bcd --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/StaleFadeReachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT StaleFadeCompletionNotReached diff --git a/Throw/Specifications/ProjectionContextTransition/StaleReportReachability.cfg b/Throw/Specifications/ProjectionContextTransition/StaleReportReachability.cfg new file mode 100644 index 000000000..f819e44d0 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/StaleReportReachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT StaleReportCompletionNotReached diff --git a/Throw/Specifications/ProjectionContextTransition/StaleWorkerReachability.cfg b/Throw/Specifications/ProjectionContextTransition/StaleWorkerReachability.cfg new file mode 100644 index 000000000..ff6e00b04 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/StaleWorkerReachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT StaleWorkerCompletionNotReached diff --git a/Throw/Specifications/ProjectionContextTransition/WorkerInputRejectionReachability.cfg b/Throw/Specifications/ProjectionContextTransition/WorkerInputRejectionReachability.cfg new file mode 100644 index 000000000..4208a1615 --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/WorkerInputRejectionReachability.cfg @@ -0,0 +1,8 @@ +SPECIFICATION Spec +CONSTANTS + Implementation = "current" + MaxContext = 1 + MaxRevision = 1 + MaxLease = 2 +INVARIANT TypeOK +INVARIANT WorkerInputRejectionNotReached diff --git a/Throw/Specifications/ProjectionContextTransition/manifest.json b/Throw/Specifications/ProjectionContextTransition/manifest.json new file mode 100644 index 000000000..0ed419a5f --- /dev/null +++ b/Throw/Specifications/ProjectionContextTransition/manifest.json @@ -0,0 +1,82 @@ +{ + "source": "tla", + "module": "ProjectionContextTransition.tla", + "cases": [ + { + "name": "broken-context", + "config": "BrokenContext.cfg", + "expect": "fail", + "outputContains": "Invariant NoInvalidatedContextCommit is violated." + }, + { + "name": "broken-pair", + "config": "BrokenPair.cfg", + "expect": "fail", + "outputContains": "Invariant ExactVisiblePair is violated." + }, + { + "name": "broken-freshness", + "config": "BrokenFreshness.cfg", + "expect": "fail", + "outputContains": "Invariant NoStaleInputAcceptance is violated." + }, + { + "name": "broken-writer", + "config": "BrokenWriter.cfg", + "expect": "fail", + "outputContains": "Invariant NoWriterDuringFadeIn is violated." + }, + { + "name": "broken-early-invalidation-finish", + "config": "BrokenEarlyInvalidationFinish.cfg", + "expect": "fail", + "outputContains": "Invariant InvalidationGateHoldsUntilCleanupAndLeaseSync is violated." + }, + { + "name": "reachability", + "config": "Reachability.cfg", + "expect": "fail", + "outputContains": "Invariant RequiredPathsNotAllReached is violated." + }, + { + "name": "stale-worker-reachability", + "config": "StaleWorkerReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleWorkerCompletionNotReached is violated." + }, + { + "name": "stale-report-reachability", + "config": "StaleReportReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleReportCompletionNotReached is violated." + }, + { + "name": "stale-fade-reachability", + "config": "StaleFadeReachability.cfg", + "expect": "fail", + "outputContains": "Invariant StaleFadeCompletionNotReached is violated." + }, + { + "name": "worker-input-rejection-reachability", + "config": "WorkerInputRejectionReachability.cfg", + "expect": "fail", + "outputContains": "Invariant WorkerInputRejectionNotReached is violated." + }, + { + "name": "report-input-rejection-reachability", + "config": "ReportInputRejectionReachability.cfg", + "expect": "fail", + "outputContains": "Invariant ReportInputRejectionNotReached is violated." + }, + { + "name": "current-small", + "config": "CurrentSmall.cfg", + "expect": "pass" + }, + { + "name": "current-larger", + "config": "CurrentLarger.cfg", + "expect": "pass" + } + ] +} diff --git a/Throw/Specifications/README.md b/Throw/Specifications/README.md new file mode 100644 index 000000000..228d248e0 --- /dev/null +++ b/Throw/Specifications/README.md @@ -0,0 +1,27 @@ +# Throw protocol specifications + +These bounded models check narrow concurrency claims in Throw. Each concern contains an editable +model in TLA+ or PlusCal. It also contains TLC configurations, a manifest, and a source map. + +| Concern | Model source | Claim | +| --- | --- | --- | +| [Preference transactions](PreferenceTransactions/README.md) | PlusCal | Preference mutations publish only durable state and cannot revive an obsolete observer context. | +| [Projection activation](ProjectionActivation/README.md) | PlusCal | Projection leases, permits, and physical polling stay aligned during activation races. | +| [Projection context transition](ProjectionContextTransition/README.md) | TLA+ | A transition cannot publish an invalid context, mismatched visible pair, or competing writer. | +| [Background preference persistence](BackgroundPreferencePersistence/README.md) | TLA+ | A background flush closes producer admission and releases each retained UIKit lease safely. | +| [Polling publication](PollingPublication/README.md) | PlusCal | Token-bound polling updates remain ordered during replacement, recovery, frame construction, and deactivation. | + +List all concerns from the repository root: + +```sh +./tla-check --list +``` + +Run one listed concern. For example: + +```sh +./tla-check PreferenceTransactions +``` + +Use [`Where/Specifications/README.md`](../../Where/Specifications/README.md) for the shared +authoring and checker rules. diff --git a/Throw/TODOs.md b/Throw/TODOs.md new file mode 100644 index 000000000..7707de877 --- /dev/null +++ b/Throw/TODOs.md @@ -0,0 +1,64 @@ +# Throw todos + +The durable backlog for the Throw feature group. Item format and placement are +owned by the root [`TODOs.md`](../TODOs.md). + +# Open issues + +## P0s (Must do) + +- test(Throw) [needs-design]: Complete the physical output acceptance matrix + before beta release — the app declares USB-C/HDMI as its guaranteed path and + AirPlay plus an explicit on-device fallback (`README.md:31-34`), but automated + scene tests cannot prove real iPhone/iPad adapters, Apple TV behavior, + independent versus mirrored output, 16:9/16:10/4:3 resolution changes, + disconnect/reconnect, mirror-fallback exit and accessibility, idle-timer + restoration, a Wi-Fi-only iPad's manual-location path, local-network denial, + or quiet/timed-wake behavior. Exercise ADS-B Exchange with invalid, revoked, + quota-exhausted, and replaced dedicated credentials; compare usage estimates + with observed request counts; verify source switching never mixes frames; + profile dense 240-NM traffic at a stable 30 Hz; and finish with an overnight + powered soak. Verify that Geography stays subtle and readable at 5, 50, and + 240 NM on each projector aspect ratio. Record the devices, OS builds, + projectors, provider states, and results in the release checklist. (human + 2026-08-24) +- test(Throw) [needs-design]: Revalidate and physically exercise the iOS 27 + external-scene accessory against the GM SDK — the availability-gated adapter + constructs, retains, unregisters, and migrates `UISceneAccessory` registration + between controller scenes (`Throw/Sources/ThrowApp.swift:36-119`), while the + feature contract explicitly treats final-SDK validation as a release gate + (`AGENTS.md:28-29`). Confirm API compatibility, controller-window + closure/recreation, and that the iOS 26 manifest and iOS 27 accessory paths do + not create duplicate output scenes or polling demand. (human 2026-08-24) +- test(ThrowCore) [needs-design]: Revalidate the externally controlled aircraft + provider contracts immediately before each beta release — implementation was + checked on 2026-08-24 against the current ADS-B Exchange Personal/RapidAPI + price, 10,000-request allowance, host, radius path, header contract, and + acceptable-use terms, plus the current adsb.lol v2 endpoint. Confirm the live + listings still match the request builders + (`ThrowCore/Sources/ADSBExchangeRapidAPISource.swift:6-7,38-51,105-128`, + `ThrowCore/Sources/AdsBLolSource.swift:65-80`) and localized usage copy + (`ThrowUI/Sources/Resources/Localizable.xcstrings:1731-1771`), then run the + disclosed five-NM credential test with a dedicated personal key. (human + 2026-08-24) +- docs(Throw) [needs-design]: Obtain ADS-B Exchange authorization and replace + the personal-client credential architecture before any public distribution — + v1 accepts a user-owned personal/non-commercial RapidAPI key in device-only + Keychain (`README.md:36-42`), which is suitable only for the stated personal + beta. A public release needs written provider approval, the applicable + commercial terms, and a provider-approved backend or other architecture that + does not distribute a shared credential in the client. (human 2026-08-24) + +## P1s (Should do) + +- feat(Throw) [needs-design]: Implement the planned Transit View — the catalog + reserves Network and Vehicles layers + (`ThrowCore/Sources/ProjectionExperience.swift:67-74`), and Views presents + Transit as unavailable + (`ThrowUI/Sources/Settings/ProjectionViewsSettingsView.swift:38-42,95-102`). + Select a live provider, define GTFS or equivalent route geometry, and add + setup and credentials if required. Give its Map runtime independent polling. + Keep vehicles brighter than dim network and Geography context. (human + 2026-08-26) + +# Completed issues diff --git a/Throw/Throw/AGENTS.md b/Throw/Throw/AGENTS.md new file mode 100644 index 000000000..15d8a5754 --- /dev/null +++ b/Throw/Throw/AGENTS.md @@ -0,0 +1,39 @@ +# Throw app – Module Shape + +The Throw app is the iOS composition and scene shell; see +[`README.md`](README.md). Read the root [`AGENTS.md`](../../AGENTS.md) and group +[`../AGENTS.md`](../AGENTS.md) first. + +## Scope and invariants + +- Depend directly on ThrowUI only, reaching ThrowCore through that product. + Keep domain, provider, persistence, and presentation behavior out of this + target. +- Expose the shared session through the runtime protocol. Compose concrete + controller and projection roots at each scene without `AnyView` erasure. +- Construct `ThrowRuntime` only in `ThrowRuntime.swift`. `AppDelegate` obtains + that one live runtime. Scene delegates use the platform handoff and never create a fallback. +- Start cold launch from the process runtime. Never attach launch ownership to a + scene or SwiftUI task. +- Compose controller and projection surfaces through the exhaustive session + launch state. Render no configured surface before the ready case. +- Track foreground controller scenes by their typed session identities in the + process runtime. Derive session foreground presence from the nonempty set; + external-display scenes never join it. +- Retain the final-background preference flush under one injected UIKit + execution lease. End the lease on completion or expiration. Cancel the + retained flush task when the lease expires or a controller returns foreground. +- Host every projected output with ThrowUI's `ThrowProjectionRootView`; keep its + UIKit window and hosting view opaque black. +- Derive size and aspect changes from the connected `UIWindowScene`, never + `UIScreen.main`. +- Retain the iOS 27 `UISceneAccessory` and its registration for as long as the + controller scene is eligible. Revalidate this adapter against the GM SDK. +- Keep required-reason API declarations in `PrivacyInfo.xcprivacy`. Preserve + the built-app manifest guard when changing app resources or preferences. +- Restore the process's prior idle-timer state when the final output leaves. + +## Testing + +Run `./test ThrowTests`. App tests prove the delegate and every scene handoff +use the same runtime and that duplicate output IDs do not duplicate demand. diff --git a/Throw/Throw/README.md b/Throw/Throw/README.md new file mode 100644 index 000000000..d6ae47c07 --- /dev/null +++ b/Throw/Throw/README.md @@ -0,0 +1,59 @@ +# Throw (app target) + +This target is the thin iOS shell for Throw. `ThrowRuntime.swift` is the only +runtime construction owner. `AppDelegate` obtains exactly one live runtime. +SwiftUI controller windows and UIKit-created external-display windows all +receive that runtime's shared ThrowUI session. + +Each scene composes its concrete ThrowUI root from that session. Runtime +handoff does not erase roots to `AnyView` or construct feature services. +The runtime starts one retained launch task when it creates the session. +Scene insertion, removal, and task cancellation cannot cancel this launch. + +The session exposes one exhaustive launch state. Controller roots show loading, +onboarding, ready, or failed content from that state. Projection roots stay +black until the state contains loaded setup and credential status. + +## Scene paths + +- The iOS 26 scene manifest declares the controller and noninteractive + external-display roles. +- Each controller root binds to its exact `UIWindowScene` and forwards that + scene's foreground, background, and disconnect notifications to the shared + runtime under a typed persistent identity. +- iOS 27 controller hosting registers a retained external scene accessory, + availability-gated at runtime. +- `ExternalDisplaySceneDelegate` hosts `ThrowProjectionRootView` in a black + `UIHostingController`. The root creates `ProjectionSurface` only after launch. +- Preview and explicit full-screen mirroring fallback remain ThrowUI flows and + use the same surface. + +Output demand is reference-counted by stable IDs so connecting another window +does not create another poller. The runtime owns idle-timer restoration and the +set of foreground controller-scene identities. The session is foreground while +that set is nonempty. External-display scenes provide output demand but never +stand in for a foreground controller. + +When the final controller enters the background, the runtime starts a retained +preference flush under a UIKit execution lease. The runtime ends the lease when +the flush completes. Expiration cancels the retained task and ends the lease. +A returning controller also cancels the old task and lease. The next final +background transition starts a new flush generation. + +## Resources + +The app ships its app icon, generated software attribution report, and privacy +manifest. The manifest declares the required-reason use of `UserDefaults` for +Throw's app-only preferences. `PrivacyManifestTests` verifies the declaration +in the built app bundle. Provider attribution is separate user-facing copy in +ThrowUI. The ADS-B Exchange key is never an app resource or preference. + +This target links ThrowUI directly and reaches ThrowCore transitively. Keeping +the composition shell off a second direct ThrowCore product avoids embedding a +duplicate static copy across the ThrowUI boundary. + +## Build and test + +Run the shared `Throw` scheme after `./ide --no-open`. App-shell tests are in +`ThrowTests`; domain and UI tests live with their modules. Revalidate the iOS +27 scene-accessory calls against the GM SDK before a release build. diff --git a/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 000000000..7ec228fbe Binary files /dev/null and b/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..cefcc878e --- /dev/null +++ b/Throw/Throw/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Throw/Throw/Resources/Assets.xcassets/Contents.json b/Throw/Throw/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Throw/Throw/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Throw/Throw/Resources/InfoPlist.xcstrings b/Throw/Throw/Resources/InfoPlist.xcstrings new file mode 100644 index 000000000..ff583a0d9 --- /dev/null +++ b/Throw/Throw/Resources/InfoPlist.xcstrings @@ -0,0 +1,54 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "The name shown for the Throw app on the Home Screen and in system interfaces.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Throw" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Throw" + } + } + } + }, + "NSLocalNetworkUsageDescription" : { + "comment" : "Privacy description shown before Throw accesses a user-selected readsb receiver on the local network.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Throw connects to a readsb receiver you choose on your local network." + } + } + } + }, + "NSLocationWhenInUseUsageDescription" : { + "comment" : "Privacy description shown before Throw requests the observer location used to project aircraft.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Throw uses your location to place aircraft correctly around you." + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Throw/Throw/Resources/PrivacyInfo.xcprivacy b/Throw/Throw/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..79bc9e285 --- /dev/null +++ b/Throw/Throw/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,17 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/Throw/Throw/Resources/attribution.json b/Throw/Throw/Resources/attribution.json new file mode 100644 index 000000000..0c1920c1e --- /dev/null +++ b/Throw/Throw/Resources/attribution.json @@ -0,0 +1,124 @@ +{ + "credits": [ + { + "name": "SFSafeSymbols", + "kind": "library", + "version": "7.0.0", + "homepageURL": "https://github.com/SFSafeSymbols/SFSafeSymbols", + "license": { + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2021 SFSafeSymbols\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "AccessibilitySnapshot", + "kind": "developmentTool", + "version": "0.12.0", + "homepageURL": "https://github.com/cashapp/AccessibilitySnapshot", + "license": { + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + } + }, + { + "name": "capture-ios", + "kind": "developmentTool", + "version": "0.23.11", + "homepageURL": "https://github.com/bitdriftlabs/capture-ios", + "license": { + "name": "Other", + "text": "# PolyForm Shield License 1.0.0\n\n\n\n## Acceptance\n\nIn order to get any license under these terms, you must agree\nto them as both strict obligations and conditions to all\nyour licenses.\n\n## Copyright License\n\nThe licensor grants you a copyright license for the\nsoftware to do everything you might do with the software\nthat would otherwise infringe the licensor's copyright\nin it for any permitted purpose. However, you may\nonly distribute the software according to [Distribution\nLicense](#distribution-license) and make changes or new works\nbased on the software according to [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Distribution License\n\nThe licensor grants you an additional copyright license\nto distribute copies of the software. Your license\nto distribute covers distributing the software with\nchanges and new works permitted by [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Notices\n\nYou must ensure that anyone who gets a copy of any part of\nthe software from you also gets a copy of these terms or the\nURL for them above, as well as copies of any plain-text lines\nbeginning with `Required Notice:` that the licensor provided\nwith the software. For example:\n\n> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)\n\n## Changes and New Works License\n\nThe licensor grants you an additional copyright license to\nmake changes and new works based on the software for any\npermitted purpose.\n\n## Patent License\n\nThe licensor grants you a patent license for the software that\ncovers patent claims the licensor can license, or becomes able\nto license, that you would infringe by using the software.\n\n## Noncompete\n\nAny purpose is a permitted purpose, except for providing any\nproduct that competes with the software or any product the\nlicensor or any of its affiliates provides using the software.\n\n## Competition\n\nGoods and services compete even when they provide functionality\nthrough different kinds of interfaces or for different technical\nplatforms. Applications can compete with services, libraries\nwith plugins, frameworks with development tools, and so on,\neven if they're written in different programming languages\nor for different computer architectures. Goods and services\ncompete even when provided free of charge. If you market a\nproduct as a practical substitute for the software or another\nproduct, it definitely competes.\n\n## New Products\n\nIf you are using the software to provide a product that does\nnot compete, but the licensor or any of its affiliates brings\nyour product into competition by providing a new version of\nthe software or another product using the software, you may\ncontinue using versions of the software available under these\nterms beforehand to provide your competing product, but not\nany later versions.\n\n## Discontinued Products\n\nYou may begin using the software to compete with a product\nor service that the licensor or any of its affiliates has\nstopped providing, unless the licensor includes a plain-text\nline beginning with `Licensor Line of Business:` with the\nsoftware that mentions that line of business. For example:\n\n> Licensor Line of Business: YoyodyneCMS Content Management\nSystem (http://example.com/cms)\n\n## Sales of Business\n\nIf the licensor or any of its affiliates sells a line of\nbusiness developing the software or using the software\nto provide a product, the buyer can also enforce\n[Noncompete](#noncompete) for that product.\n\n## Fair Use\n\nYou may have \"fair use\" rights for the software under the\nlaw. These terms do not limit them.\n\n## No Other Rights\n\nThese terms do not allow you to sublicense or transfer any of\nyour licenses to anyone else, or prevent the licensor from\ngranting licenses to anyone else. These terms do not imply\nany other licenses.\n\n## Patent Defense\n\nIf you make any written claim that the software infringes or\ncontributes to infringement of any patent, your patent license\nfor the software granted under these terms ends immediately. If\nyour company makes such a claim, your patent license ends\nimmediately for work on behalf of your company.\n\n## Violations\n\nThe first time you are notified in writing that you have\nviolated any of these terms, or done anything with the software\nnot covered by your licenses, your licenses can nonetheless\ncontinue if you come into full compliance with these terms,\nand take practical steps to correct past violations, within\n32 days of receiving notice. Otherwise, all your licenses\nend immediately.\n\n## No Liability\n\n***As far as the law allows, the software comes as is, without\nany warranty or condition, and the licensor will not be liable\nto you for any damages arising out of these terms or the use\nor nature of the software, under any kind of legal claim.***\n\n## Definitions\n\nThe **licensor** is the individual or entity offering these\nterms, and the **software** is the software the licensor makes\navailable under these terms.\n\nA **product** can be a good or service, or a combination\nof them.\n\n**You** refers to the individual or entity agreeing to these\nterms.\n\n**Your company** is any legal entity, sole proprietorship,\nor other kind of organization that you work for, plus all\nits affiliates.\n\n**Affiliates** means the other organizations than an\norganization has control over, is under the control of, or is\nunder common control with.\n\n**Control** means ownership of substantially all the assets of\nan entity, or the power to direct its management and policies\nby vote, contract, or otherwise. Control can be direct or\nindirect.\n\n**Your licenses** are all the licenses granted to you for the\nsoftware under these terms.\n\n**Use** means anything you do with the software requiring one\nof your licenses.\n" + } + }, + { + "name": "swift-snapshot-testing", + "kind": "developmentTool", + "version": "1.19.3", + "homepageURL": "https://github.com/pointfreeco/swift-snapshot-testing", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Point-Free, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "ZIPFoundation", + "kind": "developmentTool", + "version": "0.9.20", + "homepageURL": "https://github.com/weichsel/ZIPFoundation", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "simple-english", + "kind": "developmentTool", + "version": "59bf6702197a", + "homepageURL": "https://github.com/AminBlg/SimpleEnglish", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 AminBlg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "swift-concurrency-pro", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swift-testing-pro", + "kind": "developmentTool", + "version": "2d6bba14a3c8", + "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftdata-pro", + "kind": "developmentTool", + "version": "922d989473a9", + "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftui-pro", + "kind": "developmentTool", + "version": "61b74001b64b", + "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "ShellCheck", + "kind": "developmentTool", + "version": "0.11.0", + "homepageURL": "https://github.com/koalaman/shellcheck", + "license": { + "name": "GNU General Public License v3.0", + "text": " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. \n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n Preamble\n\n The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users. We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors. You can apply it to\nyour programs, too.\n\n When we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights. Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received. You must make sure that they, too, receive\nor can get the source code. And you must show them these terms so they\nknow their rights.\n\n Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software. For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so. This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software. The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable. Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts. If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary. To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n The precise terms and conditions for copying, distribution and\nmodification follow.\n\n TERMS AND CONDITIONS\n\n 0. Definitions.\n\n \"This License\" refers to version 3 of the GNU General Public License.\n\n \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n \"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n 1. Source Code.\n\n The \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\n A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n The Corresponding Source for a work in source code form is that\nsame work.\n\n 2. Basic Permissions.\n\n All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n Conveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n 4. Conveying Verbatim Copies.\n\n You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n 5. Conveying Modified Source Versions.\n\n You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n a) The work must carry prominent notices stating that you modified\n it, and giving a relevant date.\n\n b) The work must carry prominent notices stating that it is\n released under this License and any conditions added under section\n 7. This requirement modifies the requirement in section 4 to\n \"keep intact all notices\".\n\n c) You must license the entire work, as a whole, under this\n License to anyone who comes into possession of a copy. This\n License will therefore apply, along with any applicable section 7\n additional terms, to the whole of the work, and all its parts,\n regardless of how they are packaged. This License gives no\n permission to license the work in any other way, but it does not\n invalidate such permission if you have separately received it.\n\n d) If the work has interactive user interfaces, each must display\n Appropriate Legal Notices; however, if the Program has interactive\n interfaces that do not display Appropriate Legal Notices, your\n work need not make them do so.\n\n A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n 6. Conveying Non-Source Forms.\n\n You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n a) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by the\n Corresponding Source fixed on a durable physical medium\n customarily used for software interchange.\n\n b) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by a\n written offer, valid for at least three years and valid for as\n long as you offer spare parts or customer support for that product\n model, to give anyone who possesses the object code either (1) a\n copy of the Corresponding Source for all the software in the\n product that is covered by this License, on a durable physical\n medium customarily used for software interchange, for a price no\n more than your reasonable cost of physically performing this\n conveying of source, or (2) access to copy the\n Corresponding Source from a network server at no charge.\n\n c) Convey individual copies of the object code with a copy of the\n written offer to provide the Corresponding Source. This\n alternative is allowed only occasionally and noncommercially, and\n only if you received the object code with such an offer, in accord\n with subsection 6b.\n\n d) Convey the object code by offering access from a designated\n place (gratis or for a charge), and offer equivalent access to the\n Corresponding Source in the same way through the same place at no\n further charge. You need not require recipients to copy the\n Corresponding Source along with the object code. If the place to\n copy the object code is a network server, the Corresponding Source\n may be on a different server (operated by you or a third party)\n that supports equivalent copying facilities, provided you maintain\n clear directions next to the object code saying where to find the\n Corresponding Source. Regardless of what server hosts the\n Corresponding Source, you remain obligated to ensure that it is\n available for as long as needed to satisfy these requirements.\n\n e) Convey the object code using peer-to-peer transmission, provided\n you inform other peers where the object code and Corresponding\n Source of the work are being offered to the general public at no\n charge under subsection 6d.\n\n A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n 7. Additional Terms.\n\n \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n a) Disclaiming warranty or limiting liability differently from the\n terms of sections 15 and 16 of this License; or\n\n b) Requiring preservation of specified reasonable legal notices or\n author attributions in that material or in the Appropriate Legal\n Notices displayed by works containing it; or\n\n c) Prohibiting misrepresentation of the origin of that material, or\n requiring that modified versions of such material be marked in\n reasonable ways as different from the original version; or\n\n d) Limiting the use for publicity purposes of names of licensors or\n authors of the material; or\n\n e) Declining to grant rights under trademark law for use of some\n trade names, trademarks, or service marks; or\n\n f) Requiring indemnification of licensors and authors of that\n material by anyone who conveys the material (or modified versions of\n it) with contractual assumptions of liability to the recipient, for\n any liability that these contractual assumptions directly impose on\n those licensors and authors.\n\n All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n 8. Termination.\n\n You may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n 9. Acceptance Not Required for Having Copies.\n\n You are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n 10. Automatic Licensing of Downstream Recipients.\n\n Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\n An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n 11. Patents.\n\n A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\n A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n 12. No Surrender of Others' Freedom.\n\n If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n 13. Use with the GNU Affero General Public License.\n\n Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n 14. Revised Versions of this License.\n\n The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time. Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n Each version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n Later license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n 15. Disclaimer of Warranty.\n\n THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n 16. Limitation of Liability.\n\n IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n 17. Interpretation of Sections 15 and 16.\n\n If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n END OF TERMS AND CONDITIONS\n\n How to Apply These Terms to Your New Programs\n\n If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n To do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n \n Copyright (C) \n\n This program is free software: you can redistribute it and/or modify\n it under the terms of the GNU General Public License as published by\n the Free Software Foundation, either version 3 of the License, or\n (at your option) any later version.\n\n This program is distributed in the hope that it will be useful,\n but WITHOUT ANY WARRANTY; without even the implied warranty of\n MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n GNU General Public License for more details.\n\n You should have received a copy of the GNU General Public License\n along with this program. If not, see .\n\nAlso add information on how to contact you by electronic and paper mail.\n\n If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n Copyright (C) \n This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n This is free software, and you are welcome to redistribute it\n under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License. Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n.\n\n The GNU General Public License does not permit incorporating your program\ninto proprietary programs. If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library. If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License. But first, please read\n.\n" + } + }, + { + "name": "TLA+ Tools", + "kind": "developmentTool", + "version": "1.7.4", + "homepageURL": "https://github.com/tlaplus/tlaplus", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 Microsoft Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" + } + } + ] +} diff --git a/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift b/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift new file mode 100644 index 000000000..abb697e79 --- /dev/null +++ b/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift @@ -0,0 +1,93 @@ +import SwiftUI +import ThrowUI +import UIKit + +/// Hosts the shared projection surface on one noninteractive external scene. +@MainActor +final class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + private var output: ProjectionOutput? + private weak var runtime: (any ThrowApplicationRuntime)? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options _: UIScene.ConnectionOptions, + ) { + guard let windowScene = scene as? UIWindowScene else { return } + guard let runtime = Self.runtime(from: UIApplication.shared.delegate) else { + assertionFailure("Throw external scene connected without the process runtime") + connectBlackFallback(to: windowScene) + return + } + + let id = ProjectionOutputID(rawValue: "external:\(session.persistentIdentifier)") + let output = ProjectionOutput.externalDisplay(id) + let host = UIHostingController( + rootView: ThrowProjectionRootView( + session: runtime.session, + presentation: .externalDisplay, + ) + .throwBroadwayRoot(), + ) + host.view.backgroundColor = .black + host.view.isOpaque = true + + let window = UIWindow(windowScene: windowScene) + window.backgroundColor = .black + window.rootViewController = host + window.makeKeyAndVisible() + + self.window = window + connectProjectionOutput(output, runtime: runtime) { [weak host] style in + host?.overrideUserInterfaceStyle = style + } + } + + func sceneDidDisconnect(_: UIScene) { + disconnectProjectionOutput() + window = nil + } + + func windowScene( + _: UIWindowScene, + didUpdateEffectiveGeometry _: UIWindowScene.Geometry, + ) { + window?.setNeedsLayout() + window?.rootViewController?.view.setNeedsLayout() + } + + static func runtime( + from applicationDelegate: (any UIApplicationDelegate)?, + ) -> (any ThrowApplicationRuntime)? { + (applicationDelegate as? any ThrowRuntimeProviding)?.runtime + } + + func connectProjectionOutput( + _ output: ProjectionOutput, + runtime: any ThrowApplicationRuntime, + appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, + ) { + self.runtime = runtime + self.output = output + runtime.projectionOutputConnected(output, appearanceSink: appearanceSink) + } + + func disconnectProjectionOutput() { + guard let output, let runtime else { return } + runtime.projectionOutputDisconnected(output) + self.output = nil + self.runtime = nil + } + + private func connectBlackFallback(to windowScene: UIWindowScene) { + let controller = UIViewController() + controller.view.backgroundColor = .black + let window = UIWindow(windowScene: windowScene) + window.backgroundColor = .black + window.rootViewController = controller + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/Throw/Throw/Sources/ThrowApp.swift b/Throw/Throw/Sources/ThrowApp.swift new file mode 100644 index 000000000..af71b7f87 --- /dev/null +++ b/Throw/Throw/Sources/ThrowApp.swift @@ -0,0 +1,290 @@ +import SwiftUI +import ThrowUI +import UIKit + +@main +struct ThrowApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + RuntimeControllerView( + session: appDelegate.runtime.session, + outputDemandDidChange: appDelegate.runtime.sessionOutputDemandDidChange, + ) + .throwBroadwayRoot() + .background { + ControllerSceneBridge( + appearanceDidChange: appDelegate.runtime + .controllerAppearanceDidChange, + lifecycleDidChange: appDelegate.runtime + .controllerScene(_:didReceive:), + registerAccessory: appDelegate.registerExternalDisplayAccessory, + unregisterAccessory: appDelegate.unregisterExternalDisplayAccessory, + ) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + } + } +} + +/// Platform handoff from every UIKit-created scene to the process runtime. +@MainActor +protocol ThrowRuntimeProviding: UIApplicationDelegate { + var runtime: any ThrowApplicationRuntime { get } +} + +@MainActor +final class AppDelegate: NSObject, UIApplicationDelegate, ThrowRuntimeProviding { + let runtime: any ThrowApplicationRuntime + + // Type-erased because Swift does not permit iOS 27-only stored-property + // types in an app that still deploys to iOS 26. Both UIKit values are + // NSObject subclasses; retaining them keeps the accessory registered. + private var externalDisplayAccessory: AnyObject? + private var externalDisplayRegistration: AnyObject? + private weak var externalDisplayAccessoryOwner: UIViewController? + private var accessoryControllers: [ObjectIdentifier: WeakControllerReference] = [:] + + override init() { + runtime = ThrowRuntime.live() + super.init() + } + + init(runtime: any ThrowApplicationRuntime) { + self.runtime = runtime + super.init() + } + + func registerExternalDisplayAccessory(from controller: UIViewController) { + guard #available(iOS 27.0, *) else { return } + pruneAccessoryControllers() + accessoryControllers[ObjectIdentifier(controller)] = WeakControllerReference(controller) + + if externalDisplayAccessoryOwner == nil { + externalDisplayAccessory = nil + externalDisplayRegistration = nil + } + guard externalDisplayRegistration == nil else { return } + installExternalDisplayAccessory(on: controller) + } + + func unregisterExternalDisplayAccessory(from controller: UIViewController) { + guard #available(iOS 27.0, *) else { return } + accessoryControllers[ObjectIdentifier(controller)] = nil + guard externalDisplayAccessoryOwner === controller else { return } + + if let registration = externalDisplayRegistration as? UISceneAccessoryRegistration { + controller.unregisterSceneAccessory(registration) + } + externalDisplayAccessory = nil + externalDisplayRegistration = nil + externalDisplayAccessoryOwner = nil + + pruneAccessoryControllers() + if let replacement = accessoryControllers.values.lazy.compactMap(\.controller).first { + installExternalDisplayAccessory(on: replacement) + } + } + + @available(iOS 27.0, *) + private func installExternalDisplayAccessory(on controller: UIViewController) { + guard externalDisplayRegistration == nil else { return } + + let configuration = Self.externalDisplayConfiguration() + let accessory = UISceneAccessory.externalNonInteractive( + sceneConfiguration: configuration, + ) + let registration = controller.registerSceneAccessory(accessory) + registration.isEnabled = true + externalDisplayAccessory = accessory + externalDisplayRegistration = registration + externalDisplayAccessoryOwner = controller + } + + private func pruneAccessoryControllers() { + accessoryControllers = accessoryControllers.filter { $0.value.controller != nil } + } + + static func externalDisplayConfiguration() -> UISceneConfiguration { + let configuration = UISceneConfiguration( + name: "Throw External Display", + sessionRole: .windowExternalDisplayNonInteractive, + ) + configuration.delegateClass = ExternalDisplaySceneDelegate.self + return configuration + } +} + +private struct ControllerSceneBridge: UIViewControllerRepresentable { + let appearanceDidChange: @MainActor (UIUserInterfaceStyle) -> Void + let lifecycleDidChange: + @MainActor (ControllerSceneID, ControllerSceneLifecycleEvent) -> Void + let registerAccessory: @MainActor (UIViewController) -> Void + let unregisterAccessory: @MainActor (UIViewController) -> Void + + func makeUIViewController(context _: Context) -> ControllerSceneBridgeController { + ControllerSceneBridgeController( + appearanceDidChange: appearanceDidChange, + lifecycleDidChange: lifecycleDidChange, + registerAccessory: registerAccessory, + unregisterAccessory: unregisterAccessory, + ) + } + + func updateUIViewController( + _ controller: ControllerSceneBridgeController, + context _: Context, + ) { + controller.reportCurrentState() + } + + static func dismantleUIViewController( + _ controller: ControllerSceneBridgeController, + coordinator _: (), + ) { + controller.disconnect() + } +} + +private final class ControllerSceneBridgeController: UIViewController { + private let appearanceDidChange: @MainActor (UIUserInterfaceStyle) -> Void + private let lifecycleDidChange: + @MainActor (ControllerSceneID, ControllerSceneLifecycleEvent) -> Void + private let registerAccessory: @MainActor (UIViewController) -> Void + private let unregisterAccessory: @MainActor (UIViewController) -> Void + private weak var observedControllerScene: UIWindowScene? + private var observedControllerSceneID: ControllerSceneID? + + init( + appearanceDidChange: @escaping @MainActor (UIUserInterfaceStyle) -> Void, + lifecycleDidChange: @escaping @MainActor ( + ControllerSceneID, + ControllerSceneLifecycleEvent, + ) -> Void, + registerAccessory: @escaping @MainActor (UIViewController) -> Void, + unregisterAccessory: @escaping @MainActor (UIViewController) -> Void, + ) { + self.appearanceDidChange = appearanceDidChange + self.lifecycleDidChange = lifecycleDidChange + self.registerAccessory = registerAccessory + self.unregisterAccessory = unregisterAccessory + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { ( + controller: ControllerSceneBridgeController, + _: UITraitCollection, + ) in + controller.reportCurrentState() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + reportCurrentState() + registerAccessory(self) + } + + func reportCurrentState() { + observeControllerSceneIfNeeded() + appearanceDidChange(traitCollection.userInterfaceStyle) + } + + func disconnect() { + unregisterAccessory(self) + stopObservingControllerScene() + } + + private func observeControllerSceneIfNeeded() { + guard let windowScene = view.window?.windowScene else { return } + guard observedControllerScene !== windowScene else { return } + stopObservingControllerScene() + + let id = ControllerSceneID(session: windowScene.session) + observedControllerScene = windowScene + observedControllerSceneID = id + NotificationCenter.default.addObserver( + self, + selector: #selector(controllerSceneWillEnterForeground(_:)), + name: UIScene.willEnterForegroundNotification, + object: windowScene, + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(controllerSceneDidEnterBackground(_:)), + name: UIScene.didEnterBackgroundNotification, + object: windowScene, + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(controllerSceneDidDisconnect(_:)), + name: UIScene.didDisconnectNotification, + object: windowScene, + ) + lifecycleDidChange(id, Self.initialLifecycleEvent(for: windowScene.activationState)) + } + + private func stopObservingControllerScene() { + guard let id = observedControllerSceneID else { return } + NotificationCenter.default.removeObserver(self) + observedControllerScene = nil + observedControllerSceneID = nil + lifecycleDidChange(id, .didDisconnect) + } + + @objc private func controllerSceneWillEnterForeground(_ notification: Notification) { + report(.willEnterForeground, from: notification) + } + + @objc private func controllerSceneDidEnterBackground(_ notification: Notification) { + report(.didEnterBackground, from: notification) + } + + @objc private func controllerSceneDidDisconnect(_ notification: Notification) { + report(.didDisconnect, from: notification) + } + + private func report( + _ event: ControllerSceneLifecycleEvent, + from notification: Notification, + ) { + guard let notificationScene = notification.object as? UIWindowScene, + notificationScene === observedControllerScene, + let id = observedControllerSceneID + else { return } + lifecycleDidChange(id, event) + } + + private static func initialLifecycleEvent( + for activationState: UIScene.ActivationState, + ) -> ControllerSceneLifecycleEvent { + switch activationState { + case .foregroundActive, .foregroundInactive: + return .willEnterForeground + case .background, .unattached: + return .didEnterBackground + @unknown default: + assertionFailure("Unknown controller scene activation state") + return .didEnterBackground + } + } +} + +private final class WeakControllerReference { + weak var controller: UIViewController? + + init(_ controller: UIViewController) { + self.controller = controller + } +} diff --git a/Throw/Throw/Sources/ThrowRuntime.swift b/Throw/Throw/Sources/ThrowRuntime.swift new file mode 100644 index 000000000..f58ee0352 --- /dev/null +++ b/Throw/Throw/Sources/ThrowRuntime.swift @@ -0,0 +1,298 @@ +import SwiftUI +import ThrowUI +import UIKit + +@MainActor +protocol IdleTimerControlling: AnyObject { + var isIdleTimerDisabled: Bool { get set } +} + +extension UIApplication: IdleTimerControlling {} + +@MainActor +protocol BackgroundExecutionLease: AnyObject { + func end() +} + +@MainActor +protocol BackgroundExecutionLeasing: AnyObject { + func begin( + name: String, + expirationHandler: @escaping @MainActor @Sendable () -> Void, + ) -> any BackgroundExecutionLease +} + +@MainActor +final class UIApplicationBackgroundExecutionLeaser: BackgroundExecutionLeasing { + private let application: UIApplication + + init(application: UIApplication) { + self.application = application + } + + func begin( + name: String, + expirationHandler: @escaping @MainActor @Sendable () -> Void, + ) -> any BackgroundExecutionLease { + let identifier = application.beginBackgroundTask( + withName: name, + expirationHandler: expirationHandler, + ) + return UIApplicationBackgroundExecutionLease( + application: application, + identifier: identifier, + ) + } +} + +@MainActor +private final class UIApplicationBackgroundExecutionLease: BackgroundExecutionLease { + private let application: UIApplication + private var identifier: UIBackgroundTaskIdentifier? + + init(application: UIApplication, identifier: UIBackgroundTaskIdentifier) { + self.application = application + self.identifier = identifier + } + + func end() { + guard let identifier else { return } + self.identifier = nil + guard identifier != .invalid else { return } + application.endBackgroundTask(identifier) + } +} + +/// The stable UIKit identity for one controller scene in this process. +struct ControllerSceneID: Hashable { + let rawValue: String + + init(rawValue: String) { + precondition(rawValue.isEmpty == false, "A controller scene ID must not be empty") + self.rawValue = rawValue + } + + init(session: UISceneSession) { + self.init(rawValue: session.persistentIdentifier) + } +} + +/// A foreground-membership transition emitted by one controller scene. +enum ControllerSceneLifecycleEvent: Equatable { + case willEnterForeground + case didEnterBackground + case didDisconnect +} + +/// The class-bound handoff shared by the SwiftUI app and platform-created scenes. +@MainActor +protocol ThrowApplicationRuntime: AnyObject { + var session: ThrowSession { get } + + func projectionOutputConnected( + _ output: ProjectionOutput, + appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, + ) + func projectionOutputDisconnected(_ output: ProjectionOutput) + func controllerScene( + _ id: ControllerSceneID, + didReceive event: ControllerSceneLifecycleEvent, + ) + func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) + func sessionOutputDemandDidChange() +} + +/// Owns Throw's one UI session and the process-level output lifecycle. +@MainActor +final class ThrowRuntime: ThrowApplicationRuntime { + private struct BackgroundPreferenceFlushID: Equatable { + let rawValue: UInt64 + } + + private enum BackgroundPreferenceFlushState { + case idle(nextID: BackgroundPreferenceFlushID) + case active( + id: BackgroundPreferenceFlushID, + lease: any BackgroundExecutionLease, + task: Task, + ) + } + + let session: ThrowSession + + private let idleTimerController: any IdleTimerControlling + private let backgroundExecutionLeaser: any BackgroundExecutionLeasing + private var activeOutputs: [ProjectionOutputID: ProjectionOutput] = [:] + private var appearanceSinks: [ProjectionOutputID: @MainActor (UIUserInterfaceStyle) -> Void] = + [:] + private var previousIdleTimerState: Bool? + private var controllerAppearance: UIUserInterfaceStyle = .unspecified + private var foregroundControllerScenes: Set = [] + private var backgroundPreferenceFlush = BackgroundPreferenceFlushState.idle( + nextID: BackgroundPreferenceFlushID(rawValue: 0), + ) + + #if DEBUG + var backgroundPreferenceFlushTaskForTesting: Task? { + switch backgroundPreferenceFlush { + case let .active(_, _, task): + task + case .idle: + nil + } + } + #endif + + init( + session: ThrowSession, + idleTimerController: any IdleTimerControlling, + backgroundExecutionLeaser: any BackgroundExecutionLeasing, + ) { + self.session = session + self.idleTimerController = idleTimerController + self.backgroundExecutionLeaser = backgroundExecutionLeaser + session.controllerForegroundPresenceDidChange(false) + session.startLaunch() + } + + static func live() -> ThrowRuntime { + ThrowRuntime( + session: .live(), + idleTimerController: UIApplication.shared, + backgroundExecutionLeaser: UIApplicationBackgroundExecutionLeaser( + application: .shared, + ), + ) + } + + func projectionOutputConnected( + _ output: ProjectionOutput, + appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, + ) { + let id = Self.id(for: output) + appearanceSinks[id] = appearanceSink + appearanceSink(controllerAppearance) + + guard activeOutputs[id] == nil else { return } + activeOutputs[id] = output + session.projectionOutputConnected(output) + sessionOutputDemandDidChange() + } + + func projectionOutputDisconnected(_ output: ProjectionOutput) { + let id = Self.id(for: output) + appearanceSinks[id] = nil + guard let connectedOutput = activeOutputs.removeValue(forKey: id) else { return } + session.projectionOutputDisconnected(connectedOutput) + sessionOutputDemandDidChange() + } + + func controllerScene( + _ id: ControllerSceneID, + didReceive event: ControllerSceneLifecycleEvent, + ) { + let previouslyHadForegroundController = foregroundControllerScenes.isEmpty == false + switch event { + case .willEnterForeground: + foregroundControllerScenes.insert(id) + case .didEnterBackground, .didDisconnect: + foregroundControllerScenes.remove(id) + } + let hasForegroundController = foregroundControllerScenes.isEmpty == false + guard hasForegroundController != previouslyHadForegroundController else { return } + session.controllerForegroundPresenceDidChange(hasForegroundController) + if hasForegroundController { + cancelBackgroundPreferenceFlush() + } else { + startBackgroundPreferenceFlush() + } + } + + func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) { + guard controllerAppearance != style else { return } + controllerAppearance = style + for sink in appearanceSinks.values { + sink(style) + } + } + + func sessionOutputDemandDidChange() { + reconcileIdleTimer(hasOutputDemand: session.hasProjectionOutputDemand) + } + + private static func id(for output: ProjectionOutput) -> ProjectionOutputID { + switch output { + case let .externalDisplay(id), let .fullScreen(id), let .preview(id), + let .calibration(id): + id + } + } + + private func reconcileIdleTimer(hasOutputDemand: Bool) { + if hasOutputDemand { + guard previousIdleTimerState == nil else { return } + previousIdleTimerState = idleTimerController.isIdleTimerDisabled + idleTimerController.isIdleTimerDisabled = true + } else { + guard let previousIdleTimerState else { return } + idleTimerController.isIdleTimerDisabled = previousIdleTimerState + self.previousIdleTimerState = nil + } + } + + private func startBackgroundPreferenceFlush() { + guard case let .idle(id) = backgroundPreferenceFlush else { return } + let lease = backgroundExecutionLeaser.begin( + name: "Throw save preferences", + ) { [weak self] in + self?.expireBackgroundPreferenceFlush(id: id) + } + let session = session + let task = Task(name: "Throw flush preferences in background") { [weak self] in + guard Task.isCancelled == false else { return } + await session.flushPreferencesSave() + guard Task.isCancelled == false else { return } + self?.completeBackgroundPreferenceFlush(id: id) + } + backgroundPreferenceFlush = .active(id: id, lease: lease, task: task) + } + + private func cancelBackgroundPreferenceFlush() { + guard case let .active(id, _, _) = backgroundPreferenceFlush else { return } + expireBackgroundPreferenceFlush(id: id) + } + + private func completeBackgroundPreferenceFlush(id: BackgroundPreferenceFlushID) { + guard case let .active(currentID, lease, _) = backgroundPreferenceFlush, + currentID == id + else { return } + backgroundPreferenceFlush = .idle( + nextID: BackgroundPreferenceFlushID(rawValue: id.rawValue &+ 1), + ) + lease.end() + } + + private func expireBackgroundPreferenceFlush(id: BackgroundPreferenceFlushID) { + guard case let .active(currentID, lease, task) = backgroundPreferenceFlush, + currentID == id + else { return } + backgroundPreferenceFlush = .idle( + nextID: BackgroundPreferenceFlushID(rawValue: id.rawValue &+ 1), + ) + task.cancel() + lease.end() + } +} + +struct RuntimeControllerView: View { + let session: ThrowSession + let outputDemandDidChange: @MainActor () -> Void + + var body: some View { + ThrowRootView(session: session) + .onChange(of: session.projectionOutputCount, initial: true) { + _, _ in + outputDemandDidChange() + } + } +} diff --git a/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift b/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift new file mode 100644 index 000000000..582a2f972 --- /dev/null +++ b/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import Throw +import ThrowUI +import UIKit + +@MainActor +struct ExternalDisplaySceneDelegateTests { + @Test func platformHandoffUsesTheDelegateOwnedRuntime() throws { + let runtime = ThrowApplicationRuntimeSpy() + let delegate = AppDelegate(runtime: runtime) + + let resolved = try #require(ExternalDisplaySceneDelegate.runtime(from: delegate)) + + #expect(resolved === runtime) + } + + @Test func unrelatedApplicationDelegateCannotCreateARuntime() { + #expect(ExternalDisplaySceneDelegate.runtime(from: UnrelatedDelegate()) == nil) + } + + @Test func projectionOutputLifecycleUsesTheInjectedRuntime() { + let runtime = ThrowApplicationRuntimeSpy() + let delegate = ExternalDisplaySceneDelegate() + let output = ProjectionOutput.externalDisplay( + ProjectionOutputID(rawValue: "external-lifecycle-test"), + ) + + delegate.connectProjectionOutput(output, runtime: runtime) { _ in } + #expect(runtime.connectedOutputs == [output]) + + delegate.disconnectProjectionOutput() + delegate.disconnectProjectionOutput() + #expect(runtime.disconnectedOutputs == [output]) + } +} + +@MainActor +private final class UnrelatedDelegate: NSObject, UIApplicationDelegate {} diff --git a/Throw/Throw/Tests/PrivacyManifestTests.swift b/Throw/Throw/Tests/PrivacyManifestTests.swift new file mode 100644 index 000000000..bf181d4c1 --- /dev/null +++ b/Throw/Throw/Tests/PrivacyManifestTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +struct PrivacyManifestTests { + @Test func builtAppContainsTheUserDefaultsRequiredReason() throws { + let builtProductsPath = try #require( + ProcessInfo.processInfo.environment["PACKAGE_RESOURCE_BUNDLE_PATH"], + ) + let manifestURL = URL(filePath: builtProductsPath) + .appending(path: "Throw.app") + .appending(path: "PrivacyInfo.xcprivacy") + let data = try Data(contentsOf: manifestURL) + let propertyList = try PropertyListSerialization.propertyList( + from: data, + format: nil, + ) + let manifest = try #require(propertyList as? [String: Any]) + let accessedAPITypes = try #require( + manifest["NSPrivacyAccessedAPITypes"] as? [[String: Any]], + ) + let userDefaultsDeclaration = try #require(accessedAPITypes.first { declaration in + declaration["NSPrivacyAccessedAPIType"] as? String + == "NSPrivacyAccessedAPICategoryUserDefaults" + }) + + #expect(accessedAPITypes.count == 1) + #expect( + userDefaultsDeclaration["NSPrivacyAccessedAPITypeReasons"] as? [String] + == ["CA92.1"], + ) + } +} diff --git a/Throw/Throw/Tests/ThrowAppTestSupport.swift b/Throw/Throw/Tests/ThrowAppTestSupport.swift new file mode 100644 index 000000000..96fc268f9 --- /dev/null +++ b/Throw/Throw/Tests/ThrowAppTestSupport.swift @@ -0,0 +1,138 @@ +@testable import Throw +@_spi(Testing) @testable import ThrowUI +import UIKit + +@MainActor +final class ThrowApplicationRuntimeSpy: ThrowApplicationRuntime { + let session: ThrowSession + + private(set) var controllerSceneEvents: [RecordedControllerSceneEvent] = [] + private(set) var connectedOutputs: [ProjectionOutput] = [] + private(set) var disconnectedOutputs: [ProjectionOutput] = [] + private(set) var appearances: [UIUserInterfaceStyle] = [] + private(set) var outputDemandChangeCount = 0 + + init(session: ThrowSession) { + self.session = session + } + + convenience init() { + self.init(session: .fixture()) + } + + func projectionOutputConnected( + _ output: ProjectionOutput, + appearanceSink _: @escaping @MainActor (UIUserInterfaceStyle) -> Void, + ) { + connectedOutputs.append(output) + } + + func projectionOutputDisconnected(_ output: ProjectionOutput) { + disconnectedOutputs.append(output) + } + + func controllerScene( + _ id: ControllerSceneID, + didReceive event: ControllerSceneLifecycleEvent, + ) { + controllerSceneEvents.append(RecordedControllerSceneEvent(id: id, event: event)) + } + + func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) { + appearances.append(style) + } + + func sessionOutputDemandDidChange() { + outputDemandChangeCount += 1 + } +} + +struct RecordedControllerSceneEvent: Equatable { + let id: ControllerSceneID + let event: ControllerSceneLifecycleEvent +} + +@MainActor +final class IdleTimerControllerSpy: IdleTimerControlling { + private var storedIdleTimerState: Bool + private(set) var assignedStates: [Bool] = [] + + var isIdleTimerDisabled: Bool { + get { storedIdleTimerState } + set { + storedIdleTimerState = newValue + assignedStates.append(newValue) + } + } + + init(isIdleTimerDisabled: Bool) { + storedIdleTimerState = isIdleTimerDisabled + } +} + +@MainActor +final class BackgroundExecutionLeaseSpy: BackgroundExecutionLease { + private(set) var endCallCount = 0 + private var awaitedEndCallCount = 0 + private var endContinuation: CheckedContinuation? + + func end() { + endCallCount += 1 + if endCallCount >= awaitedEndCallCount { + endContinuation?.resume() + endContinuation = nil + } + } + + func waitForEndCallCount(_ expectedCount: Int) async { + guard endCallCount < expectedCount else { return } + awaitedEndCallCount = expectedCount + await withCheckedContinuation { continuation in + endContinuation = continuation + } + } +} + +@MainActor +final class ThrowRuntimeEventProbe { + private var occurred = false + private var waiters: [CheckedContinuation] = [] + + func record() { + guard occurred == false else { return } + occurred = true + let waiters = waiters + self.waiters.removeAll() + waiters.forEach { $0.resume() } + } + + func wait() async { + guard occurred == false else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +@MainActor +final class BackgroundExecutionLeaserSpy: BackgroundExecutionLeasing { + private(set) var beginCallCount = 0 + private(set) var lastLease: BackgroundExecutionLeaseSpy? + private var expirationHandler: (@MainActor @Sendable () -> Void)? + + func begin( + name _: String, + expirationHandler: @escaping @MainActor @Sendable () -> Void, + ) -> any BackgroundExecutionLease { + beginCallCount += 1 + let lease = BackgroundExecutionLeaseSpy() + lastLease = lease + self.expirationHandler = expirationHandler + return lease + } + + func expire() { + expirationHandler?() + expirationHandler = nil + } +} diff --git a/Throw/Throw/Tests/ThrowAppTests.swift b/Throw/Throw/Tests/ThrowAppTests.swift new file mode 100644 index 000000000..f472e8b39 --- /dev/null +++ b/Throw/Throw/Tests/ThrowAppTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import Throw +import UIKit + +@MainActor +struct ThrowAppTests { + @Test func appDelegateRetainsTheInjectedRuntime() { + let runtime = ThrowApplicationRuntimeSpy() + let delegate = AppDelegate(runtime: runtime) + + #expect(delegate.runtime === runtime) + } + + @Test func controllerSceneLifecycleUsesTheSameRuntime() { + let runtime = ThrowApplicationRuntimeSpy() + let delegate = AppDelegate(runtime: runtime) + let id = ControllerSceneID(rawValue: "controller-test") + + delegate.runtime.controllerScene(id, didReceive: .willEnterForeground) + delegate.runtime.controllerScene(id, didReceive: .didEnterBackground) + + #expect(runtime.controllerSceneEvents == [ + RecordedControllerSceneEvent(id: id, event: .willEnterForeground), + RecordedControllerSceneEvent(id: id, event: .didEnterBackground), + ]) + } + + @Test func iOS27ExternalAccessoryConfigurationHasStableSceneIdentity() throws { + let configuration = AppDelegate.externalDisplayConfiguration() + let delegateClass = try #require(configuration.delegateClass) + + #expect(configuration.name == "Throw External Display") + #expect(configuration.role == .windowExternalDisplayNonInteractive) + #expect(ObjectIdentifier(delegateClass) == + ObjectIdentifier(ExternalDisplaySceneDelegate.self)) + } +} diff --git a/Throw/Throw/Tests/ThrowRuntimeTests.swift b/Throw/Throw/Tests/ThrowRuntimeTests.swift new file mode 100644 index 000000000..295d6b991 --- /dev/null +++ b/Throw/Throw/Tests/ThrowRuntimeTests.swift @@ -0,0 +1,294 @@ +import Testing +@testable import Throw +@_spi(Testing) @testable import ThrowUI +import UIKit + +@MainActor +struct ThrowRuntimeTests { + @Test func controllerSceneCancellationCannotCancelTheProcessLaunch() async { + let harness = ThrowSessionLaunchTestHarness.configuredSuspended() + let session = harness.session + let runtime = ThrowRuntime( + session: session, + idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let first = ControllerSceneID(rawValue: "launch-first") + let second = ControllerSceneID(rawValue: "launch-second") + + await harness.waitForLoadToStart() + runtime.controllerScene(first, didReceive: .willEnterForeground) + runtime.controllerScene(second, didReceive: .willEnterForeground) + let sceneWaiter = Task(name: "Throw cancelled scene launch waiter") { + await session.waitForLaunchForTesting() + } + sceneWaiter.cancel() + runtime.controllerScene(first, didReceive: .didDisconnect) + runtime.controllerScene(second, didReceive: .didEnterBackground) + + let loadCountBeforeResume = await harness.loadCallCount() + #expect(loadCountBeforeResume == 1) + await harness.resumeLoad() + await session.waitForLaunchForTesting() + await sceneWaiter.value + + guard case .ready = session.launchState else { + Issue.record("Scene cancellation must not cancel the process launch") + return + } + let finalLoadCount = await harness.loadCallCount() + #expect(finalLoadCount == 1) + #expect(session.hasForegroundControllerSceneForTesting == false) + } + + @Test func firstAndLastOutputOwnIdleTimerRestoration() { + let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: false) + let runtime = ThrowRuntime( + session: .fixture(), + idleTimerController: idleTimer, + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let external = ProjectionOutput.externalDisplay( + ProjectionOutputID(rawValue: "external-test"), + ) + let preview = ProjectionOutput.preview( + ProjectionOutputID(rawValue: "preview-test"), + ) + + runtime.projectionOutputConnected(external) { _ in } + runtime.projectionOutputConnected(preview) { _ in } + #expect(idleTimer.isIdleTimerDisabled) + + runtime.projectionOutputDisconnected(external) + #expect(idleTimer.isIdleTimerDisabled) + + runtime.projectionOutputDisconnected(preview) + #expect(idleTimer.isIdleTimerDisabled == false) + } + + @Test func duplicateOutputIdentityDoesNotRequireAnExtraDisconnect() { + let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: false) + let runtime = ThrowRuntime( + session: .fixture(), + idleTimerController: idleTimer, + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let output = ProjectionOutput.externalDisplay( + ProjectionOutputID(rawValue: "external-test"), + ) + + runtime.projectionOutputConnected(output) { _ in } + runtime.projectionOutputConnected(output) { _ in } + runtime.projectionOutputDisconnected(output) + + #expect(idleTimer.isIdleTimerDisabled == false) + } + + @Test func externalAppearanceTracksTheController() { + let runtime = ThrowRuntime( + session: .fixture(), + idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let output = ProjectionOutput.externalDisplay( + ProjectionOutputID(rawValue: "external-test"), + ) + var received: [UIUserInterfaceStyle] = [] + + runtime.projectionOutputConnected(output) { received.append($0) } + runtime.controllerAppearanceDidChange(.dark) + runtime.controllerAppearanceDidChange(.dark) + runtime.controllerAppearanceDidChange(.light) + + #expect(received == [.unspecified, .dark, .light]) + } + + @Test func sessionOutputDemandDrivesTheIdleTimerBridge() { + let session = ThrowSession.fixture() + let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: false) + let runtime = ThrowRuntime( + session: session, + idleTimerController: idleTimer, + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let output = ProjectionOutput.preview( + ProjectionOutputID(rawValue: "session-preview-test"), + ) + + session.projectionOutputConnected(output) + runtime.sessionOutputDemandDidChange() + #expect(idleTimer.isIdleTimerDisabled) + + session.projectionOutputDisconnected(output) + runtime.sessionOutputDemandDidChange() + #expect(idleTimer.isIdleTimerDisabled == false) + #expect(idleTimer.assignedStates == [true, false]) + } + + @Test func sessionOutputDemandRestoresAnInitiallyDisabledIdleTimer() { + let session = ThrowSession.fixture() + let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: true) + let runtime = ThrowRuntime( + session: session, + idleTimerController: idleTimer, + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let output = ProjectionOutput.fullScreen( + ProjectionOutputID(rawValue: "session-full-screen-test"), + ) + + session.projectionOutputConnected(output) + runtime.sessionOutputDemandDidChange() + #expect(idleTimer.isIdleTimerDisabled) + + session.projectionOutputDisconnected(output) + runtime.sessionOutputDemandDidChange() + #expect(idleTimer.isIdleTimerDisabled) + #expect(idleTimer.assignedStates == [true, true]) + } + + @Test func twoControllerScenesOwnAggregateForegroundPresence() { + let session = ThrowSession.fixture() + let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: false) + let runtime = ThrowRuntime( + session: session, + idleTimerController: idleTimer, + backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), + ) + let firstController = ControllerSceneID(rawValue: "controller-first") + let secondController = ControllerSceneID(rawValue: "controller-second") + let externalOutput = ProjectionOutput.externalDisplay( + ProjectionOutputID(rawValue: "external-lifecycle-test"), + ) + + runtime.projectionOutputConnected(externalOutput) { _ in } + #expect(session.hasProjectionOutputDemand) + #expect(session.hasForegroundControllerSceneForTesting == false) + + runtime.controllerScene(firstController, didReceive: .willEnterForeground) + runtime.controllerScene(secondController, didReceive: .willEnterForeground) + runtime.controllerScene(firstController, didReceive: .didEnterBackground) + #expect(session.hasForegroundControllerSceneForTesting) + + runtime.controllerScene(secondController, didReceive: .didDisconnect) + #expect(session.hasForegroundControllerSceneForTesting == false) + #expect(session.hasProjectionOutputDemand) + + runtime.controllerScene(firstController, didReceive: .willEnterForeground) + #expect(session.hasForegroundControllerSceneForTesting) + + runtime.controllerScene(firstController, didReceive: .didDisconnect) + runtime.projectionOutputDisconnected(externalOutput) + #expect(session.hasForegroundControllerSceneForTesting == false) + #expect(session.hasProjectionOutputDemand == false) + #expect(idleTimer.assignedStates == [true, false]) + } + + @Test func finalControllerBackgroundEndsItsPersistenceLeaseAfterFlush() async throws { + let session = ThrowSession.fixture() + let leaser = BackgroundExecutionLeaserSpy() + let runtime = ThrowRuntime( + session: session, + idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), + backgroundExecutionLeaser: leaser, + ) + let controller = ControllerSceneID(rawValue: "controller-background-flush") + runtime.controllerScene(controller, didReceive: .willEnterForeground) + let preferenceProducer = try #require(session.beginPreferenceMutation()) + let registration = ThrowRuntimeEventProbe() + session.preferenceFlushDidRegisterForTesting = { + registration.record() + } + + runtime.controllerScene(controller, didReceive: .didEnterBackground) + + let lease = try #require(leaser.lastLease) + let flushTask = try #require(runtime.backgroundPreferenceFlushTaskForTesting) + #expect(leaser.beginCallCount == 1) + await registration.wait() + #expect(session.preferencePersistence.quiescenceWaiterCount == 1) + #expect(lease.endCallCount == 0) + session.finishPreferenceMutation(preferenceProducer) + await flushTask.value + #expect(lease.endCallCount == 1) + } + + @Test func backgroundPersistenceExpirationCancelsAndEndsItsLeaseOnce() async throws { + let session = ThrowSession.fixture() + let leaser = BackgroundExecutionLeaserSpy() + let runtime = ThrowRuntime( + session: session, + idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), + backgroundExecutionLeaser: leaser, + ) + let controller = ControllerSceneID(rawValue: "controller-expired-flush") + runtime.controllerScene(controller, didReceive: .willEnterForeground) + let preferenceProducer = try #require(session.beginPreferenceMutation()) + let registration = ThrowRuntimeEventProbe() + session.preferenceFlushDidRegisterForTesting = { + registration.record() + } + runtime.controllerScene(controller, didReceive: .didEnterBackground) + let lease = try #require(leaser.lastLease) + let flushTask = try #require(runtime.backgroundPreferenceFlushTaskForTesting) + await registration.wait() + #expect(session.preferencePersistence.quiescenceWaiterCount == 1) + #expect(lease.endCallCount == 0) + + leaser.expire() + await flushTask.value + + #expect(lease.endCallCount == 1) + #expect(session.preferencePersistence.quiescenceWaiterCount == 0) + #expect(session.preferencePersistence.isMutationActive) + #expect(session.preferencePersistence.activeProducerCount == 1) + session.finishPreferenceMutation(preferenceProducer) + #expect(lease.endCallCount == 1) + } + + @Test func returningForegroundCancelsAStaleFlushGeneration() async throws { + let session = ThrowSession.fixture() + let leaser = BackgroundExecutionLeaserSpy() + let runtime = ThrowRuntime( + session: session, + idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), + backgroundExecutionLeaser: leaser, + ) + let controller = ControllerSceneID(rawValue: "controller-repeat-background-flush") + runtime.controllerScene(controller, didReceive: .willEnterForeground) + let preferenceProducer = try #require(session.beginPreferenceMutation()) + let firstRegistration = ThrowRuntimeEventProbe() + session.preferenceFlushDidRegisterForTesting = { + firstRegistration.record() + } + runtime.controllerScene(controller, didReceive: .didEnterBackground) + let firstLease = try #require(leaser.lastLease) + let firstTask = try #require(runtime.backgroundPreferenceFlushTaskForTesting) + await firstRegistration.wait() + #expect(session.preferencePersistence.quiescenceWaiterCount == 1) + + runtime.controllerScene(controller, didReceive: .willEnterForeground) + await firstTask.value + + #expect(firstLease.endCallCount == 1) + #expect(session.preferencePersistence.quiescenceWaiterCount == 0) + #expect(session.preferencePersistence.isMutationActive) + let secondRegistration = ThrowRuntimeEventProbe() + session.preferenceFlushDidRegisterForTesting = { + secondRegistration.record() + } + runtime.controllerScene(controller, didReceive: .didEnterBackground) + let secondLease = try #require(leaser.lastLease) + let secondTask = try #require(runtime.backgroundPreferenceFlushTaskForTesting) + await secondRegistration.wait() + + #expect(leaser.beginCallCount == 2) + #expect(firstLease !== secondLease) + #expect(secondLease.endCallCount == 0) + #expect(session.preferencePersistence.quiescenceWaiterCount == 1) + session.finishPreferenceMutation(preferenceProducer) + await secondTask.value + #expect(firstLease.endCallCount == 1) + #expect(secondLease.endCallCount == 1) + } +} diff --git a/Throw/Throw/attribution-sources.json b/Throw/Throw/attribution-sources.json new file mode 100644 index 000000000..abedfe90c --- /dev/null +++ b/Throw/Throw/attribution-sources.json @@ -0,0 +1,21 @@ +{ + "output": "Throw/Throw/Resources/attribution.json", + "sources": [ + { + "type": "swiftPackageManager", + "manifest": "Package.swift", + "resolved": "Package.resolved", + "shippedFrom": ["ThrowUI"] + }, + { + "type": "agentSkills", + "kind": "developmentTool", + "manifest": ".agents/external-skills.json" + }, + { + "type": "developmentTools", + "kind": "developmentTool", + "manifest": ".agents/development-tools.json" + } + ] +} diff --git a/Throw/ThrowCore/AGENTS.md b/Throw/ThrowCore/AGENTS.md new file mode 100644 index 000000000..516da164b --- /dev/null +++ b/Throw/ThrowCore/AGENTS.md @@ -0,0 +1,131 @@ +# ThrowCore – Module Shape + +ThrowCore owns Throw's domain, projection, source, polling, persistence seams, +location, and scheduling; see [`README.md`](README.md). Read the root +[`AGENTS.md`](../../AGENTS.md) and group [`../AGENTS.md`](../AGENTS.md) first. + +## Scope and dependencies + +- Import Foundation, CoreLocation, Security, and PeriscopeCore only. Never + import SwiftUI, UIKit, WhereCore, RegionKit, or LifecycleKit. +- Keep provider DTOs internal. Public source, layer, preference, and + credential boundaries stay provider-neutral and typed. +- Keep provider-specific setup and capability dispatch in + `AircraftSourceService`. Presentation code uses its protocol only. +- Keep source factories at composition boundaries. A source never creates a + global transport, store, poller, or credential. + +## Invariants + +- Normalize timestamps, altitude sentinels, missing position, identities, and + padded callsigns at the DTO boundary. Never interpret missing wire values as + zero. +- Require diagnostics for each snapshot construction. +- Preserve the counts through filters and wrappers. Aggregate them when snapshots merge. +- Log partial schema drift at warning level without provider record values. +- Represent each polling log event as one closed case with its required payload. +- Keep the flat version-three polling event wire vocabulary stable. +- Keep one structured poll task. Cancel and drain before replacement, and + reject responses from an old generation. +- Publish polling as inactive or as active state with a coordinator-minted + token. Mint a new token for each accepted replacement. +- Put active state in one coordinator-built envelope. Increase its revision + within the token before each changed publication. +- Keep polling-clock sleep cancellation-only. A clock cannot add another + polling failure state. +- Emit a readsb receiver-metadata cadence fallback as a separate warning event. + Keep source activation informational. +- Carry polling cadence as a positive `AircraftPollingCadence`. Unwrap its + `Duration` only at clock and date boundaries. +- Build FR24 bounds as a conservative spherical cap. Use all longitudes when + the cap reaches a pole, and round transmitted edges outward. +- Never fall back between aircraft sources or merge their frames. +- Represent source setup with one `AircraftSourceSelection`. Never restore + parallel selected and validated source properties. +- Derive provider credential IDs from the source kind. Never accept an + arbitrary credential ID in a provider configuration. +- Build connection tests with `AircraftSourceValidationDraft`. Credential-free + cases carry no replacement-credential field. +- Keep consecutive motion state inside the Flights runtime actor. Clear it when + the selected source changes, and never persist it. +- Represent horizontal motion as `AircraftHorizontalMotion`. Available motion + carries track, speed, source, and optional turn rate as one validated value. +- Pass `ResolvedAircraftObservation` values to frame builders. Never pass a + separate observation collection and keyed motion lookup. +- Store only the RapidAPI key in device-only Keychain storage. Persist no live + aircraft data or credential in preferences. +- Represent setup as `ThrowSetupState`. A configured setup carries its validated + source, confirmed location, and projection mode as required values. +- Keep projection functions deterministic and independent of SwiftUI layout. +- Represent geodetic altitude as `GeodeticAltitude`. An available altitude + carries its value and available quality together. +- Keep experience and layer catalogs compile-time and free of UI values. Add no + runtime plugin or `AnyView` boundary. +- Keep shipped experience identities closed. Derive standard descriptors and + presentation through exhaustive switches so a new case forces every owner to update. +- Keep the standard experience catalog authoritative. Pass + `RunnableProjectionExperienceID` through playlist mutations, and keep planned + `ProjectionExperienceID` values at display and persistence boundaries. +- Construct semantic frames through typed layer and experience cases. Never pass + parallel experience IDs, raw layer arrays, and modes across production boundaries. +- Keep layer IDs, mark element families, line styles, and payload shapes closed + and typed through semantic and projected frames. Keep raw `LayerFrame` + construction in DEBUG Testing SPI in `ProjectionModels.swift`. +- Derive an airport mark's identity and glyph from one descriptor. Never store + a parallel airport ID beside that descriptor. +- Bind each semantic layer kind to its projected payload with + `ProjectionMarkLayerKind` or `ProjectionLineLayerKind`. +- Derive renderer z-order from the closed `LayerID`. Never duplicate it in a + catalog or presentation switch. +- Pass only `PreparedProjectionExperienceInput` to `ProjectionEngine`. Return a + closed `ProjectedExperienceFrame` from projection. +- Project each present static-line source through a nonoptional preparation + closure. Store its projected frame and source revision in one value. +- Create static-line frames with `ProjectionEngine.lineFrame`. Reject a prepared + frame when its source revision or projection context does not match. +- Derive static-line render identity from its typed layer, source revision, and + projection context. Never accept a caller-supplied revision ID. +- Never let a production engine API accept an arbitrary experience ID or + erased layer array. +- Erase projected frames only in ThrowUI's `ProjectionFrame.swift`. Cache static + lines by layer identity and semantic revision. +- Keep version-two preferences grouped by global, playlist, and experience + ownership. Preserve exact version-one migration and existing Keychain IDs. +- Keep global and experience preferences as validated aggregate values. Use + their replacement methods instead of mutable scalar mirrors. +- Represent temporary quiet wake durations as `TemporaryQuietWake`. Never pass + a raw minute count across the session boundary. +- Project Geography with the selected regional Map center and saved calibration. + Never use Mercator placement or draw it in True Sky. +- Keep observer and Map-center semantics separate. True Sky and local activity + use the observer. Map projection, filtering, and cloud queries use its center. +- Keep every derived `MapRegionID` inside its persisted band ranges. Treat + positive 180° longitude as the negative-dateline band. +- Change the pinned source manifest, generator, and generated archive together. + Keep expected counts scoped to emitted records, and keep the archive free of + names and unused source attributes. +- Keep aircraft-family and airline-brand classification provider-neutral and + deterministic. Use only bundled type characteristics, emitter category, + explicit ICAO airline designators, and curated direct callsign prefixes; + never add an online or per-tail lookup. +- Keep route enrichment optional, request-bounded, and off the aircraft rendering + path. Never persist or log its callsigns, routes, or response body. +- Keep downloaded source archives outside the tracked tree. Require an exact + digest before the generator reads an archive. +- Keep `ThrowLog` payloads redacted according to the group privacy invariant. +- Pass attribution-load failure into the durable-logging starter. Emit its typed + event after sink attachment, or before a store-open error returns. +- Route cold-launch and post-launch failures through the process-owned durable + logging starter. Retain each typed event with its error attachment until attachment. +- Make the durable-logging starter the session-failure logger. Never inject a + second logger beside it. +- Flush the existing sinks before the store handoff. Write each retained record + to the store once, then release records that arrived during the handoff. +- Open one durable logging session through `PeriscopeThrowDurableLoggingStarter`. + Keep OSLog active when the store cannot open or history pruning fails. + +## Testing + +Run `./test ThrowCoreTests`. Use injected deterministic dependencies and +memory-only stores; production network, GPS, UserDefaults, and Keychain are +forbidden in tests and previews. diff --git a/Throw/ThrowCore/README.md b/Throw/ThrowCore/README.md new file mode 100644 index 000000000..c31ee6610 --- /dev/null +++ b/Throw/ThrowCore/README.md @@ -0,0 +1,228 @@ +# ThrowCore + +ThrowCore is Throw's UI-independent domain and services module. It normalizes +aircraft observations from the explicitly selected provider, maintains one +polling stream, predicts motion, and projects semantic experience layers into +immutable frames for the UI. + +## Install + +Use the `ThrowCore` product from the repository's root Swift package. The +module imports Foundation, CoreLocation, Security, and PeriscopeCore; it does +not depend on SwiftUI, UIKit, WhereCore, or RegionKit. + +## Public shape + +The live catalog and public declarations in `Sources/` are authoritative. The +important boundaries are: + +- validated geographic, viewport, calibration, source, experience, playlist, + layer, and frame values; +- fixed Map centers that are stored by coarse observer region; +- `ProjectionExperienceCatalog.standard` and `LayerCatalog.standard`, which + define fixed View and layer membership at compile time; +- `AircraftObservationSource`, the provider-neutral one-shot feed contract; +- `AircraftSourceOperationServing`, the provider-neutral setup and usage + boundary used by presentation code; +- HTTP, preference, credential, location, and clock protocols, with live and + deterministic in-memory implementations; +- the polling coordinator, prediction, quiet scheduler, and pure projection + engine. + +Coarse Map regions use one-degree bands. The north pole uses the final northern +band, and positive 180° longitude shares the negative-dateline band. + +`adsb.lol`, local `readsb`, and ADS-B Exchange RapidAPI use separate request +and envelope adapters around the reusable ADS-B Exchange-v2 aircraft decoder. +Flightradar24 has its own live-position decoder. Both decoders retain usable +rows and report aggregate malformed and missing-position counts. The polling +log records these counts at warning level. Every snapshot construction supplies +these diagnostics. Filters and response wrappers preserve them. +Each FR24 snapshot carries a completed route result for each aircraft. The result +is unavailable when the same record has no usable route. FR24 zero-altitude positions normalize as +ground because its position schema has no separate airborne-state field. The +FR24 adapter also reads the account's 24-hour usage report. Its estimator uses +the reported credits per request, the selected cadence, quiet hours, and the +current region's request multiplicity. +`readsb` receiver metadata selects its cadence. If metadata fails, Throw uses +one second and emits a separate warning event. Source activation remains +informational. +`AircraftPollingCadence` validates that each configured cadence is positive. +It keeps fractional receiver cadences intact until the polling-clock boundary. +Aircraft polling log events use case-specific payloads. Their flat version-three +wire keys and kind values stay stable for existing Periscope records. +Source configuration carries no credential value or credential ID. Each paid +provider selects its fixed Keychain slot through `AircraftCredentialID`. +`AircraftSourceValidationDraft` is the temporary setup boundary. Only its paid +provider cases can carry a replacement credential and derive a Keychain ID. +An FR24 bounds query that crosses the antimeridian uses two valid hemisphere +requests. Both must succeed before Throw publishes the merged snapshot. +Duplicate aircraft keep the freshest observation and its matching route. +FR24 bounds use a spherical cap that matches Throw's local distance model. +A cap that reaches either pole requests all longitudes. Other bounds round +outward so provider coordinates on the local filter boundary are not omitted. + +## Composition + +`ThrowSession+Composition.swift` creates the live stores, source graph, poller, +and session once. ThrowUI's shared session drives the poller according to +foreground, quiet, and output demand. Version-two preferences separate global, +playlist, and Air & Space state. The codec migrates version-one data under the +existing storage key. Validated preference aggregates stay intact through the +session boundary. Raw values exist only at editing and codec boundaries. +Keychain credential IDs do not change. + +Setup is one typed lifecycle value. Its configured case requires a validated +source, confirmed location, and projection mode. The codec reconstructs this +state from the stable version-one and version-two fields. + +The process starts one Periscope store and attaches it to Throw's typed log. +The durable-logging starter is also the session failure logger. It sends failures +to OSLog while this store opens. It also +retains each exact typed record and its error attachment. The handoff writes +these records to the store once before new session records use the attached sink. +The store keeps at most 100 days and 50,000 events. A store error leaves OSLog +active and produces a typed error event. A history-prune error does not make +an attached store unavailable. + +Composition gives software-attribution failure to the durable logging starter. +The starter records the error after the store attaches. Existing sinks receive +the event if the store cannot open. + +`AircraftSourceSelection` keeps unconfigured, awaiting-validation, and +configured source state in one value. A configured source cannot disagree with +a separate validation flag. +`TemporaryQuietWake` closes the supported 15-minute, 30-minute, and 60-minute +wake choices. Session intents do not accept other minute values. + +`LayerID` and `LayerMarkID` are closed typed values. +`GeodeticAltitude` stores altitude availability, value, and quality in one +state. An unavailable altitude cannot carry a quality. +`ProjectionLayerFrame` fixes each semantic layer identity, element family, and +payload shape in its generic type. A mark layer cannot receive another layer's +elements. A line layer cannot receive another layer's style. Raw `LayerFrame` +construction exists only in DEBUG Testing SPI. +`ProjectionExperienceFrame` then accepts only the typed layers for its experience. +`ProjectionExperienceInput` also pairs each experience with its supported projection modes. +Transit can accept only a Map viewport. Geography visibility belongs to Map +inputs, so it cannot be requested in True Sky. +`ProjectionExperienceID` is the closed display and persistence identity. It +includes planned Transit. `RunnableProjectionExperienceID` is the smaller +release runtime identity. It includes only Air & Space. The standard catalog +owns the relationship between these types. Its public descriptors can be read, +but production code cannot construct another catalog. Test catalogs are +available only through the DEBUG Testing SPI. Playlist entries and mutations +require runnable identities, so planned Transit cannot enter a release playlist. + +`ProjectionMarkLayerKind` and `ProjectionLineLayerKind` bind each semantic +element or style family to its projected payload. `ProjectedLayerFrame` keeps +that binding through `ProjectionEngine`. Airport elements derive both identity +and glyph from one `AirportGlyphDescriptor`, so those values cannot drift. +`ProjectedExperienceFrame` fixes each experience's projected layers and modes. +Air & Space True Sky cannot carry Geography. Transit cannot carry Air & Space +layers or a True Sky mode. Map output cannot carry the True-Sky-only Stars +layer. Layer identity also owns the fixed renderer z-order. + +The worker converts `ProjectionExperienceInput` into one closed +`PreparedProjectionExperienceInput` after it projects static lines. The engine +accepts that value and returns the matching `ProjectedExperienceFrame`. +Each present Transit network produces one required projected frame. That frame +stores the semantic source revision, so the prepared value cannot omit it. +ThrowUI erases the closed output once in +`ProjectionFrame.swift`, at the renderer boundary. + +Line styles are typed, so Geography and future transit routes use one +projection path. `ProjectionEngine.lineFrame` records the semantic source +revision and full projection context in each static-line frame. The engine +rejects a prepared frame from another source revision or projection context. +It derives the render identity from this provenance. Callers cannot supply an +arbitrary revision ID or replace the projected payload. +The worker caches static lines by layer, revision, Map center, viewport, +calibration, and geometry. +It does not rebuild them at the 30 Hz mark rate. Expensive work stays off the +main actor. Generation checks reject late work. +Position prediction continues until a later successful poll replaces the +snapshot. A retryable poll failure starts a 15-second grace period and a +15-second fade. +Each polling publication is inactive or active with a coordinator-minted +token. Each accepted replacement gets a new token. Consumers reject an active +publication unless its token matches the activation that they accepted. Each +active envelope also has a revision that increases within that token. Thus, a +delayed state read cannot replace a newer stream publication. +The polling clock can finish a wait or report cancellation. It cannot leave a +dead poll task in a retrying state through another error. + +The Flights runtime compares consecutive positions for each aircraft. +Valid provider track and speed remain authoritative. +Observed positions supply motion when provider values are missing or clearly inconsistent. +Recent provider tracks can supply a bounded turn rate. +Horizontal motion is one typed value: an available value always has track, +speed, and source, while orientation-only observations remain unavailable for +translation. Turn rate exists only inside available horizontal motion. +The motion estimator pairs each observation with its resolved motion before it +calls the Flights frame builder. The builder cannot receive missing motion. +Throw uses that turn rate for the first 12 seconds of a prediction. +The runtime removes this history when the source changes or the app runtime ends. + +`Tools/generate-geography.rb` creates the bundled archive from pinned Natural +Earth Vector 1:10m and U.S. Census Bureau inputs. A source manifest records each +official URL, release, file member, and SHA-256 digest. + +The generator filters features, removes names, splits antimeridian paths, and +simplifies linework. It then quantizes coordinates and assigns wide, standard, +or local visibility. The committed archive lets every app build stay offline. +Raw source archives stay outside the tracked tree. + +`Tools/generate-aircraft-types.rb` creates the bundled ICAO type lookup from a +pinned Mictronics aircraft-database archive. Throw keeps only the designator, +airframe and engine description, and wake category. The visual classifier uses +that lookup with the provider's emitter category to select one of six stable +silhouette families. A small curated callsign-prefix table can add a carrier +identity. It does not perform online, route, registration, or operator-name +lookups for aircraft classification. Separately, the route resolver sends up to +12 newly seen callsigns per pass to ADSBDB, with at most four concurrent +requests. It caches successful routes for six hours and unknown routes for one +hour. A provider failure pauses lookups for five minutes. Cancellation does not +start this cooldown. Aircraft rendering +does not wait for this optional enrichment. The resolver is not used for +Flightradar24 snapshots because FR24 supplies position and route fields in one +response. + +`Tools/generate-airports.rb` creates the bundled airport catalog from a pinned +OurAirports revision. The manifest fixes both source-file digests and the +generated-resource digest. Its counts describe the airports and runways that +the archive emits, not all valid rows in the source files. The catalog includes +active coded airports, elevations, code aliases, and open runway endpoints. + +The activity classifier treats an airport within 50 NM of the observer as +local. Route data can confirm an arrival or departure estimate. Strict motion, +altitude, distance, and runway-alignment rules can infer an estimate without a +route. Ground aircraft and observations without altitude or vertical rate do +not receive inferred activity. + +## Privacy and limitations + +Cloud requests use ephemeral sessions. Logging is typed and redacted: no +coordinates, coordinate-bearing URLs, receiver URLs, credentials, response +bodies, callsigns, or aircraft IDs. Aircraft data is incomplete and +non-safety-critical. True Sky treats provider altitudes as compatible +mean-sea-level approximations and is not an optical ceiling registration. +The offline map sends no request. Its generalized boundaries are not +authoritative. Natural Earth uses its default de facto view. Census boundaries +support statistical work and are not legal land descriptions. +Cloud aircraft sources receive the coarse query center. In Map mode, this value +can differ from the observer location. True Sky always uses the observer location. +Aircraft classifications and carrier identities are not persisted or logged. +Aircraft snapshots, routes, and motion history are not persisted. +Projection logs contain only aggregate cadence, age, motion, correction, and snapshot-overlap values. +Durable session logs contain build and device metadata, readiness, failure, +and aggregate retention counts. +Route enrichment sends broadcast callsigns to ADSBDB. It never sends aircraft +or observer positions, persists route history, or logs route request or +response values. + +## Testing + +`ThrowCoreTests` uses Swift Testing with injected clocks, HTTP, preferences, +credentials, and location. It never contacts a provider, GPS, UserDefaults, or +the Keychain. Run it with `./test ThrowCoreTests`. diff --git a/Throw/ThrowCore/Sources/ADSBDBFlightRouteSource.swift b/Throw/ThrowCore/Sources/ADSBDBFlightRouteSource.swift new file mode 100644 index 000000000..3142ead59 --- /dev/null +++ b/Throw/ThrowCore/Sources/ADSBDBFlightRouteSource.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Callsign-only route enrichment through ADSBDB's public API. +public struct ADSBDBFlightRouteSource: FlightRouteSource { + public static let endpoint = URL(string: "https://api.adsbdb.com/v0/callsign/")! + public static let maximumConcurrentRequests = 4 + + private let transport: any HTTPTransport + + public init(transport: any HTTPTransport) { + self.transport = transport + } + + public func routes( + for queries: [FlightRouteQuery], + ) async throws -> [FlightCallsign: FlightRoute] { + guard queries.isEmpty == false else { return [:] } + var remaining = queries.makeIterator() + + return try await withThrowingTaskGroup( + of: LookupResult.self, + returning: [FlightCallsign: FlightRoute].self, + ) { group in + for _ in 0 ..< min(Self.maximumConcurrentRequests, queries.count) { + guard let query = remaining.next() else { break } + group.addTask { + try await lookup(query) + } + } + + var routes: [FlightCallsign: FlightRoute] = [:] + while let result = try await group.next() { + if let route = result.route { + routes[result.callsign] = route + } + if let query = remaining.next() { + group.addTask { + try await lookup(query) + } + } + } + return routes + } + } + + public func makeRequest(for query: FlightRouteQuery) -> HTTPRequest { + HTTPRequest( + method: .get, + url: Self.endpoint.appendingPathComponent(query.callsign.rawValue), + headers: [.accept: "application/json"], + timeoutSeconds: 8, + ) + } + + private func lookup(_ query: FlightRouteQuery) async throws -> LookupResult { + do { + let response = try await transport.response(for: makeRequest(for: query)) + if response.statusCode == 404 { + return LookupResult(callsign: query.callsign, route: nil) + } + guard (200 ... 299).contains(response.statusCode) else { + throw FlightRouteLookupError.provider + } + return try LookupResult( + callsign: query.callsign, + route: Self.decode(response.data), + ) + } catch is CancellationError { + throw CancellationError() + } catch let error as FlightRouteLookupError { + throw error + } catch let error as HTTPTransportFailure { + throw FlightRouteLookupError.transport(error.category) + } catch { + throw FlightRouteLookupError.decoding + } + } + + private static func decode(_ data: Data) throws -> FlightRoute { + let record: ADSBDBResponse.RouteRecord + do { + record = try JSONDecoder().decode(ADSBDBResponse.self, from: data) + .response.flightroute + } catch { + throw FlightRouteLookupError.decoding + } + guard let origin = record.origin.preferredCode, + let destination = record.destination.preferredCode, + origin != destination + else { + throw FlightRouteLookupError.decoding + } + return FlightRoute(origin: origin, destination: destination) + } +} + +private struct LookupResult { + let callsign: FlightCallsign + let route: FlightRoute? +} + +private struct ADSBDBResponse: Decodable { + struct Payload: Decodable { + let flightroute: RouteRecord + } + + struct RouteRecord: Decodable { + let origin: Airport + let destination: Airport + } + + struct Airport: Decodable { + let iataCode: String? + let icaoCode: String? + + var preferredCode: AirportCode? { + iataCode.flatMap(AirportCode.init(rawValue:)) + ?? icaoCode.flatMap(AirportCode.init(rawValue:)) + } + + enum CodingKeys: String, CodingKey { + case iataCode = "iata_code" + case icaoCode = "icao_code" + } + } + + let response: Payload +} diff --git a/Throw/ThrowCore/Sources/ADSBExchangeRapidAPISource.swift b/Throw/ThrowCore/Sources/ADSBExchangeRapidAPISource.swift new file mode 100644 index 000000000..33c182a57 --- /dev/null +++ b/Throw/ThrowCore/Sources/ADSBExchangeRapidAPISource.swift @@ -0,0 +1,134 @@ +import Foundation + +struct ADSBExchangeRapidAPISource: AircraftObservationSource, CustomStringConvertible, + CustomDebugStringConvertible +{ + static let host = "adsbexchange-com1.p.rapidapi.com" + static let baseURL = URL(string: "https://adsbexchange-com1.p.rapidapi.com")! + + private let transport: any HTTPTransport + private let decodingWorker: AircraftDecodingWorker + private let credential: AircraftCredential + private let dateProvider: any DateProvider + + init( + transport: any HTTPTransport, + decoder: ADSBExchangeV2Decoder, + credential: AircraftCredential, + dateProvider: any DateProvider, + ) { + self.transport = transport + decodingWorker = AircraftDecodingWorker(decoder: decoder) + self.credential = credential + self.dateProvider = dateProvider + } + + var description: String { + ">" + } + + var debugDescription: String { + description + } + + func snapshot(for query: AircraftQuery) async throws -> AircraftSnapshot { + try await snapshot(for: query, request: makeRequest(for: query)) + } + + /// Performs the disclosed credential check using exactly one transmitted + /// five-nautical-mile request rather than the live feed's padded radius. + func credentialTestSnapshot( + observer: ObserverPosition, + ) async throws -> AircraftSnapshot { + let query = try AircraftQuery( + observer: observer, + center: observer.coordinate, + viewport: .map(MapViewport(radius: NauticalMiles(value: 5))), + includeGroundAircraft: false, + ) + return try await snapshot( + for: query, + request: makeRequest(for: query, transmittedRadius: NauticalMiles(value: 5)), + ) + } + + private func snapshot( + for query: AircraftQuery, + request: HTTPRequest, + ) async throws -> AircraftSnapshot { + do { + let response = try await transport.response(for: request) + let fetchedAt = dateProvider.now() + try SourceHTTPValidation.validate( + response, + source: .adsbExchangeRapidAPI, + receivedAt: fetchedAt, + ) + let snapshot = try await decodingWorker.decodeCloudSnapshot( + response.data, + source: .adsbExchangeRapidAPI, + fetchedAt: fetchedAt, + query: query, + ) + return AircraftSnapshot( + source: snapshot.source, + fetchedAt: snapshot.fetchedAt, + observations: snapshot.observations, + successfulHTTPStatus: response.statusCode, + decodingDiagnostics: snapshot.decodingDiagnostics, + ) + } catch is CancellationError { + throw CancellationError() + } catch let error as AircraftSourceFailure { + throw error + } catch let error as HTTPTransportFailure { + throw AircraftSourceFailure.transport(error.category) + } catch is ADSBV2DecodingError { + throw AircraftSourceFailure.decoding + } catch { + throw AircraftSourceFailure.decoding + } + } + + func makeRequest(for query: AircraftQuery) throws -> HTTPRequest { + let plan = try CloudAircraftQuery.plan(for: query) + return try makeRequest(for: query, transmittedRadius: plan.transmittedRadius) + } + + func makeCredentialTestRequest(observer: ObserverPosition) throws -> HTTPRequest { + let query = try AircraftQuery( + observer: observer, + center: observer.coordinate, + viewport: .map(MapViewport(radius: NauticalMiles(value: 5))), + includeGroundAircraft: false, + ) + return try makeRequest(for: query, transmittedRadius: NauticalMiles(value: 5)) + } + + private func makeRequest( + for query: AircraftQuery, + transmittedRadius: NauticalMiles, + ) throws -> HTTPRequest { + let plan = try CloudAircraftQuery.plan(for: query) + let latitude = CloudAircraftQuery.pathComponent(for: plan.coarseCenter.latitude) + let longitude = CloudAircraftQuery.pathComponent(for: plan.coarseCenter.longitude) + let radius = String(Int(transmittedRadius.value)) + guard let url = URL( + string: "/v2/lat/\(latitude)/lon/\(longitude)/dist/\(radius)/", + relativeTo: Self.baseURL, + )?.absoluteURL else { + throw AircraftSourceFailure.invalidConfiguration + } + return HTTPRequest( + method: .get, + url: url, + headers: [ + .accept: "application/json", + .acceptEncoding: "gzip", + .rapidAPIHost: Self.host, + .rapidAPIKey: credential.authenticationHeaderValue, + ], + timeoutSeconds: 8, + ) + } +} diff --git a/Throw/ThrowCore/Sources/ADSBExchangeUsageEstimator.swift b/Throw/ThrowCore/Sources/ADSBExchangeUsageEstimator.swift new file mode 100644 index 000000000..57ff92829 --- /dev/null +++ b/Throw/ThrowCore/Sources/ADSBExchangeUsageEstimator.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct ADSBExchangeUsageEstimate: Equatable, Sendable { + public let requestsPerHour: Double + public let thirtyDayUpperBound: Double + public let activeHoursCoveredByAllowance: Double + public let exceedsPublishedAllowance: Bool + + public init( + requestsPerHour: Double, + thirtyDayUpperBound: Double, + activeHoursCoveredByAllowance: Double, + exceedsPublishedAllowance: Bool, + ) { + self.requestsPerHour = requestsPerHour + self.thirtyDayUpperBound = thirtyDayUpperBound + self.activeHoursCoveredByAllowance = activeHoursCoveredByAllowance + self.exceedsPublishedAllowance = exceedsPublishedAllowance + } +} + +public enum ADSBExchangeUsageEstimator { + public static let publishedPersonalPlanAllowance = 10000.0 + + public static func estimate( + pollingInterval: PollingInterval, + quietSchedule: QuietSchedule, + ) -> ADSBExchangeUsageEstimate { + let requestsPerHour = 3600 / Double(pollingInterval.seconds) + let quietMinutes = quietSchedule.interval?.durationMinutes ?? 0 + let activeHoursPerDay = Double(24 * 60 - quietMinutes) / 60 + let thirtyDayUpperBound = requestsPerHour * activeHoursPerDay * 30 + return ADSBExchangeUsageEstimate( + requestsPerHour: requestsPerHour, + thirtyDayUpperBound: thirtyDayUpperBound, + activeHoursCoveredByAllowance: publishedPersonalPlanAllowance / requestsPerHour, + exceedsPublishedAllowance: thirtyDayUpperBound > publishedPersonalPlanAllowance, + ) + } +} diff --git a/Throw/ThrowCore/Sources/ADSBExchangeV2Decoder.swift b/Throw/ThrowCore/Sources/ADSBExchangeV2Decoder.swift new file mode 100644 index 000000000..ed43e2d03 --- /dev/null +++ b/Throw/ThrowCore/Sources/ADSBExchangeV2Decoder.swift @@ -0,0 +1,331 @@ +import Foundation + +enum ADSBV2DecodingError: Error, Equatable { + case invalidEnvelope +} + +/// Normalizes the common ADS-B Exchange v2 aircraft envelope used by all +/// three v1 providers. Unknown additive fields are ignored. Individual bad +/// records are lossy, but an otherwise empty malformed payload is a schema error. +struct ADSBExchangeV2Decoder { + init() {} + + func decode( + _ data: Data, + source: AircraftSourceKind, + fetchedAt: Date, + ) throws -> AircraftSnapshot { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: data) + } catch { + throw ADSBV2DecodingError.invalidEnvelope + } + + let providerNow = envelope.now.map { normalizedTimestamp($0.value) } + let referenceDate = providerNow.map(Date.init(timeIntervalSince1970:)) ?? fetchedAt + var observations: [AircraftObservation] = [] + observations.reserveCapacity(envelope.aircraft.count) + var malformedRecordCount = 0 + var missingPositionRecordCount = 0 + + for lossyAircraft in envelope.aircraft { + try Task.checkCancellation() + guard let aircraft = lossyAircraft.value else { + malformedRecordCount += 1 + continue + } + guard let rawID = aircraft.hex?.trimmingCharacters(in: .whitespacesAndNewlines), + rawID.isEmpty == false + else { + malformedRecordCount += 1 + continue + } + + let identity: AircraftID + if rawID.hasPrefix("~") { + let value = String(rawID.dropFirst()) + guard let decodedIdentity = AircraftID( + kind: .providerMarkedNonICAO, + rawValue: value, + ) else { + malformedRecordCount += 1 + continue + } + identity = decodedIdentity + } else { + guard let decodedIdentity = AircraftID(kind: .icao, rawValue: rawID) else { + malformedRecordCount += 1 + continue + } + identity = decodedIdentity + } + + guard (aircraft.seen?.value ?? 0) >= 0, + (aircraft.seenPosition?.value ?? 0) >= 0 + else { + malformedRecordCount += 1 + continue + } + guard let latitude = aircraft.latitude?.value, + let longitude = aircraft.longitude?.value + else { + missingPositionRecordCount += 1 + continue + } + + do { + let coordinate = try GeoCoordinate(latitude: latitude, longitude: longitude) + let barometricAltitude: Altitude? + let airborneState: AircraftAirborneState + switch aircraft.barometricAltitude { + case let .altitude(value): + barometricAltitude = try Altitude(feet: value) + airborneState = .airborne + case .ground: + barometricAltitude = nil + airborneState = .ground + case nil: + barometricAltitude = nil + airborneState = aircraft.geometricAltitude == nil ? .unknown : .airborne + } + + let geometricAltitude: Altitude? = if let value = aircraft.geometricAltitude? + .value + { + try Altitude(feet: value) + } else { + nil + } + + try observations.append( + AircraftObservation( + id: identity, + coordinate: coordinate, + geometricAltitude: geometricAltitude, + barometricAltitude: barometricAltitude, + airborneState: airborneState, + groundTrack: aircraft.track.map { try Bearing(degrees: $0.value) }, + trueHeading: aircraft.trueHeading.map { try Bearing(degrees: $0.value) }, + magneticHeading: aircraft.magneticHeading + .map { try Bearing(degrees: $0.value) }, + groundSpeedKnots: aircraft.groundSpeed?.value, + verticalRateFeetPerMinute: aircraft.geometricRate?.value ?? aircraft + .barometricRate?.value, + callsign: aircraft.flight, + registration: aircraft.registration, + aircraftType: aircraft.aircraftType.flatMap( + AircraftTypeDesignator.init(rawValue:), + ), + emitterCategory: aircraft.emitterCategory.flatMap( + AircraftEmitterCategory.init(providerValue:), + ), + airlineDesignator: nil, + messageObservedAt: referenceDate.addingTimeInterval( + -(aircraft.seen?.value ?? 0), + ), + positionObservedAt: referenceDate.addingTimeInterval( + -(aircraft.seenPosition?.value ?? 0), + ), + fetchedAt: fetchedAt, + metadata: AircraftObservationMetadata( + source: source, + positionSource: aircraft.positionSource, + messageCount: aircraft.messages, + ), + ), + ) + } catch { + malformedRecordCount += 1 + continue + } + } + if observations.isEmpty, + malformedRecordCount > 0, + missingPositionRecordCount == 0 + { + throw ADSBV2DecodingError.invalidEnvelope + } + return AircraftSnapshot( + source: source, + fetchedAt: fetchedAt, + observations: observations, + decodingDiagnostics: AircraftSnapshotDecodingDiagnostics( + malformedRecordCount: malformedRecordCount, + missingPositionRecordCount: missingPositionRecordCount, + ), + ) + } + + private func normalizedTimestamp(_ timestamp: Double) -> TimeInterval { + timestamp >= 100_000_000_000 ? timestamp / 1000 : timestamp + } +} + +/// Runs response adaptation, normalization, and exact local filtering away +/// from the polling coordinator's actor. +actor AircraftDecodingWorker { + private let decoder: ADSBExchangeV2Decoder + + init(decoder: ADSBExchangeV2Decoder) { + self.decoder = decoder + } + + func decodeCloudSnapshot( + _ data: Data, + source: AircraftSourceKind, + fetchedAt: Date, + query: AircraftQuery, + ) throws -> AircraftSnapshot { + try Task.checkCancellation() + let decoded = try decoder.decode(data, source: source, fetchedAt: fetchedAt) + try Task.checkCancellation() + let observations = try CloudAircraftQuery.postFilter(decoded.observations, for: query) + try Task.checkCancellation() + return AircraftSnapshot( + source: source, + fetchedAt: fetchedAt, + observations: observations, + decodingDiagnostics: decoded.decodingDiagnostics, + ) + } + + func decodeReadsbSnapshot( + _ data: Data, + fetchedAt: Date, + query: AircraftQuery, + ) throws -> AircraftSnapshot { + try Task.checkCancellation() + let adapted = try ReadsbEnvelopeAdapter.adapt(data) + return try decodeCloudSnapshot( + adapted, + source: .readsb, + fetchedAt: fetchedAt, + query: query, + ) + } +} + +private struct Envelope: Decodable { + let aircraft: [LossyAircraftDTO] + let now: FlexibleDouble? + + enum CodingKeys: String, CodingKey { + case aircraft = "ac" + case now + } +} + +/// Consumes one array element even when that provider record has a malformed +/// field, allowing the rest of a valid envelope to remain usable. +private struct LossyAircraftDTO: Decodable { + let value: AircraftDTO? + + init(from decoder: any Decoder) throws { + value = try? AircraftDTO(from: decoder) + } +} + +private struct AircraftDTO: Decodable { + let hex: String? + let flight: String? + let registration: String? + let aircraftType: String? + let emitterCategory: String? + let barometricAltitude: BarometricAltitudeDTO? + let geometricAltitude: FlexibleDouble? + let groundSpeed: FlexibleDouble? + let track: FlexibleDouble? + let trueHeading: FlexibleDouble? + let magneticHeading: FlexibleDouble? + let barometricRate: FlexibleDouble? + let geometricRate: FlexibleDouble? + let latitude: FlexibleDouble? + let longitude: FlexibleDouble? + let seen: FlexibleDouble? + let seenPosition: FlexibleDouble? + let positionSource: String? + let messages: Int? + + enum CodingKeys: String, CodingKey { + case hex + case flight + case registration = "r" + case aircraftType = "t" + case emitterCategory = "category" + case barometricAltitude = "alt_baro" + case geometricAltitude = "alt_geom" + case groundSpeed = "gs" + case track + case trueHeading = "true_heading" + case magneticHeading = "mag_heading" + case barometricRate = "baro_rate" + case geometricRate = "geom_rate" + case latitude = "lat" + case longitude = "lon" + case seen + case seenPosition = "seen_pos" + case positionSource = "type" + case messages + } +} + +private struct FlexibleDouble: Decodable { + let value: Double + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let decodedValue: Double + if let double = try? container.decode(Double.self) { + decodedValue = double + } else if let text = try? container.decode(String.self), let double = Double(text) { + decodedValue = double + } else { + throw DecodingError.typeMismatch( + Double.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected a numeric value", + ), + ) + } + guard decodedValue.isFinite else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a finite numeric value", + ) + } + value = decodedValue + } +} + +private enum BarometricAltitudeDTO: Decodable { + case altitude(Double) + case ground + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Double.self) { + self = .altitude(value) + } else if let text = try? container.decode(String.self) { + if text.lowercased() == "ground" { + self = .ground + } else if let value = Double(text) { + self = .altitude(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected numeric altitude or ground", + ) + } + } else { + throw DecodingError.typeMismatch( + Double.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected numeric altitude or ground", + ), + ) + } + } +} diff --git a/Throw/ThrowCore/Sources/AdsBLolSource.swift b/Throw/ThrowCore/Sources/AdsBLolSource.swift new file mode 100644 index 000000000..dcc45e951 --- /dev/null +++ b/Throw/ThrowCore/Sources/AdsBLolSource.swift @@ -0,0 +1,84 @@ +import Foundation + +struct AdsBLolSource: AircraftObservationSource, CustomStringConvertible, + CustomDebugStringConvertible +{ + static let baseURL = URL(string: "https://api.adsb.lol")! + + private let transport: any HTTPTransport + private let decodingWorker: AircraftDecodingWorker + private let dateProvider: any DateProvider + + init( + transport: any HTTPTransport, + decoder: ADSBExchangeV2Decoder, + dateProvider: any DateProvider, + ) { + self.transport = transport + decodingWorker = AircraftDecodingWorker(decoder: decoder) + self.dateProvider = dateProvider + } + + var description: String { + ">" + } + + var debugDescription: String { + description + } + + func snapshot(for query: AircraftQuery) async throws -> AircraftSnapshot { + let request = try makeRequest(for: query) + do { + let response = try await transport.response(for: request) + let fetchedAt = dateProvider.now() + try SourceHTTPValidation.validate( + response, + source: .adsbLol, + receivedAt: fetchedAt, + ) + let snapshot = try await decodingWorker.decodeCloudSnapshot( + response.data, + source: .adsbLol, + fetchedAt: fetchedAt, + query: query, + ) + return AircraftSnapshot( + source: snapshot.source, + fetchedAt: snapshot.fetchedAt, + observations: snapshot.observations, + successfulHTTPStatus: response.statusCode, + decodingDiagnostics: snapshot.decodingDiagnostics, + ) + } catch is CancellationError { + throw CancellationError() + } catch let error as AircraftSourceFailure { + throw error + } catch let error as HTTPTransportFailure { + throw AircraftSourceFailure.transport(error.category) + } catch is ADSBV2DecodingError { + throw AircraftSourceFailure.decoding + } catch { + throw AircraftSourceFailure.decoding + } + } + + func makeRequest(for query: AircraftQuery) throws -> HTTPRequest { + let plan = try CloudAircraftQuery.plan(for: query) + let latitude = CloudAircraftQuery.pathComponent(for: plan.coarseCenter.latitude) + let longitude = CloudAircraftQuery.pathComponent(for: plan.coarseCenter.longitude) + let radius = String(Int(plan.transmittedRadius.value)) + guard let url = URL( + string: "/v2/point/\(latitude)/\(longitude)/\(radius)", + relativeTo: Self.baseURL, + )?.absoluteURL else { + throw AircraftSourceFailure.invalidConfiguration + } + return HTTPRequest( + method: .get, + url: url, + headers: [.accept: "application/json"], + timeoutSeconds: 8, + ) + } +} diff --git a/Throw/ThrowCore/Sources/AircraftCredentialStore.swift b/Throw/ThrowCore/Sources/AircraftCredentialStore.swift new file mode 100644 index 000000000..ed9519b28 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftCredentialStore.swift @@ -0,0 +1,157 @@ +import Foundation +import Security + +public enum AircraftCredentialError: Error, Equatable, Sendable { + case emptyCredential + case keychain(status: OSStatus) + case invalidStoredValue +} + +/// A secret whose string/debug descriptions are permanently redacted. +public struct AircraftCredential: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + fileprivate let secret: String + + public init(secret: String) throws { + let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { + throw AircraftCredentialError.emptyCredential + } + self.secret = trimmed + } + + public var lastFour: String? { + secret.count >= 8 ? String(secret.suffix(4)) : nil + } + + public var description: String { + "••••" + } + + public var debugDescription: String { + description + } +} + +public enum CredentialState: Equatable, Sendable { + case missing + case saved(lastFour: String?) +} + +public protocol AircraftCredentialStore: Sendable { + func state(for id: AircraftCredentialID) async throws -> CredentialState + func credential(for id: AircraftCredentialID) async throws -> AircraftCredential? + func save(_ credential: AircraftCredential, for id: AircraftCredentialID) async throws + func delete(_ id: AircraftCredentialID) async throws +} + +/// Stores each provider credential as a generic-password item that is only +/// available while the device is unlocked and never migrates to another device. +public actor KeychainAircraftCredentialStore: AircraftCredentialStore { + private let service: String + private let accountPrefix: String + + public init(service: String, accountPrefix: String) { + precondition(service.isEmpty == false) + precondition(accountPrefix.isEmpty == false) + self.service = service + self.accountPrefix = accountPrefix + } + + public func state(for id: AircraftCredentialID) throws -> CredentialState { + guard let credential = try credential(for: id) else { return .missing } + return .saved(lastFour: credential.lastFour) + } + + public func credential(for id: AircraftCredentialID) throws -> AircraftCredential? { + var query = baseQuery(for: id) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data, + let value = String(data: data, encoding: .utf8) + else { + throw AircraftCredentialError.invalidStoredValue + } + return try AircraftCredential(secret: value) + case errSecItemNotFound: + return nil + default: + throw AircraftCredentialError.keychain(status: status) + } + } + + public func save(_ credential: AircraftCredential, for id: AircraftCredentialID) throws { + let valueData = Data(credential.secret.utf8) + let attributes: [String: Any] = [ + kSecValueData as String: valueData, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + let query = baseQuery(for: id) + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + var addition = query + addition.merge(attributes) { _, new in new } + let addStatus = SecItemAdd(addition as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AircraftCredentialError.keychain(status: addStatus) + } + default: + throw AircraftCredentialError.keychain(status: updateStatus) + } + } + + public func delete(_ id: AircraftCredentialID) throws { + let status = SecItemDelete(baseQuery(for: id) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AircraftCredentialError.keychain(status: status) + } + } + + private func baseQuery(for id: AircraftCredentialID) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "\(accountPrefix).\(id.rawValue)", + ] + } +} + +public actor MemoryAircraftCredentialStore: AircraftCredentialStore { + private var credentials: [AircraftCredentialID: AircraftCredential] + + public init(credentials: [AircraftCredentialID: AircraftCredential]) { + self.credentials = credentials + } + + public func state(for id: AircraftCredentialID) -> CredentialState { + guard let credential = credentials[id] else { return .missing } + return .saved(lastFour: credential.lastFour) + } + + public func credential(for id: AircraftCredentialID) -> AircraftCredential? { + credentials[id] + } + + public func save(_ credential: AircraftCredential, for id: AircraftCredentialID) { + credentials[id] = credential + } + + public func delete(_ id: AircraftCredentialID) { + credentials[id] = nil + } +} + +extension AircraftCredential { + var authenticationHeaderValue: String { + secret + } +} diff --git a/Throw/ThrowCore/Sources/AircraftModels.swift b/Throw/ThrowCore/Sources/AircraftModels.swift new file mode 100644 index 000000000..2ab9e1c07 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftModels.swift @@ -0,0 +1,594 @@ +import Foundation + +public struct AircraftID: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public enum Kind: String, Hashable, Sendable { + case icao + case providerMarkedNonICAO = "non-icao" + } + + public let kind: Kind + public let rawValue: String + + public init?(kind: Kind, rawValue: String) { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalized.isEmpty == false else { return nil } + self.kind = kind + self.rawValue = normalized + } + + public var layerMarkID: LayerMarkID { + .aircraft(self) + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public enum AircraftSourceKind: String, CaseIterable, Codable, Hashable, Sendable { + case adsbLol = "adsb-lol" + case readsb + case adsbExchangeRapidAPI = "adsb-exchange-rapidapi" + case flightradar24 +} + +public enum AircraftCredentialID: String, Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + case rapidAPI = "rapidapi-personal-key" + case flightradar24 = "flightradar24-api-token" + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public struct ReadsbConfiguration: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let aircraftJSONURL: URL + + public init(aircraftJSONURL: URL) throws { + self.aircraftJSONURL = try ReadsbURLValidator.validate(aircraftJSONURL) + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public struct ADSBExchangeConfiguration: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let pollingInterval: PollingInterval + + public init(pollingInterval: PollingInterval) { + self.pollingInterval = pollingInterval + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public struct Flightradar24Configuration: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let pollingInterval: PollingInterval + + public init(pollingInterval: PollingInterval) { + self.pollingInterval = pollingInterval + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public enum AircraftSourceConfiguration: Equatable, Sendable { + case adsbLol + case readsb(ReadsbConfiguration) + case adsbExchangeRapidAPI(ADSBExchangeConfiguration) + case flightradar24(Flightradar24Configuration) + + public var kind: AircraftSourceKind { + switch self { + case .adsbLol: .adsbLol + case .readsb: .readsb + case .adsbExchangeRapidAPI: .adsbExchangeRapidAPI + case .flightradar24: .flightradar24 + } + } + + public var basePollingInterval: Duration { + switch self { + case .adsbLol: + .seconds(10) + case .readsb: + .seconds(1) + case let .adsbExchangeRapidAPI(configuration): + configuration.pollingInterval.duration + case let .flightradar24(configuration): + configuration.pollingInterval.duration + } + } +} + +extension AircraftSourceConfiguration: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public struct AircraftQuery: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let observer: ObserverPosition + /// The center of the requested region. It equals the observer in True Sky + /// and may be a fixed regional Map center in Map mode. + public let center: GeoCoordinate + public let viewport: ProjectionViewport + public let includeGroundAircraft: Bool + + public init( + observer: ObserverPosition, + center: GeoCoordinate, + viewport: ProjectionViewport, + includeGroundAircraft: Bool, + ) { + self.observer = observer + self.center = center + self.viewport = viewport + self.includeGroundAircraft = includeGroundAircraft + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public enum AircraftAirborneState: String, Hashable, Sendable { + case airborne + case ground + case unknown +} + +/// A normalized ICAO aircraft type designator supplied by an observation provider. +public struct AircraftTypeDesignator: Hashable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init?(rawValue: String) { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard (2 ... 4).contains(normalized.count), + normalized.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) + else { return nil } + self.rawValue = normalized + } + + public var description: String { + rawValue + } +} + +/// A normalized three-character ICAO airline designator supplied by a provider. +public struct AirlineICAODesignator: Hashable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init?(rawValue: String) { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard normalized.count == 3, + normalized.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) + else { return nil } + self.rawValue = normalized + } + + public var description: String { + rawValue + } +} + +/// The semantic ADS-B emitter category used as a classification hint. +public enum AircraftEmitterCategory: String, Hashable, Sendable { + case noInformation = "A0" + case light = "A1" + case small = "A2" + case large = "A3" + case highVortexLarge = "A4" + case heavy = "A5" + case highPerformance = "A6" + case rotorcraft = "A7" + case glider = "B1" + case lighterThanAir = "B2" + case parachutist = "B3" + case ultralight = "B4" + case unmanned = "B6" + case spaceVehicle = "B7" + case emergencySurface = "C1" + case serviceSurface = "C2" + case pointObstacle = "C3" + + public init?(providerValue: String) { + self + .init(rawValue: providerValue.trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased()) + } +} + +public struct AircraftObservationMetadata: Hashable, Sendable { + public let source: AircraftSourceKind + public let positionSource: String? + public let messageCount: Int? + + public init(source: AircraftSourceKind, positionSource: String?, messageCount: Int?) { + self.source = source + self.positionSource = positionSource + self.messageCount = messageCount + } +} + +public struct AircraftObservation: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let id: AircraftID + public let coordinate: GeoCoordinate + public let geometricAltitude: Altitude? + public let barometricAltitude: Altitude? + public let airborneState: AircraftAirborneState + public let groundTrack: Bearing? + public let trueHeading: Bearing? + public let magneticHeading: Bearing? + public let groundSpeedKnots: Double? + public let verticalRateFeetPerMinute: Double? + public let callsign: String? + public let registration: String? + public let aircraftType: AircraftTypeDesignator? + public let emitterCategory: AircraftEmitterCategory? + public let airlineDesignator: AirlineICAODesignator? + public let messageObservedAt: Date + public let positionObservedAt: Date + public let fetchedAt: Date + public let metadata: AircraftObservationMetadata + + public init( + id: AircraftID, + coordinate: GeoCoordinate, + geometricAltitude: Altitude?, + barometricAltitude: Altitude?, + airborneState: AircraftAirborneState, + groundTrack: Bearing?, + trueHeading: Bearing?, + magneticHeading: Bearing?, + groundSpeedKnots: Double?, + verticalRateFeetPerMinute: Double?, + callsign: String?, + registration: String?, + aircraftType: AircraftTypeDesignator?, + emitterCategory: AircraftEmitterCategory?, + airlineDesignator: AirlineICAODesignator?, + messageObservedAt: Date, + positionObservedAt: Date, + fetchedAt: Date, + metadata: AircraftObservationMetadata, + ) throws { + if let groundSpeedKnots { + guard groundSpeedKnots.isFinite, (0 ... 2000).contains(groundSpeedKnots) else { + throw ThrowValidationError.outOfRange( + field: "groundSpeed", + closedRange: 0 ... 2000, + ) + } + } + if let verticalRateFeetPerMinute { + guard verticalRateFeetPerMinute.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "verticalRate") + } + } + self.id = id + self.coordinate = coordinate + self.geometricAltitude = geometricAltitude + self.barometricAltitude = barometricAltitude + self.airborneState = airborneState + self.groundTrack = groundTrack + self.trueHeading = trueHeading + self.magneticHeading = magneticHeading + self.groundSpeedKnots = groundSpeedKnots + self.verticalRateFeetPerMinute = verticalRateFeetPerMinute + self.callsign = callsign?.nilIfTrimmedEmpty + self.registration = registration?.nilIfTrimmedEmpty + self.aircraftType = aircraftType + self.emitterCategory = emitterCategory + self.airlineDesignator = airlineDesignator + self.messageObservedAt = messageObservedAt + self.positionObservedAt = positionObservedAt + self.fetchedAt = fetchedAt + self.metadata = metadata + } + + public var preferredSkyAltitude: Altitude? { + skyAltitude.value + } + + public var skyAltitude: GeodeticAltitude { + if let geometricAltitude { + return .available(geometricAltitude, quality: .geometric) + } + if let barometricAltitude { + return .available(barometricAltitude, quality: .barometricApproximation) + } + return .unavailable + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// Privacy-safe counts for provider records that a snapshot decoder discarded. +public struct AircraftSnapshotDecodingDiagnostics: Codable, Hashable, Sendable { + /// Counts that prove a decoder discarded at least one provider record. + public struct DiscardedRecords: Hashable, Sendable { + public let malformedRecordCount: Int + public let missingPositionRecordCount: Int + + fileprivate init(malformedRecordCount: Int, missingPositionRecordCount: Int) { + self.malformedRecordCount = malformedRecordCount + self.missingPositionRecordCount = missingPositionRecordCount + } + } + + public static let none = AircraftSnapshotDecodingDiagnostics( + malformedRecordCount: 0, + missingPositionRecordCount: 0, + ) + + public let malformedRecordCount: Int + public let missingPositionRecordCount: Int + + public init(malformedRecordCount: Int, missingPositionRecordCount: Int) { + precondition(malformedRecordCount >= 0) + precondition(missingPositionRecordCount >= 0) + self.malformedRecordCount = malformedRecordCount + self.missingPositionRecordCount = missingPositionRecordCount + } + + public var hasDiscardedRecords: Bool { + malformedRecordCount > 0 || missingPositionRecordCount > 0 + } + + public var discardedRecords: DiscardedRecords? { + guard hasDiscardedRecords else { return nil } + return DiscardedRecords( + malformedRecordCount: malformedRecordCount, + missingPositionRecordCount: missingPositionRecordCount, + ) + } + + public func adding( + _ other: AircraftSnapshotDecodingDiagnostics, + ) -> AircraftSnapshotDecodingDiagnostics { + let malformed = malformedRecordCount.addingReportingOverflow(other.malformedRecordCount) + let missingPosition = missingPositionRecordCount.addingReportingOverflow( + other.missingPositionRecordCount, + ) + precondition(malformed.overflow == false && missingPosition.overflow == false) + return AircraftSnapshotDecodingDiagnostics( + malformedRecordCount: malformed.partialValue, + missingPositionRecordCount: missingPosition.partialValue, + ) + } +} + +public struct AircraftSnapshot: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let source: AircraftSourceKind + public let fetchedAt: Date + public let observations: [AircraftObservation] + /// Completed route results supplied with the matching source observation. + /// Absence means that the source did not resolve route availability. + public let routeResultsByAircraft: [AircraftID: FlightRouteResult] + public let successfulHTTPStatus: Int? + public let decodingDiagnostics: AircraftSnapshotDecodingDiagnostics + + public init( + source: AircraftSourceKind, + fetchedAt: Date, + observations: [AircraftObservation], + decodingDiagnostics: AircraftSnapshotDecodingDiagnostics, + ) { + self.init( + source: source, + fetchedAt: fetchedAt, + observations: observations, + routeResultsByAircraft: [:], + successfulHTTPStatus: nil, + decodingDiagnostics: decodingDiagnostics, + ) + } + + public init( + source: AircraftSourceKind, + fetchedAt: Date, + observations: [AircraftObservation], + successfulHTTPStatus: Int?, + decodingDiagnostics: AircraftSnapshotDecodingDiagnostics, + ) { + self.init( + source: source, + fetchedAt: fetchedAt, + observations: observations, + routeResultsByAircraft: [:], + successfulHTTPStatus: successfulHTTPStatus, + decodingDiagnostics: decodingDiagnostics, + ) + } + + public init( + source: AircraftSourceKind, + fetchedAt: Date, + observations: [AircraftObservation], + routeResultsByAircraft: [AircraftID: FlightRouteResult], + successfulHTTPStatus: Int?, + decodingDiagnostics: AircraftSnapshotDecodingDiagnostics, + ) { + let observations = Self.canonicalObservations(observations) + precondition(observations.allSatisfy { $0.metadata.source == source }) + let observationIDs = Set(observations.map(\.id)) + precondition(routeResultsByAircraft.keys.allSatisfy(observationIDs.contains)) + precondition( + successfulHTTPStatus.map { (200 ..< 300).contains($0) } ?? true, + "A successful snapshot can only carry a successful HTTP status", + ) + self.source = source + self.fetchedAt = fetchedAt + self.observations = observations + self.routeResultsByAircraft = routeResultsByAircraft + self.successfulHTTPStatus = successfulHTTPStatus + self.decodingDiagnostics = decodingDiagnostics + } + + /// Selects the freshest position while retaining the first-seen order of identities. + static func canonicalObservations( + _ observations: [AircraftObservation], + ) -> [AircraftObservation] { + var result: [AircraftObservation] = [] + result.reserveCapacity(observations.count) + var indexByID: [AircraftID: Int] = [:] + indexByID.reserveCapacity(observations.count) + + for observation in observations { + if let index = indexByID[observation.id] { + if prefers(observation, over: result[index]) { + result[index] = observation + } + } else { + indexByID[observation.id] = result.count + result.append(observation) + } + } + return result + } + + /// Applies the tie-break order shared by snapshot and route-envelope normalization. + static func prefers( + _ candidate: AircraftObservation, + over existing: AircraftObservation, + ) -> Bool { + if candidate.positionObservedAt != existing.positionObservedAt { + return candidate.positionObservedAt > existing.positionObservedAt + } + if candidate.messageObservedAt != existing.messageObservedAt { + return candidate.messageObservedAt > existing.messageObservedAt + } + if candidate.fetchedAt != existing.fetchedAt { + return candidate.fetchedAt > existing.fetchedAt + } + return true + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public enum AircraftTransportErrorCategory: String, Equatable, Sendable { + case cancelled + case timedOut + case offline + case localNetworkDenied + case connection + case invalidResponse + case other +} + +public enum AircraftSourceFailure: Error, Equatable, Sendable { + case invalidConfiguration + case missingCredential + case invalidCredential + case subscriptionRequired + case entitlementRejected + case quotaReached(retryAfterSeconds: Double?) + case provider(statusCode: Int, retryAfterSeconds: Double?) + case transport(AircraftTransportErrorCategory) + case decoding + + public var retryAfterSeconds: Double? { + switch self { + case let .quotaReached(retryAfterSeconds), + let .provider(_, retryAfterSeconds): + retryAfterSeconds + case .invalidConfiguration, + .missingCredential, + .invalidCredential, + .subscriptionRequired, + .entitlementRejected, + .transport, + .decoding: + nil + } + } + + public var isRetryable: Bool { + switch self { + case .quotaReached, .transport, .decoding: + true + case let .provider(statusCode, _): + (500 ... 599).contains(statusCode) + case .invalidConfiguration, + .missingCredential, + .invalidCredential, + .subscriptionRequired, + .entitlementRejected: + false + } + } +} + +public protocol AircraftObservationSource: Sendable { + func snapshot(for query: AircraftQuery) async throws -> AircraftSnapshot +} + +extension String { + fileprivate var nilIfTrimmedEmpty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Throw/ThrowCore/Sources/AircraftMotion.swift b/Throw/ThrowCore/Sources/AircraftMotion.swift new file mode 100644 index 000000000..1e8415824 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftMotion.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Identifies where a provider-neutral horizontal-motion estimate came from. +public enum AircraftHorizontalMotionSource: String, Hashable, Sendable { + case provider + case positionDerived = "position-derived" +} + +/// A validated horizontal-motion value that can drive position prediction. +public struct AvailableAircraftHorizontalMotion: Hashable, Sendable { + public let track: Bearing + public let speedKnots: Double + public let turnRateDegreesPerSecond: Double? + public let source: AircraftHorizontalMotionSource + + public init( + track: Bearing, + speedKnots: Double, + turnRateDegreesPerSecond: Double?, + source: AircraftHorizontalMotionSource, + ) throws { + guard speedKnots.isFinite, (0 ... 2000).contains(speedKnots) else { + throw ThrowValidationError.outOfRange( + field: "groundSpeed", + closedRange: 0 ... 2000, + ) + } + if let turnRateDegreesPerSecond { + guard turnRateDegreesPerSecond.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "turnRate") + } + guard (-3 ... 3).contains(turnRateDegreesPerSecond) else { + throw ThrowValidationError.outOfRange( + field: "turnRate", + closedRange: -3 ... 3, + ) + } + } + self.track = track + self.speedKnots = speedKnots + self.turnRateDegreesPerSecond = turnRateDegreesPerSecond + self.source = source + } +} + +/// Horizontal motion is either unavailable or a complete validated value. +public enum AircraftHorizontalMotion: Hashable, Sendable { + case unavailable(orientation: Bearing?) + case available(AvailableAircraftHorizontalMotion) + + public var availableValue: AvailableAircraftHorizontalMotion? { + if case let .available(value) = self { value } else { nil } + } + + public var orientation: Bearing? { + switch self { + case let .unavailable(orientation): orientation + case let .available(value): value.track + } + } +} + +/// The motion Throw uses for prediction after validating provider values and +/// optionally reconciling them with consecutive observed positions. +public struct AircraftMotion: Hashable, Sendable { + public let horizontal: AircraftHorizontalMotion + public let verticalRateFeetPerMinute: Double? + + public init( + horizontal: AircraftHorizontalMotion, + verticalRateFeetPerMinute: Double?, + ) throws { + if let verticalRateFeetPerMinute { + guard verticalRateFeetPerMinute.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "verticalRate") + } + } + self.horizontal = horizontal + self.verticalRateFeetPerMinute = verticalRateFeetPerMinute + } + + public var groundTrack: Bearing? { + horizontal.orientation + } + + public var groundSpeedKnots: Double? { + horizontal.availableValue?.speedKnots + } + + public var turnRateDegreesPerSecond: Double? { + horizontal.availableValue?.turnRateDegreesPerSecond + } + + public var horizontalSource: AircraftHorizontalMotionSource? { + horizontal.availableValue?.source + } + + public static func reported(by observation: AircraftObservation) -> AircraftMotion { + let horizontal: AircraftHorizontalMotion + if let track = observation.groundTrack, + let speedKnots = observation.groundSpeedKnots + { + do { + horizontal = try .available( + AvailableAircraftHorizontalMotion( + track: track, + speedKnots: speedKnots, + turnRateDegreesPerSecond: nil, + source: .provider, + ), + ) + } catch { + preconditionFailure("A validated observation must contain valid motion: \(error)") + } + } else { + horizontal = .unavailable(orientation: observation.groundTrack) + } + do { + return try AircraftMotion( + horizontal: horizontal, + verticalRateFeetPerMinute: observation.verticalRateFeetPerMinute, + ) + } catch { + preconditionFailure("A validated observation must contain valid motion: \(error)") + } + } +} + +/// One aircraft observation paired with the motion resolved for that observation. +public struct ResolvedAircraftObservation: Hashable, Sendable { + public let observation: AircraftObservation + public let motion: AircraftMotion + + public init(observation: AircraftObservation, motion: AircraftMotion) { + self.observation = observation + self.motion = motion + } +} diff --git a/Throw/ThrowCore/Sources/AircraftPollingCadence.swift b/Throw/ThrowCore/Sources/AircraftPollingCadence.swift new file mode 100644 index 000000000..9157c2de6 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftPollingCadence.swift @@ -0,0 +1,17 @@ +import Foundation + +/// A positive interval between aircraft polling attempts. +public struct AircraftPollingCadence: Hashable, Sendable { + public let duration: Duration + + public init(duration: Duration) throws { + guard duration > .zero else { + throw AircraftPollingCadenceError.nonPositiveDuration + } + self.duration = duration + } +} + +public enum AircraftPollingCadenceError: Error, Equatable, Sendable { + case nonPositiveDuration +} diff --git a/Throw/ThrowCore/Sources/AircraftPollingCoordinator.swift b/Throw/ThrowCore/Sources/AircraftPollingCoordinator.swift new file mode 100644 index 000000000..4df1f19f4 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftPollingCoordinator.swift @@ -0,0 +1,738 @@ +import Foundation + +public protocol AircraftPollingClock: Sendable { + func now() async -> Date + func sleep(for duration: Duration) async throws(CancellationError) +} + +public struct SystemAircraftPollingClock: AircraftPollingClock { + public init() {} + + public func now() async -> Date { + Date() + } + + public func sleep(for duration: Duration) async throws(CancellationError) { + do { + try await Task.sleep(for: duration) + } catch { + // Task.sleep uses its error channel only for task cancellation. + throw CancellationError() + } + } +} + +/// A capability that identifies one accepted physical polling activation. +/// +/// Only ``AircraftPollingCoordinator`` can mint production values. Consumers +/// use the token to reject updates from a superseded polling task. +public struct AircraftPollingActivationToken: Hashable, Sendable { + fileprivate let rawValue: UInt64 + + fileprivate init(mintedRawValue: UInt64) { + rawValue = mintedRawValue + } + + #if DEBUG + @_spi(Testing) public init(testingRawValue: UInt64) { + rawValue = testingRawValue + } + #endif +} + +/// The semantic state of an active physical poller. +public enum AircraftPollingState: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + case loading(source: AircraftSourceKind) + case healthy(snapshot: AircraftSnapshot, nextPollAt: Date) + case retrying( + lastGoodSnapshot: AircraftSnapshot?, + failure: AircraftSourceFailure, + failureStartedAt: Date, + nextRetryAt: Date, + ) + case failed(AircraftSourceFailure) + case quiet + + public var snapshot: AircraftSnapshot? { + switch self { + case let .healthy(snapshot, _): snapshot + case let .retrying(lastGoodSnapshot, _, _, _): lastGoodSnapshot + case .loading, .failed, .quiet: nil + } + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// One ordered semantic publication from an accepted physical poller. +public struct AircraftPollingActiveUpdate: Equatable, Sendable { + /// A monotonic position in one polling activation's publication stream. + public struct Revision: Hashable, Comparable, Sendable { + fileprivate let rawValue: UInt64 + + fileprivate init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + public let token: AircraftPollingActivationToken + public let revision: Revision + public let state: AircraftPollingState + + fileprivate init( + token: AircraftPollingActivationToken, + revision: Revision, + state: AircraftPollingState, + ) { + self.token = token + self.revision = revision + self.state = state + } + + #if DEBUG + @_spi(Testing) public init( + testingToken: AircraftPollingActivationToken, + testingRevisionRawValue: UInt64, + state: AircraftPollingState, + ) { + self.init( + token: testingToken, + revision: Revision(rawValue: testingRevisionRawValue), + state: state, + ) + } + #endif +} + +/// A closed polling publication. Active state carries one coordinator-built +/// envelope whose revision is ordered within its activation token. +public enum AircraftPollingUpdate: Equatable, Sendable { + case inactive + case active(AircraftPollingActiveUpdate) +} + +public enum AircraftPollingBackoff { + public static func delay( + baseCadence: AircraftPollingCadence, + failureCount: Int, + retryAfterSeconds: Double?, + ) -> Duration { + precondition(failureCount > 0) + let baseSeconds = baseCadence.duration.secondsValue + let exponent = min(failureCount - 1, 20) + let exponential = baseSeconds * pow(2, Double(exponent)) + let bounded = min(max(exponential, 2), 60) + let retryAfter = max(0, retryAfterSeconds ?? 0) + return .seconds(max(retryAfter, bounded)) + } +} + +/// Owns the one structured polling task. Replacements cancel and drain before +/// a new generation starts, so late provider responses cannot mix frames. +public actor AircraftPollingCoordinator { + private struct ActivePolling { + let token: AircraftPollingActivationToken + let configuration: AircraftSourceConfiguration + let query: AircraftQuery + } + + private let sourceFactory: any AircraftSourceProducing + private let clock: any AircraftPollingClock + private let logger: any AircraftPollingLogging + private let updatesStream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var pollTask: Task? + private var lifecycleTail: Task? + private var lifecycleRequestGeneration: UInt64 = 0 + private var generation: UInt64 = 0 + private var activePolling: ActivePolling? + private var activePublicationRevision: UInt64 = 0 + private var update = AircraftPollingUpdate.inactive + #if DEBUG + private var beforeReturningCurrentUpdateForTesting: + (@Sendable (AircraftPollingUpdate) async -> Void)? + #endif + + public init( + sourceFactory: any AircraftSourceProducing, + clock: any AircraftPollingClock, + logger: any AircraftPollingLogging, + ) { + self.sourceFactory = sourceFactory + self.clock = clock + self.logger = logger + let pair = AsyncStream.makeStream( + of: AircraftPollingUpdate.self, + bufferingPolicy: .bufferingNewest(1), + ) + updatesStream = pair.stream + continuation = pair.continuation + } + + deinit { + pollTask?.cancel() + lifecycleTail?.cancel() + continuation.finish() + } + + public func stateUpdates() -> AsyncStream { + continuation.yield(update) + return updatesStream + } + + public func currentUpdate() async -> AircraftPollingUpdate { + let currentUpdate = update + #if DEBUG + await beforeReturningCurrentUpdateForTesting?(currentUpdate) + #endif + return currentUpdate + } + + #if DEBUG + @_spi(Testing) public func lifecycleRequestGenerationForTesting() -> UInt64 { + lifecycleRequestGeneration + } + + @_spi(Testing) public func setBeforeReturningCurrentUpdateForTesting( + _ operation: (@Sendable (AircraftPollingUpdate) async -> Void)?, + ) { + beforeReturningCurrentUpdateForTesting = operation + } + #endif + + public func activate( + configuration: AircraftSourceConfiguration, + query: AircraftQuery, + quiet: Bool, + ) async -> AircraftPollingActivationToken? { + lifecycleRequestGeneration &+= 1 + let requestGeneration = lifecycleRequestGeneration + let token = AircraftPollingActivationToken(mintedRawValue: requestGeneration) + let predecessor = lifecycleTail + let operation = Task(name: "Throw activate aircraft source") { [weak self] in + await predecessor?.value + guard Task.isCancelled == false, let self, + await isCurrentLifecycleRequest(requestGeneration) + else { return } + await replace( + configuration: configuration, + query: query, + quiet: quiet, + token: token, + lifecycleRequestGeneration: requestGeneration, + ) + } + lifecycleTail = operation + await withTaskCancellationHandler { + await operation.value + } onCancel: { + operation.cancel() + Task(name: "Settle cancelled Throw source activation") { [weak self] in + await self?.scheduleCancellationCleanup(for: requestGeneration) + } + } + guard Task.isCancelled == false, + isCurrentLifecycleRequest(requestGeneration), + activePolling?.token == token + else { return nil } + return token + } + + public func update( + query: AircraftQuery, + quiet: Bool, + ) async -> AircraftPollingActivationToken? { + lifecycleRequestGeneration &+= 1 + let requestGeneration = lifecycleRequestGeneration + let token = AircraftPollingActivationToken(mintedRawValue: requestGeneration) + let predecessor = lifecycleTail + let operation = Task(name: "Throw update aircraft query") { [weak self] in + await predecessor?.value + guard Task.isCancelled == false, let self, + await isCurrentLifecycleRequest(requestGeneration) + else { return } + await performUpdate( + query: query, + quiet: quiet, + token: token, + lifecycleRequestGeneration: requestGeneration, + ) + } + lifecycleTail = operation + await withTaskCancellationHandler { + await operation.value + } onCancel: { + operation.cancel() + Task(name: "Settle cancelled Throw query update") { [weak self] in + await self?.scheduleCancellationCleanup(for: requestGeneration) + } + } + guard Task.isCancelled == false, + isCurrentLifecycleRequest(requestGeneration), + activePolling?.token == token + else { return nil } + return token + } + + public func deactivate() async { + lifecycleRequestGeneration &+= 1 + let requestGeneration = lifecycleRequestGeneration + let predecessor = lifecycleTail + let operation = Task(name: "Throw deactivate aircraft source") { [weak self] in + await predecessor?.value + guard Task.isCancelled == false, let self, + await isCurrentLifecycleRequest(requestGeneration) + else { return } + await performDeactivate(lifecycleRequestGeneration: requestGeneration) + } + lifecycleTail = operation + await withTaskCancellationHandler { + await operation.value + } onCancel: { + operation.cancel() + Task(name: "Settle cancelled Throw deactivation") { [weak self] in + await self?.scheduleCancellationCleanup(for: requestGeneration) + } + } + } + + private func scheduleCancellationCleanup(for requestGeneration: UInt64) { + guard isCurrentLifecycleRequest(requestGeneration) else { return } + lifecycleRequestGeneration &+= 1 + let cleanupGeneration = lifecycleRequestGeneration + let predecessor = lifecycleTail + let cleanup = Task(name: "Throw cancelled lifecycle cleanup") { [weak self] in + await predecessor?.value + guard let self, + await isCurrentLifecycleRequest(cleanupGeneration) + else { return } + await performDeactivate(lifecycleRequestGeneration: cleanupGeneration) + } + lifecycleTail = cleanup + } + + private func performUpdate( + query: AircraftQuery, + quiet: Bool, + token: AircraftPollingActivationToken, + lifecycleRequestGeneration: UInt64, + ) async { + guard let activePolling else { + publish(.inactive) + return + } + await replace( + configuration: activePolling.configuration, + query: query, + quiet: quiet, + token: token, + lifecycleRequestGeneration: lifecycleRequestGeneration, + ) + } + + private func performDeactivate(lifecycleRequestGeneration: UInt64) async { + generation &+= 1 + activePolling = nil + activePublicationRevision = 0 + publish(.inactive) + let oldTask = pollTask + pollTask = nil + oldTask?.cancel() + await oldTask?.value + guard Task.isCancelled == false, + isCurrentLifecycleRequest(lifecycleRequestGeneration) + else { return } + publish(.inactive) + } + + private func replace( + configuration: AircraftSourceConfiguration, + query: AircraftQuery, + quiet: Bool, + token: AircraftPollingActivationToken, + lifecycleRequestGeneration: UInt64, + ) async { + generation &+= 1 + let replacementGeneration = generation + activePolling = nil + activePublicationRevision = 0 + publish(.inactive) + let oldTask = pollTask + pollTask = nil + oldTask?.cancel() + await oldTask?.value + guard Task.isCancelled == false, + generation == replacementGeneration, + isCurrentLifecycleRequest(lifecycleRequestGeneration) + else { + if isCurrentLifecycleRequest(lifecycleRequestGeneration) { + activePolling = nil + publish(.inactive) + } + return + } + + activePolling = ActivePolling( + token: token, + configuration: configuration, + query: query, + ) + activePublicationRevision = 0 + guard quiet == false else { + publish(.quiet, token: token) + return + } + publish(.loading(source: configuration.kind), token: token) + pollTask = Task(name: "Throw aircraft polling") { [weak self] in + await self?.run( + configuration: configuration, + query: query, + token: token, + generation: replacementGeneration, + ) + } + } + + private func isCurrentLifecycleRequest(_ requestGeneration: UInt64) -> Bool { + lifecycleRequestGeneration == requestGeneration + } + + private func run( + configuration: AircraftSourceConfiguration, + query: AircraftQuery, + token: AircraftPollingActivationToken, + generation runGeneration: UInt64, + ) async { + var requestCount = 0 + var lastGood: AircraftSnapshot? + defer { + logger.record( + AircraftPollingLogEvent.pollingStopped( + AircraftPollingLogEvent.PollingStop( + source: configuration.kind, + requestCount: requestCount, + decodedAircraftCount: lastGood?.observations.count, + ), + ), + ) + } + + let factoryStartedAt = await clock.now() + let configuredSource: ConfiguredAircraftSource + do { + configuredSource = try await sourceFactory.makeSource(configuration: configuration) + } catch is CancellationError { + return + } catch let failure as AircraftSourceFailure { + let completedAt = await clock.now() + guard generation == runGeneration, Task.isCancelled == false else { return } + recordFactoryFailure( + failure, + configuration: configuration, + startedAt: factoryStartedAt, + completedAt: completedAt, + ) + publish(.failed(failure), token: token) + return + } catch { + let completedAt = await clock.now() + guard generation == runGeneration, Task.isCancelled == false else { return } + let failure = AircraftSourceFailure.invalidConfiguration + recordFactoryFailure( + failure, + configuration: configuration, + startedAt: factoryStartedAt, + completedAt: completedAt, + ) + publish(.failed(failure), token: token) + return + } + guard generation == runGeneration, Task.isCancelled == false else { return } + + var failureCount = 0 + var failureStartedAt: Date? + logger.record( + AircraftPollingLogEvent.sourceActivated( + AircraftPollingLogEvent.SourceActivation(source: configuration.kind), + ), + ) + if let metadataWarning = configuredSource.metadataWarning { + logger.record( + AircraftPollingLogEvent.receiverMetadataFallback( + AircraftPollingLogEvent.ReceiverMetadataFallback( + failureCategory: Self.category(metadataWarning), + ), + ), + ) + } + + while Task.isCancelled == false { + requestCount += 1 + let requestStartedAt = await clock.now() + do { + let snapshot = try await configuredSource.source.snapshot(for: query) + try Task.checkCancellation() + guard generation == runGeneration else { return } + let completedAt = await clock.now() + guard generation == runGeneration else { return } + failureCount = 0 + failureStartedAt = nil + lastGood = snapshot + let nextPollAt = completedAt.addingTimeInterval( + configuredSource.baseCadence.duration.secondsValue, + ) + publish( + .healthy(snapshot: snapshot, nextPollAt: nextPollAt), + token: token, + ) + logger.record( + AircraftPollingLogEvent.requestSucceeded( + AircraftPollingLogEvent.RequestSuccess( + source: configuration.kind, + requestCount: requestCount, + durationMilliseconds: max( + 0, + Int(completedAt.timeIntervalSince(requestStartedAt) * 1000), + ), + httpStatus: snapshot.successfulHTTPStatus, + decodedAircraftCount: snapshot.observations.count, + ), + ), + ) + if let discardedRecords = snapshot.decodingDiagnostics.discardedRecords { + logger.record( + AircraftPollingLogEvent.partialSchemaDrift( + AircraftPollingLogEvent.PartialSchemaDrift( + source: configuration.kind, + requestCount: requestCount, + httpStatus: snapshot.successfulHTTPStatus, + decodedAircraftCount: snapshot.observations.count, + discardedRecords: discardedRecords, + ), + ), + ) + } + try await clock.sleep(for: configuredSource.baseCadence.duration) + } catch is CancellationError { + return + } catch let failure as AircraftSourceFailure { + guard generation == runGeneration else { return } + failureCount += 1 + let completedAt = await clock.now() + guard generation == runGeneration else { return } + let startedAt = failureStartedAt ?? completedAt + failureStartedAt = startedAt + logger.record( + AircraftPollingLogEvent.requestFailed( + AircraftPollingLogEvent.RequestFailure( + source: configuration.kind, + requestCount: requestCount, + durationMilliseconds: max( + 0, + Int(completedAt.timeIntervalSince(requestStartedAt) * 1000), + ), + httpStatus: Self.statusCode(failure), + failureCategory: Self.category(failure), + ), + ), + ) + guard failure.isRetryable else { + publish(.failed(failure), token: token) + return + } + let delay = AircraftPollingBackoff.delay( + baseCadence: configuredSource.baseCadence, + failureCount: failureCount, + retryAfterSeconds: failure.retryAfterSeconds, + ) + let retryAt = completedAt.addingTimeInterval(delay.secondsValue) + publish( + .retrying( + lastGoodSnapshot: lastGood, + failure: failure, + failureStartedAt: startedAt, + nextRetryAt: retryAt, + ), + token: token, + ) + logger.record( + AircraftPollingLogEvent.retryScheduled( + AircraftPollingLogEvent.RetrySchedule( + source: configuration.kind, + requestCount: requestCount, + httpStatus: Self.statusCode(failure), + decodedAircraftCount: lastGood?.observations.count, + backoffSeconds: delay.secondsValue, + failureCategory: Self.category(failure), + ), + ), + ) + do { + try await clock.sleep(for: delay) + } catch { + return + } + } catch { + guard generation == runGeneration else { return } + failureCount += 1 + let completedAt = await clock.now() + guard generation == runGeneration else { return } + let startedAt = failureStartedAt ?? completedAt + failureStartedAt = startedAt + let failure = AircraftSourceFailure.transport(.other) + logger.record( + AircraftPollingLogEvent.requestFailed( + AircraftPollingLogEvent.RequestFailure( + source: configuration.kind, + requestCount: requestCount, + durationMilliseconds: max( + 0, + Int(completedAt.timeIntervalSince(requestStartedAt) * 1000), + ), + httpStatus: Self.statusCode(failure), + failureCategory: Self.category(failure), + ), + ), + ) + let delay = AircraftPollingBackoff.delay( + baseCadence: configuredSource.baseCadence, + failureCount: failureCount, + retryAfterSeconds: nil, + ) + publish( + .retrying( + lastGoodSnapshot: lastGood, + failure: failure, + failureStartedAt: startedAt, + nextRetryAt: completedAt.addingTimeInterval(delay.secondsValue), + ), + token: token, + ) + logger.record( + AircraftPollingLogEvent.retryScheduled( + AircraftPollingLogEvent.RetrySchedule( + source: configuration.kind, + requestCount: requestCount, + httpStatus: Self.statusCode(failure), + decodedAircraftCount: lastGood?.observations.count, + backoffSeconds: delay.secondsValue, + failureCategory: Self.category(failure), + ), + ), + ) + do { + try await clock.sleep(for: delay) + } catch { + return + } + } + } + } + + private func publish( + _ state: AircraftPollingState, + token: AircraftPollingActivationToken, + ) { + guard activePolling?.token == token else { return } + if case let .active(currentUpdate) = update, + currentUpdate.token == token, + currentUpdate.state == state + { + return + } + precondition( + activePublicationRevision < UInt64.max, + "Aircraft polling publication revision overflow", + ) + activePublicationRevision += 1 + publish(.active(AircraftPollingActiveUpdate( + token: token, + revision: .init(rawValue: activePublicationRevision), + state: state, + ))) + } + + private func publish(_ newUpdate: AircraftPollingUpdate) { + guard update != newUpdate else { return } + update = newUpdate + continuation.yield(newUpdate) + } + + private func recordFactoryFailure( + _ failure: AircraftSourceFailure, + configuration: AircraftSourceConfiguration, + startedAt: Date, + completedAt: Date, + ) { + logger.record( + AircraftPollingLogEvent.requestFailed( + AircraftPollingLogEvent.RequestFailure( + source: configuration.kind, + requestCount: 0, + durationMilliseconds: max( + 0, + Int(completedAt.timeIntervalSince(startedAt) * 1000), + ), + httpStatus: Self.statusCode(failure), + failureCategory: Self.category(failure), + ), + ), + ) + } + + private static func statusCode(_ failure: AircraftSourceFailure) -> Int? { + switch failure { + case .invalidCredential: 401 + case .subscriptionRequired: 402 + case .entitlementRejected: 403 + case .quotaReached: 429 + case let .provider(statusCode, _): statusCode + case .invalidConfiguration, .missingCredential, .transport, .decoding: nil + } + } + + private static func category( + _ failure: AircraftSourceFailure, + ) -> AircraftPollingLogEvent.FailureCategory { + switch failure { + case .invalidConfiguration: .invalidConfiguration + case .missingCredential: .missingCredential + case .invalidCredential: .invalidCredential + case .subscriptionRequired: .subscriptionRequired + case .entitlementRejected: .entitlementRejected + case .quotaReached: .quotaReached + case .provider: .provider + case let .transport(category): + switch category { + case .cancelled: .transportCancelled + case .timedOut: .transportTimedOut + case .offline: .transportOffline + case .localNetworkDenied: .transportLocalNetworkDenied + case .connection: .transportConnection + case .invalidResponse: .transportInvalidResponse + case .other: .transportOther + } + case .decoding: .decoding + } + } +} + +extension Duration { + fileprivate var secondsValue: Double { + let components = components + return Double(components.seconds) + Double(components.attoseconds) / 1e18 + } +} diff --git a/Throw/ThrowCore/Sources/AircraftSourceFactory.swift b/Throw/ThrowCore/Sources/AircraftSourceFactory.swift new file mode 100644 index 000000000..4a55e7dbe --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftSourceFactory.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct ConfiguredAircraftSource: Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let source: any AircraftObservationSource + public let baseCadence: AircraftPollingCadence + public let metadataWarning: AircraftSourceFailure? + + public init( + source: any AircraftObservationSource, + baseCadence: AircraftPollingCadence, + metadataWarning: AircraftSourceFailure?, + ) { + self.source = source + self.baseCadence = baseCadence + self.metadataWarning = metadataWarning + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public protocol AircraftSourceProducing: Sendable { + func makeSource( + configuration: AircraftSourceConfiguration, + ) async throws -> ConfiguredAircraftSource +} + +public struct AircraftSourceFactory: AircraftSourceProducing { + private let cloudTransport: any HTTPTransport + private let localTransport: any HTTPTransport + private let credentialStore: any AircraftCredentialStore + private let dateProvider: any DateProvider + + public init( + cloudTransport: any HTTPTransport, + localTransport: any HTTPTransport, + credentialStore: any AircraftCredentialStore, + dateProvider: any DateProvider, + ) { + self.cloudTransport = cloudTransport + self.localTransport = localTransport + self.credentialStore = credentialStore + self.dateProvider = dateProvider + } + + public func makeSource( + configuration: AircraftSourceConfiguration, + ) async throws -> ConfiguredAircraftSource { + let decoder = ADSBExchangeV2Decoder() + switch configuration { + case .adsbLol: + return try ConfiguredAircraftSource( + source: AdsBLolSource( + transport: cloudTransport, + decoder: decoder, + dateProvider: dateProvider, + ), + baseCadence: AircraftPollingCadence(duration: .seconds(10)), + metadataWarning: nil, + ) + case let .readsb(readsbConfiguration): + let source = ReadsbSource( + configuration: readsbConfiguration, + transport: localTransport, + decoder: decoder, + dateProvider: dateProvider, + ) + let timing = try await source.recommendedPollingTiming() + return try ConfiguredAircraftSource( + source: source, + baseCadence: AircraftPollingCadence( + duration: .seconds(timing.intervalSeconds), + ), + metadataWarning: timing.metadataFailure, + ) + case let .adsbExchangeRapidAPI(rapidConfiguration): + guard let credential = try await credentialStore.credential( + for: .rapidAPI, + ) else { + throw AircraftSourceFailure.missingCredential + } + return try ConfiguredAircraftSource( + source: ADSBExchangeRapidAPISource( + transport: cloudTransport, + decoder: decoder, + credential: credential, + dateProvider: dateProvider, + ), + baseCadence: AircraftPollingCadence( + duration: rapidConfiguration.pollingInterval.duration, + ), + metadataWarning: nil, + ) + case let .flightradar24(configuration): + guard let credential = try await credentialStore.credential( + for: .flightradar24, + ) else { + throw AircraftSourceFailure.missingCredential + } + return try ConfiguredAircraftSource( + source: Flightradar24Source( + transport: cloudTransport, + decoder: Flightradar24Decoder(), + credential: credential, + dateProvider: dateProvider, + ), + baseCadence: AircraftPollingCadence( + duration: configuration.pollingInterval.duration, + ), + metadataWarning: nil, + ) + } + } +} diff --git a/Throw/ThrowCore/Sources/AircraftSourceSelection.swift b/Throw/ThrowCore/Sources/AircraftSourceSelection.swift new file mode 100644 index 000000000..fa4ca45fc --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftSourceSelection.swift @@ -0,0 +1,54 @@ +/// One valid relationship between a selected aircraft source and its validation state. +public enum AircraftSourceSelection: Equatable, Sendable { + case unconfigured + case awaitingValidation(AircraftSourceConfiguration) + case configured(AircraftSourceConfiguration) + + public init( + selectedSource: AircraftSourceConfiguration?, + validatedSource: AircraftSourceConfiguration?, + ) throws { + switch (selectedSource, validatedSource) { + case (nil, nil): + self = .unconfigured + case let (selected?, nil): + self = .awaitingValidation(selected) + case let (selected?, validated?) where selected == validated: + self = .configured(selected) + case (nil, .some), (.some, .some): + throw ThrowValidationError.invalidPreferencePayload + } + } + + public var selectedSource: AircraftSourceConfiguration? { + switch self { + case .unconfigured: + nil + case let .awaitingValidation(configuration), + let .configured(configuration): + configuration + } + } + + public var validatedSource: AircraftSourceConfiguration? { + switch self { + case .unconfigured, .awaitingValidation: + nil + case let .configured(configuration): + configuration + } + } + + public var configuredSource: AircraftSourceConfiguration? { + switch self { + case .unconfigured, .awaitingValidation: + nil + case let .configured(configuration): + configuration + } + } + + public var isConfigured: Bool { + configuredSource != nil + } +} diff --git a/Throw/ThrowCore/Sources/AircraftSourceService.swift b/Throw/ThrowCore/Sources/AircraftSourceService.swift new file mode 100644 index 000000000..b1a0b1948 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftSourceService.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Provider-neutral operations used by source setup without exposing concrete adapters. +public protocol AircraftSourceOperationServing: Sendable { + func testConnection( + request: AircraftSourceValidationRequest, + ) async throws -> AircraftSnapshot + + func flightradar24Usage( + period: Flightradar24UsagePeriod, + ) async throws -> Flightradar24UsageReport +} + +/// Keeps provider construction and capability dispatch inside ThrowCore. +public struct AircraftSourceService: AircraftSourceOperationServing { + private let sourceFactory: any AircraftSourceProducing + private let cloudTransport: any HTTPTransport + private let credentialStore: any AircraftCredentialStore + private let dateProvider: any DateProvider + + public init( + sourceFactory: any AircraftSourceProducing, + cloudTransport: any HTTPTransport, + credentialStore: any AircraftCredentialStore, + dateProvider: any DateProvider, + ) { + self.sourceFactory = sourceFactory + self.cloudTransport = cloudTransport + self.credentialStore = credentialStore + self.dateProvider = dateProvider + } + + public func testConnection( + request: AircraftSourceValidationRequest, + ) async throws -> AircraftSnapshot { + switch request.draft { + case .adsbLol, .readsb: + let configured = try await sourceFactory.makeSource( + configuration: request.draft.configuration, + ) + return try await configured.source.snapshot(for: request.query) + case let .adsbExchangeRapidAPI(_, replacementCredential): + let credential = try await credential( + replacement: replacementCredential, + id: .rapidAPI, + ) + return try await ADSBExchangeRapidAPISource( + transport: cloudTransport, + decoder: ADSBExchangeV2Decoder(), + credential: credential, + dateProvider: dateProvider, + ).credentialTestSnapshot(observer: request.query.observer) + case let .flightradar24(_, replacementCredential): + let credential = try await credential( + replacement: replacementCredential, + id: .flightradar24, + ) + return try await Flightradar24Source( + transport: cloudTransport, + decoder: Flightradar24Decoder(), + credential: credential, + dateProvider: dateProvider, + ).credentialTestSnapshot(observer: request.query.observer) + } + } + + public func flightradar24Usage( + period: Flightradar24UsagePeriod, + ) async throws -> Flightradar24UsageReport { + let credential = try await credential( + replacement: nil, + id: .flightradar24, + ) + return try await Flightradar24Source( + transport: cloudTransport, + decoder: Flightradar24Decoder(), + credential: credential, + dateProvider: dateProvider, + ).usage(period: period) + } + + private func credential( + replacement: AircraftCredential?, + id: AircraftCredentialID, + ) async throws -> AircraftCredential { + if let replacement { + return replacement + } + guard let stored = try await credentialStore.credential(for: id) else { + throw AircraftSourceFailure.missingCredential + } + return stored + } +} diff --git a/Throw/ThrowCore/Sources/AircraftSourceValidationRequest.swift b/Throw/ThrowCore/Sources/AircraftSourceValidationRequest.swift new file mode 100644 index 000000000..db513faf0 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftSourceValidationRequest.swift @@ -0,0 +1,77 @@ +/// One source candidate that can be tested before it becomes active. +public enum AircraftSourceValidationDraft: Equatable, Sendable { + case adsbLol + case readsb(ReadsbConfiguration) + case adsbExchangeRapidAPI( + ADSBExchangeConfiguration, + replacementCredential: AircraftCredential?, + ) + case flightradar24( + Flightradar24Configuration, + replacementCredential: AircraftCredential?, + ) + + public init(configuration: AircraftSourceConfiguration) { + switch configuration { + case .adsbLol: + self = .adsbLol + case let .readsb(configuration): + self = .readsb(configuration) + case let .adsbExchangeRapidAPI(configuration): + self = .adsbExchangeRapidAPI( + configuration, + replacementCredential: nil, + ) + case let .flightradar24(configuration): + self = .flightradar24( + configuration, + replacementCredential: nil, + ) + } + } + + public var configuration: AircraftSourceConfiguration { + switch self { + case .adsbLol: + .adsbLol + case let .readsb(configuration): + .readsb(configuration) + case let .adsbExchangeRapidAPI(configuration, _): + .adsbExchangeRapidAPI(configuration) + case let .flightradar24(configuration, _): + .flightradar24(configuration) + } + } + + public var credentialReplacement: AircraftCredentialReplacement? { + switch self { + case .adsbLol, .readsb: + nil + case let .adsbExchangeRapidAPI(_, replacementCredential): + replacementCredential.map { + AircraftCredentialReplacement(id: .rapidAPI, credential: $0) + } + case let .flightradar24(_, replacementCredential): + replacementCredential.map { + AircraftCredentialReplacement(id: .flightradar24, credential: $0) + } + } + } +} + +/// A provider-owned credential replacement with its fixed Keychain identity. +public struct AircraftCredentialReplacement: Equatable, Sendable { + public let id: AircraftCredentialID + public let credential: AircraftCredential +} + +/// A closed source candidate paired with the query used to test it. +public struct AircraftSourceValidationRequest: Equatable, Sendable { + public let draft: AircraftSourceValidationDraft + public let query: AircraftQuery + + public init(draft: AircraftSourceValidationDraft, query: AircraftQuery) { + self.draft = draft + self.query = query + } +} diff --git a/Throw/ThrowCore/Sources/AircraftTypeCatalog.swift b/Throw/ThrowCore/Sources/AircraftTypeCatalog.swift new file mode 100644 index 000000000..437730b0e --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftTypeCatalog.swift @@ -0,0 +1,89 @@ +import Foundation + +public enum AircraftWakeCategory: String, Hashable, Sendable { + case light = "L" + case medium = "M" + case heavy = "H" + case superHeavy = "J" +} + +public struct AircraftTypeCharacteristics: Hashable, Sendable { + public let airframeCode: Character + public let engineCode: Character + public let wakeCategory: AircraftWakeCategory? + + public init( + airframeCode: Character, + engineCode: Character, + wakeCategory: AircraftWakeCategory?, + ) { + self.airframeCode = airframeCode + self.engineCode = engineCode + self.wakeCategory = wakeCategory + } +} + +/// The compact, bundled lookup from ICAO designator to physical characteristics. +public struct AircraftTypeCatalog: Sendable { + public static let bundled: AircraftTypeCatalog = { + do { + guard let url = Bundle.module.url( + forResource: "aircraft-types-v1", + withExtension: "json", + ) else { + preconditionFailure("ThrowCore is missing aircraft-types-v1.json") + } + return try AircraftTypeCatalog(data: Data(contentsOf: url)) + } catch { + preconditionFailure("ThrowCore's aircraft type catalog is invalid: \(error)") + } + }() + + private let entries: [String: AircraftTypeCharacteristics] + + public init(data: Data) throws { + let archive = try JSONDecoder().decode(Archive.self, from: data) + guard archive.version == 1 else { throw AircraftTypeCatalogError.unsupportedVersion } + var decoded: [String: AircraftTypeCharacteristics] = [:] + for (designator, record) in archive.types { + guard let type = AircraftTypeDesignator(rawValue: designator), + record.description.count == 3, + let airframe = record.description.first, + let engine = record.description.last, + record.wake == "-" || AircraftWakeCategory(rawValue: record.wake) != nil + else { throw AircraftTypeCatalogError.invalidRecord } + decoded[type.rawValue] = AircraftTypeCharacteristics( + airframeCode: airframe, + engineCode: engine, + wakeCategory: AircraftWakeCategory(rawValue: record.wake), + ) + } + entries = decoded + } + + public func characteristics( + for designator: AircraftTypeDesignator, + ) -> AircraftTypeCharacteristics? { + entries[designator.rawValue] + } + + private struct Archive: Decodable { + let version: Int + let types: [String: Record] + } + + private struct Record: Decodable { + let description: String + let wake: String + + enum CodingKeys: String, CodingKey { + case description = "d" + case wake = "w" + } + } +} + +public enum AircraftTypeCatalogError: Error, Equatable, Sendable { + case unsupportedVersion + case invalidRecord +} diff --git a/Throw/ThrowCore/Sources/AircraftVisualClassifier.swift b/Throw/ThrowCore/Sources/AircraftVisualClassifier.swift new file mode 100644 index 000000000..7146e0e17 --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftVisualClassifier.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Converts provider metadata into stable, provider-neutral projection semantics. +public struct AircraftVisualClassifier: Sendable { + private static let regionalAndBusinessJets: Set = [ + "C25A", + "C25B", + "C25C", + "C510", + "C525", + "C550", + "C560", + "C56X", + "C680", + "C700", + "C750", + "CL30", + "CL35", + "CL60", + "CRJ1", + "CRJ2", + "CRJ7", + "CRJ9", + "CRJX", + "E135", + "E145", + "E170", + "E190", + "E195", + "E50P", + "E55P", + "E75L", + "E75S", + "F2TH", + "F900", + "FA7X", + "FA8X", + "GLEX", + "GL5T", + "GL6T", + "GL7T", + "GLF4", + "GLF5", + "GLF6", + "LJ31", + "LJ35", + "LJ40", + "LJ45", + "LJ60", + "LJ70", + "LJ75", + "P300", + ] + + private let catalog: AircraftTypeCatalog + + public init(catalog: AircraftTypeCatalog) { + self.catalog = catalog + } + + public func descriptor(for observation: AircraftObservation) -> AircraftGlyphDescriptor { + descriptor(for: observation, activity: .overflight) + } + + public func descriptor( + for observation: AircraftObservation, + activity: FlightActivity, + ) -> AircraftGlyphDescriptor { + AircraftGlyphDescriptor( + family: family( + designator: observation.aircraftType, + emitterCategory: observation.emitterCategory, + ), + brand: AirlineBrand.identify(designator: observation.airlineDesignator) + ?? AirlineBrand.identify(callsign: observation.callsign), + isGrounded: observation.airborneState == .ground, + activity: activity, + ) + } + + public func family( + designator: AircraftTypeDesignator?, + emitterCategory: AircraftEmitterCategory?, + ) -> AircraftVisualFamily { + if let designator, let characteristics = catalog.characteristics(for: designator) { + let airframe = characteristics.airframeCode + let engine = characteristics.engineCode + if ["H", "G", "T"].contains(airframe) { + return emitterCategory.map { $0 == .rotorcraft ? .helicopter : .unknown } + ?? .helicopter + } + if ["L", "A", "S"].contains(airframe), ["P", "T", "E"].contains(engine) { + return emitterCategory == .rotorcraft ? .unknown : .propeller + } + if engine == "J" { + if emitterCategory == .rotorcraft { return .unknown } + if Self.regionalAndBusinessJets.contains(designator.rawValue) { + return .regionalBusinessJet + } + switch characteristics.wakeCategory { + case .heavy, .superHeavy: return .heavyJet + case .medium: return .airliner + case .light: return .regionalBusinessJet + case nil: break + } + } + } + + return switch emitterCategory { + case .rotorcraft: .helicopter + case .heavy: .heavyJet + case .large, .highVortexLarge: .airliner + case .light, .small, .highPerformance: .regionalBusinessJet + case .noInformation, .glider, .lighterThanAir, .parachutist, .ultralight, + .unmanned, .spaceVehicle, .emergencySurface, .serviceSurface, + .pointObstacle, nil: .unknown + } + } +} diff --git a/Throw/ThrowCore/Sources/AircraftVisualModels.swift b/Throw/ThrowCore/Sources/AircraftVisualModels.swift new file mode 100644 index 000000000..c9142bf5e --- /dev/null +++ b/Throw/ThrowCore/Sources/AircraftVisualModels.swift @@ -0,0 +1,87 @@ +import Foundation + +/// The six silhouettes that Throw can draw without relying on color. +public enum AircraftVisualFamily: String, CaseIterable, Hashable, Sendable { + case heavyJet = "heavy-jet" + case airliner + case regionalBusinessJet = "regional-business-jet" + case propeller + case helicopter + case unknown + + public var sizeMultiplier: Double { + switch self { + case .heavyJet: 1.15 + case .airliner: 1 + case .regionalBusinessJet: 0.95 + case .propeller: 0.9 + case .helicopter: 0.95 + case .unknown: 1 + } + } +} + +/// A carrier with a deliberately curated, logo-free metadata color. +public enum AirlineBrand: String, CaseIterable, Hashable, Sendable { + case alaska = "ASA" + case allegiant = "AAY" + case american = "AAL" + case airCanada = "ACA" + case aeromexico = "AMX" + case avelo = "VXP" + case breeze = "MXY" + case delta = "DAL" + case frontier = "FFT" + case flair = "FLE" + case hawaiian = "HAL" + case jetBlue = "JBU" + case porter = "POE" + case southwest = "SWA" + case spirit = "NKS" + case sunCountry = "SCX" + case airTransat = "TSC" + case united = "UAL" + case westJet = "WJA" + case volaris = "VOI" + case vivaAerobus = "VIV" + case fedEx = "FDX" + case ups = "UPS" + + public static func identify(callsign: String?) -> AirlineBrand? { + guard let callsign else { return nil } + let normalized = callsign.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard normalized.count >= 3 else { return nil } + return AirlineBrand(rawValue: String(normalized.prefix(3))) + } + + public static func identify(designator: AirlineICAODesignator?) -> AirlineBrand? { + designator.flatMap { AirlineBrand(rawValue: $0.rawValue) } + } +} + +/// Provider-neutral visual semantics carried into the projection renderer. +public struct AircraftGlyphDescriptor: Hashable, Sendable { + public let family: AircraftVisualFamily + public let brand: AirlineBrand? + public let isGrounded: Bool + public let activity: FlightActivity + + public init( + family: AircraftVisualFamily, + brand: AirlineBrand?, + isGrounded: Bool, + activity: FlightActivity, + ) { + self.family = family + self.brand = brand + self.isGrounded = isGrounded + self.activity = activity + } + + public static let unknownAirborne = AircraftGlyphDescriptor( + family: .unknown, + brand: nil, + isGrounded: false, + activity: .overflight, + ) +} diff --git a/Throw/ThrowCore/Sources/AirportCatalog.swift b/Throw/ThrowCore/Sources/AirportCatalog.swift new file mode 100644 index 000000000..c048e07f8 --- /dev/null +++ b/Throw/ThrowCore/Sources/AirportCatalog.swift @@ -0,0 +1,173 @@ +import Foundation + +/// An immutable, spatially indexed catalog of airports and open runways. +public struct AirportCatalog: Sendable { + public static let bundled: AirportCatalog = { + guard let url = Bundle.module.url(forResource: "airports-v1", withExtension: "json"), + let data = try? Data(contentsOf: url), + let archive = try? JSONDecoder().decode(Archive.self, from: data), + let catalog = try? AirportCatalog(archive: archive) + else { + preconditionFailure("The bundled airport catalog is invalid") + } + return catalog + }() + + public let airports: [AirportRecord] + private let byCode: [AirportCode: [AirportRecord]] + private let spatialIndex: [Cell: [AirportRecord]] + + public init(airports: [AirportRecord]) { + self.airports = airports + var index: [AirportCode: [AirportRecord]] = [:] + for airport in airports { + for code in airport.codes { + index[code, default: []].append(airport) + } + } + byCode = index + spatialIndex = Dictionary(grouping: airports) { Cell(coordinate: $0.coordinate) } + } + + public func airport( + for code: AirportCode, + near observer: ObserverPosition, + ) throws -> AirportRecord? { + var nearest: (airport: AirportRecord, distance: NauticalMiles)? + for airport in byCode[code] ?? [] { + let candidateDistance = try distance( + from: observer.coordinate, + to: airport.coordinate, + ) + if nearest == nil || candidateDistance < nearest!.distance { + nearest = (airport, candidateDistance) + } + } + return nearest?.airport + } + + public func airports( + within radius: NauticalMiles, + of coordinate: GeoCoordinate, + ) throws -> [AirportRecord] { + let latitudeSpan = Int(ceil(radius.value / 60)) + 1 + let longitudeScale = max(0.05, cos(coordinate.latitude * .pi / 180)) + let longitudeSpan = min(180, Int(ceil(radius.value / (60 * longitudeScale))) + 1) + let center = Cell(coordinate: coordinate) + var candidates: [AirportRecord] = [] + for latitude in max(-90, center.latitude - latitudeSpan) ... + min(89, center.latitude + latitudeSpan) + { + for offset in -longitudeSpan ... longitudeSpan { + var longitude = center.longitude + offset + while longitude < -180 { + longitude += 360 + } + while longitude > 179 { + longitude -= 360 + } + candidates.append(contentsOf: spatialIndex[Cell( + latitude: latitude, + longitude: longitude, + )] ?? []) + } + } + return try candidates.filter { try distance(from: coordinate, to: $0.coordinate) <= radius } + } + + public func distance( + from source: GeoCoordinate, + to target: GeoCoordinate, + ) throws -> NauticalMiles { + try ProjectionEngine().greatCirclePosition(from: source, to: target).distance + } + + private init(archive: Archive) throws { + guard archive.v == 1 else { throw AirportCatalogError.unsupportedVersion } + let records = try archive.airports.map { row -> AirportRecord in + guard row.count == 6, + let id = row[0].int, + let latitude = row[1].double, + let longitude = row[2].double, + let codeRows = row[4].strings, + let runwayRows = row[5].rows + else { throw AirportCatalogError.invalidRecord } + let elevation = try row[3].double.map(Altitude.init(feet:)) + let codes = codeRows.compactMap(AirportCode.init(rawValue:)) + let runways = try runwayRows.map { runway -> RunwayRecord in + guard runway.count == 6, + let runwayID = runway[0].int, + let length = runway[1].int, + let leLatitude = runway[2].double, + let leLongitude = runway[3].double, + let heLatitude = runway[4].double, + let heLongitude = runway[5].double + else { throw AirportCatalogError.invalidRecord } + return try RunwayRecord( + id: runwayID, + lengthFeet: length, + lowEnd: GeoCoordinate(latitude: leLatitude, longitude: leLongitude), + highEnd: GeoCoordinate(latitude: heLatitude, longitude: heLongitude), + ) + } + return try AirportRecord( + id: AirportID(rawValue: id), + coordinate: GeoCoordinate(latitude: latitude, longitude: longitude), + elevation: elevation, + codes: codes, + runways: runways, + ) + } + self.init(airports: records) + } +} + +private struct Cell: Hashable { + let latitude: Int + let longitude: Int + + init(coordinate: GeoCoordinate) { + latitude = min(89, Int(floor(coordinate.latitude))) + longitude = coordinate.longitude == 180 ? 179 : Int(floor(coordinate.longitude)) + } + + init(latitude: Int, longitude: Int) { + self.latitude = latitude + self.longitude = longitude + } +} + +private enum AirportCatalogError: Error { case unsupportedVersion, invalidRecord } + +private struct Archive: Decodable { let v: Int; let revision: String; let airports: [[JSONValue]] } + +private enum JSONValue: Decodable { + case number(Double), string(String), array([JSONValue]), null + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else { self = try .array(container.decode([JSONValue].self)) } + } + + var double: Double? { + if case let .number(value) = self { value } else { nil } + } + + var int: Int? { + double.map(Int.init) + } + + var strings: [String]? { + if case let .array(values) = self { + values.compactMap { if case let .string(value) = $0 { value } else { nil } } + } else { nil } + } + + var rows: [[JSONValue]]? { + if case let .array(values) = self { + values.compactMap { if case let .array(value) = $0 { value } else { nil } } + } else { nil } + } +} diff --git a/Throw/ThrowCore/Sources/AirportModels.swift b/Throw/ThrowCore/Sources/AirportModels.swift new file mode 100644 index 000000000..012b1aa57 --- /dev/null +++ b/Throw/ThrowCore/Sources/AirportModels.swift @@ -0,0 +1,126 @@ +import Foundation + +public struct AirportID: Hashable, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + precondition(rawValue > 0, "An airport ID must be positive") + self.rawValue = rawValue + } + + public var layerMarkID: LayerMarkID { + .airport(self) + } +} + +public struct RunwayRecord: Hashable, Sendable { + public let id: Int + public let lengthFeet: Int + public let lowEnd: GeoCoordinate + public let highEnd: GeoCoordinate + + public init(id: Int, lengthFeet: Int, lowEnd: GeoCoordinate, highEnd: GeoCoordinate) { + precondition(id > 0 && lengthFeet > 0) + self.id = id + self.lengthFeet = lengthFeet + self.lowEnd = lowEnd + self.highEnd = highEnd + } +} + +public struct AirportRecord: Hashable, Sendable { + public let id: AirportID + public let coordinate: GeoCoordinate + public let elevation: Altitude? + public let codes: [AirportCode] + public let runways: [RunwayRecord] + + public init( + id: AirportID, + coordinate: GeoCoordinate, + elevation: Altitude?, + codes: [AirportCode], + runways: [RunwayRecord], + ) { + precondition(codes.isEmpty == false) + self.id = id + self.coordinate = coordinate + self.elevation = elevation + self.codes = codes + self.runways = runways + } + + public var displayCode: AirportCode { + codes.sorted { + if $0.rawValue.count != $1.rawValue.count { + return $0.rawValue.count < $1.rawValue.count + } + return $0.rawValue < $1.rawValue + }.first! + } + + public var longestOpenRunway: RunwayRecord? { + runways.max { $0.lengthFeet < $1.lengthFeet } + } +} + +public enum FlightActivityStage: String, Hashable, Sendable { + case inbound + case approach + case outbound + case initialClimb = "initial-climb" +} + +public enum FlightActivityCertainty: String, Hashable, Sendable { + case confirmed + case inferred +} + +public struct AirportActivityContext: Hashable, Sendable { + public let airport: AirportRecord + public let aircraftDistance: NauticalMiles + + public init(airport: AirportRecord, aircraftDistance: NauticalMiles) { + self.airport = airport + self.aircraftDistance = aircraftDistance + } +} + +public enum FlightActivity: Hashable, Sendable { + case overflight + case arrival(AirportActivityContext, FlightActivityStage, FlightActivityCertainty) + case departure(AirportActivityContext, FlightActivityStage, FlightActivityCertainty) + + public var airportContext: AirportActivityContext? { + switch self { + case .overflight: nil + case let .arrival(context, _, _), let .departure(context, _, _): context + } + } + + public var certainty: FlightActivityCertainty? { + switch self { + case .overflight: nil + case let .arrival(_, _, certainty), let .departure(_, _, certainty): certainty + } + } +} + +public struct AirportGlyphDescriptor: Hashable, Sendable { + public let airportID: AirportID + public let code: AirportCode? + public let runwayBearing: Bearing? + public let certainty: FlightActivityCertainty + + public init( + airportID: AirportID, + code: AirportCode?, + runwayBearing: Bearing?, + certainty: FlightActivityCertainty, + ) { + self.airportID = airportID + self.code = code + self.runwayBearing = runwayBearing + self.certainty = certainty + } +} diff --git a/Throw/ThrowCore/Sources/CloudAircraftQuery.swift b/Throw/ThrowCore/Sources/CloudAircraftQuery.swift new file mode 100644 index 000000000..5a8a67f1a --- /dev/null +++ b/Throw/ThrowCore/Sources/CloudAircraftQuery.swift @@ -0,0 +1,103 @@ +import Foundation + +public struct CloudAircraftQueryPlan: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let coarseCenter: GeoCoordinate + public let transmittedRadius: NauticalMiles + + public init(coarseCenter: GeoCoordinate, transmittedRadius: NauticalMiles) { + self.coarseCenter = coarseCenter + self.transmittedRadius = transmittedRadius + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public enum CloudAircraftQuery { + public static let paddingNauticalMiles = 10.0 + public static let maximumRadiusNauticalMiles = 250.0 + public static let trueSkyGroundRangeNauticalMiles = 240.0 + + public static func plan(for query: AircraftQuery) throws -> CloudAircraftQueryPlan { + let latitude = roundedTenth(query.center.latitude) + let longitude = min(180, max(-180, roundedTenth(query.center.longitude))) + let radius: Double = switch query.viewport { + case let .map(viewport): + min( + viewport.radius.value + paddingNauticalMiles, + maximumRadiusNauticalMiles, + ) + case .trueSky: + maximumRadiusNauticalMiles + } + return try CloudAircraftQueryPlan( + coarseCenter: GeoCoordinate(latitude: latitude, longitude: longitude), + transmittedRadius: NauticalMiles(value: radius), + ) + } + + public static func postFilter( + _ observations: [AircraftObservation], + for query: AircraftQuery, + ) throws -> [AircraftObservation] { + let engine = ProjectionEngine() + var filtered: [AircraftObservation] = [] + filtered.reserveCapacity(observations.count) + for observation in observations { + try Task.checkCancellation() + switch query.viewport { + case let .map(viewport): + if observation.airborneState == .ground, query.includeGroundAircraft == false { + continue + } + let position = try engine.greatCirclePosition( + from: query.center, + to: observation.coordinate, + ) + if position.distance <= viewport.radius { + filtered.append(observation) + } + case let .trueSky(viewport): + guard observation.airborneState != .ground else { continue } + guard case let .available(altitude, quality) = observation.skyAltitude else { + continue + } + let groundPosition = try engine.greatCirclePosition( + from: query.observer.coordinate, + to: observation.coordinate, + ) + guard groundPosition.distance.value <= trueSkyGroundRangeNauticalMiles else { + continue + } + let anchor = GeodeticAnchor( + coordinate: observation.coordinate, + altitude: .available(altitude, quality: quality), + ) + guard let horizontal = try engine.horizontalPosition( + observer: query.observer, + target: anchor, + ), horizontal.elevation.degrees >= viewport.minimumElevation.degrees + else { + continue + } + filtered.append(observation) + } + } + return filtered + } + + public static func pathComponent(for value: Double) -> String { + String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value) + } + + private static func roundedTenth(_ value: Double) -> Double { + (value * 10).rounded() / 10 + } +} diff --git a/Throw/ThrowCore/Sources/DateProvider.swift b/Throw/ThrowCore/Sources/DateProvider.swift new file mode 100644 index 000000000..e293437fc --- /dev/null +++ b/Throw/ThrowCore/Sources/DateProvider.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol DateProvider: Sendable { + func now() -> Date +} + +public struct SystemDateProvider: DateProvider { + public init() {} + + public func now() -> Date { + Date() + } +} diff --git a/Throw/ThrowCore/Sources/DomainValues.swift b/Throw/ThrowCore/Sources/DomainValues.swift new file mode 100644 index 000000000..1b7784f5a --- /dev/null +++ b/Throw/ThrowCore/Sources/DomainValues.swift @@ -0,0 +1,217 @@ +import Foundation + +public enum ThrowValidationError: Error, Equatable, Sendable { + case nonFiniteValue(field: String) + case outOfRange(field: String, closedRange: ClosedRange) + case invalidQuietInterval + case invalidURL + case invalidPreferencePayload +} + +/// A validated WGS84 latitude/longitude pair in decimal degrees. +public struct GeoCoordinate: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let latitude: Double + public let longitude: Double + + public init(latitude: Double, longitude: Double) throws { + guard latitude.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "latitude") + } + guard longitude.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "longitude") + } + guard (-90.0 ... 90.0).contains(latitude) else { + throw ThrowValidationError.outOfRange(field: "latitude", closedRange: -90 ... 90) + } + guard (-180.0 ... 180.0).contains(longitude) else { + throw ThrowValidationError.outOfRange(field: "longitude", closedRange: -180 ... 180) + } + self.latitude = latitude + self.longitude = longitude + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// A mean-sea-level altitude represented in feet. +public struct Altitude: Hashable, Sendable { + public static let allowedFeet = -2000.0 ... 100_000.0 + + public let feet: Double + + public init(feet: Double) throws { + guard feet.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "altitude") + } + guard Self.allowedFeet.contains(feet) else { + throw ThrowValidationError.outOfRange( + field: "altitude", + closedRange: Self.allowedFeet, + ) + } + self.feet = feet + } + + public var meters: Double { + feet * 0.3048 + } +} + +public struct ObserverPosition: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let coordinate: GeoCoordinate + public let altitude: Altitude + + public init(coordinate: GeoCoordinate, altitude: Altitude) { + self.coordinate = coordinate + self.altitude = altitude + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// A true-geographic bearing normalized into `0 ..< 360` degrees. +public struct Bearing: Hashable, Sendable { + public let degrees: Double + + public init(degrees: Double) throws { + guard degrees.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "bearing") + } + let remainder = degrees.truncatingRemainder(dividingBy: 360) + self.degrees = remainder >= 0 ? remainder : remainder + 360 + } +} + +public struct ElevationAngle: Hashable, Sendable { + public let degrees: Double + + public init(degrees: Double) throws { + guard degrees.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "elevation") + } + guard (-90.0 ... 90.0).contains(degrees) else { + throw ThrowValidationError.outOfRange(field: "elevation", closedRange: -90 ... 90) + } + self.degrees = degrees + } +} + +public struct NauticalMiles: Hashable, Comparable, Sendable { + public let value: Double + + public init(value: Double) throws { + guard value.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "nauticalMiles") + } + guard (0.0 ... 20000.0).contains(value) else { + throw ThrowValidationError.outOfRange( + field: "nauticalMiles", + closedRange: 0 ... 20000, + ) + } + self.value = value + } + + public static func < (lhs: NauticalMiles, rhs: NauticalMiles) -> Bool { + lhs.value < rhs.value + } + + public var meters: Double { + value * 1852 + } +} + +public enum ProjectionMode: String, CaseIterable, Codable, Hashable, Sendable { + case map + case trueSky = "true-sky" +} + +public struct MapViewport: Hashable, Sendable { + public static let allowedRadius = 5.0 ... 240.0 + public static let defaultValue = try! MapViewport(radius: NauticalMiles(value: 50)) + + public let radius: NauticalMiles + + public init(radius: NauticalMiles) throws { + guard Self.allowedRadius.contains(radius.value), radius.value.rounded() == radius.value, + Int(radius.value).isMultiple(of: 5) + else { + throw ThrowValidationError.outOfRange( + field: "mapRadius", + closedRange: Self.allowedRadius, + ) + } + self.radius = radius + } +} + +public struct SkyViewport: Hashable, Sendable { + public static let allowedMinimumElevation = 0.0 ... 45.0 + public static let defaultValue = try! SkyViewport( + minimumElevation: ElevationAngle(degrees: 10), + ) + + public let minimumElevation: ElevationAngle + + public init(minimumElevation: ElevationAngle) throws { + guard Self.allowedMinimumElevation.contains(minimumElevation.degrees), + minimumElevation.degrees.rounded() == minimumElevation.degrees + else { + throw ThrowValidationError.outOfRange( + field: "minimumElevation", + closedRange: Self.allowedMinimumElevation, + ) + } + self.minimumElevation = minimumElevation + } +} + +public enum ProjectionViewport: Hashable, Sendable { + case map(MapViewport) + case trueSky(SkyViewport) + + public var mode: ProjectionMode { + switch self { + case .map: .map + case .trueSky: .trueSky + } + } +} + +public struct PollingInterval: Hashable, Sendable { + public static let allowedSeconds = 5 ... 300 + public static let defaultValue = try! PollingInterval(seconds: 10) + + public let seconds: Int + + public init(seconds: Int) throws { + guard Self.allowedSeconds.contains(seconds) else { + throw ThrowValidationError.outOfRange( + field: "pollingInterval", + closedRange: Double(Self.allowedSeconds.lowerBound) ... + Double(Self.allowedSeconds.upperBound), + ) + } + self.seconds = seconds + } + + public var duration: Duration { + .seconds(seconds) + } +} diff --git a/Throw/ThrowCore/Sources/FlightActivityClassifier.swift b/Throw/ThrowCore/Sources/FlightActivityClassifier.swift new file mode 100644 index 000000000..ccb1b2131 --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightActivityClassifier.swift @@ -0,0 +1,311 @@ +import Foundation + +/// Classifies ambient arrival and departure estimates from route and motion data. +public struct FlightActivityClassifier: Sendable { + public static let localRadius = try! NauticalMiles(value: 50) + private static let inferredRadius = try! NauticalMiles(value: 15) + + private let airportCatalog: AirportCatalog + private let engine = ProjectionEngine() + + public init(airportCatalog: AirportCatalog) { + self.airportCatalog = airportCatalog + } + + public func activity( + for observation: AircraftObservation, + observer: ObserverPosition, + route: FlightRoute?, + motion: AircraftMotion, + ) throws -> FlightActivity { + guard observation.airborneState != .ground else { return .overflight } + if let route, + let confirmed = try confirmedActivity( + observation: observation, + observer: observer, + route: route, + motion: motion, + ) + { + return confirmed + } + return try inferredActivity(observation: observation, motion: motion) ?? .overflight + } + + private func confirmedActivity( + observation: AircraftObservation, + observer: ObserverPosition, + route: FlightRoute, + motion: AircraftMotion, + ) throws -> FlightActivity? { + let origin = try localAirport(for: route.origin, observer: observer) + let destination = try localAirport(for: route.destination, observer: observer) + switch (origin, destination) { + case (nil, nil): return nil + case let (origin?, nil): return try departure( + observation, + airport: origin, + certainty: .confirmed, + motion: motion, + ) + case let (nil, destination?): return try arrival( + observation, + airport: destination, + certainty: .confirmed, + motion: motion, + ) + case let (origin?, destination?): + return try chooseBothLocal( + observation, + origin: origin, + destination: destination, + motion: motion, + ) + } + } + + private func localAirport( + for code: AirportCode, + observer: ObserverPosition, + ) throws -> AirportRecord? { + guard let airport = try airportCatalog.airport(for: code, near: observer) + else { return nil } + let distance = try airportCatalog.distance( + from: observer.coordinate, + to: airport.coordinate, + ) + return distance <= Self.localRadius ? airport : nil + } + + private func chooseBothLocal( + _ observation: AircraftObservation, + origin: AirportRecord, + destination: AirportRecord, + motion: AircraftMotion, + ) throws -> FlightActivity { + if let verticalRate = motion.verticalRateFeetPerMinute { + if verticalRate <= -200 { return try arrival( + observation, + airport: destination, + certainty: .confirmed, + motion: motion, + ) } + if verticalRate >= 200 { return try departure( + observation, + airport: origin, + certainty: .confirmed, + motion: motion, + ) } + } + if let track = motion.groundTrack { + let towardDestination = try courseDifference( + track, + engine.greatCirclePosition( + from: observation.coordinate, + to: destination.coordinate, + ).initialBearing, + ) + let awayFromOrigin = try courseDifference( + track, + engine.greatCirclePosition( + from: origin.coordinate, + to: observation.coordinate, + ).initialBearing, + ) + if towardDestination != awayFromOrigin { + return towardDestination < awayFromOrigin + ? try arrival( + observation, + airport: destination, + certainty: .confirmed, + motion: motion, + ) + : try departure( + observation, + airport: origin, + certainty: .confirmed, + motion: motion, + ) + } + } + let originDistance = try distance(observation, airport: origin) + let destinationDistance = try distance(observation, airport: destination) + return originDistance <= destinationDistance + ? try departure( + observation, + airport: origin, + certainty: .confirmed, + motion: motion, + ) + : try arrival( + observation, + airport: destination, + certainty: .confirmed, + motion: motion, + ) + } + + private func inferredActivity( + observation: AircraftObservation, + motion: AircraftMotion, + ) throws -> FlightActivity? { + guard let altitude = observation.preferredSkyAltitude, + let verticalRate = motion.verticalRateFeetPerMinute, + abs(verticalRate) >= 300, + let track = motion.groundTrack + else { return nil } + let nearby = try airportCatalog.airports( + within: Self.inferredRadius, + of: observation.coordinate, + ) + var candidates: [(airport: AirportRecord, distance: NauticalMiles, difference: Double)] = [] + for airport in nearby { + guard let airportElevation = airport.elevation else { continue } + let agl = altitude.feet - airportElevation.feet + guard agl >= 0, agl <= 6000 else { continue } + let airportDistance = try distance(observation, airport: airport) + let expectedBearing = try verticalRate < 0 + ? engine.greatCirclePosition( + from: observation.coordinate, + to: airport.coordinate, + ).initialBearing + : engine.greatCirclePosition( + from: airport.coordinate, + to: observation.coordinate, + ).initialBearing + let difference = courseDifference(track, expectedBearing) + guard difference <= 30 else { continue } + if airportDistance.value <= 8, + let runway = airport.longestOpenRunway, + try runwayDifference(track, runway: runway) > 25 + { + continue + } + candidates.append((airport, airportDistance, difference)) + } + guard let selected = candidates.min(by: { + if $0.difference != $1.difference { return $0.difference < $1.difference } + if $0.distance != $1.distance { return $0.distance < $1.distance } + return $0.airport.id.rawValue < $1.airport.id.rawValue + }) else { return nil } + return verticalRate < 0 + ? try arrival( + observation, + airport: selected.airport, + certainty: .inferred, + motion: motion, + ) + : try departure( + observation, + airport: selected.airport, + certainty: .inferred, + motion: motion, + ) + } + + private func arrival( + _ observation: AircraftObservation, + airport: AirportRecord, + certainty: FlightActivityCertainty, + motion: AircraftMotion, + ) throws -> FlightActivity { + let context = try context(observation, airport: airport) + let isClose = context.aircraftDistance.value <= 25 + let isLow = agl(observation, airport: airport).map { $0 <= 10000 } ?? false + let descends = (motion.verticalRateFeetPerMinute ?? 0) <= -200 + let pointsToward = try aligned( + observation, + track: motion.groundTrack, + toward: airport.coordinate, + tolerance: 30, + ) + return .arrival( + context, + isClose && isLow && (descends || pointsToward) ? .approach : .inbound, + certainty, + ) + } + + private func departure( + _ observation: AircraftObservation, + airport: AirportRecord, + certainty: FlightActivityCertainty, + motion: AircraftMotion, + ) throws -> FlightActivity { + let context = try context(observation, airport: airport) + let isClose = context.aircraftDistance.value <= 25 + let isLow = agl(observation, airport: airport).map { $0 <= 10000 } ?? false + let climbs = (motion.verticalRateFeetPerMinute ?? 0) >= 200 + let pointsAway = try aligned( + observation, + track: motion.groundTrack, + awayFrom: airport.coordinate, + tolerance: 30, + ) + return .departure( + context, + isClose && isLow && (climbs || pointsAway) ? .initialClimb : .outbound, + certainty, + ) + } + + private func context( + _ observation: AircraftObservation, + airport: AirportRecord, + ) throws -> AirportActivityContext { + try AirportActivityContext( + airport: airport, + aircraftDistance: distance(observation, airport: airport), + ) + } + + private func distance( + _ observation: AircraftObservation, + airport: AirportRecord, + ) throws -> NauticalMiles { + try airportCatalog.distance(from: observation.coordinate, to: airport.coordinate) + } + + private func agl(_ observation: AircraftObservation, airport: AirportRecord) -> Double? { + guard let altitude = observation.preferredSkyAltitude, + let airportElevation = airport.elevation + else { return nil } + return altitude.feet - airportElevation.feet + } + + private func aligned( + _ observation: AircraftObservation, + track: Bearing?, + toward coordinate: GeoCoordinate, + tolerance: Double, + ) throws -> Bool { + guard let track else { return false } + let bearing = try engine.greatCirclePosition(from: observation.coordinate, to: coordinate) + .initialBearing + return courseDifference(track, bearing) <= tolerance + } + + private func aligned( + _ observation: AircraftObservation, + track: Bearing?, + awayFrom coordinate: GeoCoordinate, + tolerance: Double, + ) throws -> Bool { + guard let track else { return false } + let bearing = try engine.greatCirclePosition(from: coordinate, to: observation.coordinate) + .initialBearing + return courseDifference(track, bearing) <= tolerance + } + + private func runwayDifference(_ track: Bearing, runway: RunwayRecord) throws -> Double { + let bearing = try engine.greatCirclePosition(from: runway.lowEnd, to: runway.highEnd) + .initialBearing + let reverse = try Bearing(degrees: bearing.degrees + 180) + return min(courseDifference(track, bearing), courseDifference(track, reverse)) + } + + private func courseDifference(_ lhs: Bearing, _ rhs: Bearing) -> Double { + let difference = abs(lhs.degrees - rhs.degrees).truncatingRemainder(dividingBy: 360) + return min(difference, 360 - difference) + } +} diff --git a/Throw/ThrowCore/Sources/FlightLayerFrameBuilder.swift b/Throw/ThrowCore/Sources/FlightLayerFrameBuilder.swift new file mode 100644 index 000000000..2e9f1e384 --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightLayerFrameBuilder.swift @@ -0,0 +1,196 @@ +import Foundation + +public struct FlightLayerFrameBuilder: Sendable { + private let visualClassifier: AircraftVisualClassifier + private let activityClassifier: FlightActivityClassifier + + public init( + visualClassifier: AircraftVisualClassifier, + activityClassifier: FlightActivityClassifier, + ) { + self.visualClassifier = visualClassifier + self.activityClassifier = activityClassifier + } + + public func frame( + observations: [ResolvedAircraftObservation], + observedAt: Date, + providerRouteResults: [AircraftID: FlightRouteResult], + observer: ObserverPosition, + labelMode: FlightLabelMode, + routeResults: [FlightCallsign: FlightRouteResult], + availability: MarkAvailability, + ) throws -> ProjectionLayerFrame { + var airportMarks: [AirportID: ProjectionMark] = [:] + var aircraftMarks: [ProjectionMark] = [] + for resolvedObservation in observations { + let observation = resolvedObservation.observation + let motion = resolvedObservation.motion + let callsign = observation.callsign.flatMap(FlightCallsign.init(rawValue:)) + let routeResult = providerRouteResults[observation.id] + ?? callsign.flatMap { routeResults[$0] } + let route = routeResult?.route + let prominence: ProjectionProminence = if callsign == nil || routeResult == + .unavailable + { + .secondary + } else { + .primary + } + let activity = try activityClassifier.activity( + for: observation, + observer: observer, + route: route, + motion: motion, + ) + let anchor = GeodeticAnchor( + coordinate: observation.coordinate, + altitude: observation.skyAltitude, + ) + try aircraftMarks.append(ProjectionMark( + element: .aircraft( + id: observation.id, + glyph: visualClassifier.descriptor( + for: observation, + activity: activity, + ), + ), + anchor: .geodetic(anchor), + label: label( + for: observation, + observer: observer, + mode: labelMode, + routeResult: routeResult, + ), + prominence: prominence, + velocity: ProjectionVelocity( + horizontal: motion.horizontal, + verticalRateFeetPerMinute: motion.verticalRateFeetPerMinute, + ), + freshness: MarkFreshness( + positionObservedAt: observation.positionObservedAt, + fetchedAt: observation.fetchedAt, + availability: availability, + ), + )) + if let context = activity.airportContext { + let airport = context.airport + let runwayBearing: Bearing? = if let runway = airport.longestOpenRunway { + try ProjectionEngine().greatCirclePosition( + from: runway.lowEnd, + to: runway.highEnd, + ).initialBearing + } else { + nil + } + let code = activity.certainty == .confirmed && labelMode != .marksOnly + ? airport.displayCode + : nil + if let existing = airportMarks[airport.id], + case let .airport(existingDescriptor) = existing.glyph, + existingDescriptor.certainty == .confirmed, + activity.certainty == .inferred + { + continue + } + airportMarks[airport.id] = ProjectionMark( + element: .airport(AirportGlyphDescriptor( + airportID: airport.id, + code: code, + runwayBearing: runwayBearing, + certainty: activity.certainty ?? .inferred, + )), + anchor: .geodetic(GeodeticAnchor( + coordinate: airport.coordinate, + altitude: airport.elevation.map { + .available($0, quality: .geometric) + } ?? .unavailable, + )), + label: code.map { + ProjectionLabel( + primary: $0.rawValue, + primaryRole: .headline, + secondary: nil, + ) + }, + prominence: .primary, + velocity: nil, + freshness: MarkFreshness( + positionObservedAt: observation.positionObservedAt, + fetchedAt: observation.fetchedAt, + availability: availability, + ), + ) + } + } + return ProjectionLayerFrame( + observedAt: observedAt, + marks: aircraftMarks + airportMarks.sorted { $0.key.rawValue < $1.key.rawValue } + .map(\.value), + ) + } + + private func label( + for observation: AircraftObservation, + observer: ObserverPosition, + mode: FlightLabelMode, + routeResult: FlightRouteResult?, + ) throws -> ProjectionLabel? { + let route = routeResult?.route + switch mode { + case .marksOnly: + return nil + case .callsigns: + return label(route: route, callsign: observation.callsign) + case .adaptive: + let altitudeText = observation.skyAltitude.value.map(Self.altitudeText) + let isNearby: Bool + if case let .available(altitude, quality) = observation.skyAltitude { + let position = try ProjectionEngine().horizontalPosition( + observer: observer, + target: GeodeticAnchor( + coordinate: observation.coordinate, + altitude: .available(altitude, quality: quality), + ), + ) + isNearby = (position?.slantRange.value ?? .infinity) <= 10 + } else { + isNearby = false + } + + if observation.callsign != nil { + return label(route: route, callsign: observation.callsign) + } + if isNearby, let altitudeText { + return ProjectionLabel( + primary: altitudeText, + primaryRole: .headline, + secondary: nil, + ) + } + return nil + } + } + + private func label(route: FlightRoute?, callsign: String?) -> ProjectionLabel? { + guard let callsign else { return nil } + guard let route else { + return ProjectionLabel( + primary: callsign, + primaryRole: .detail, + secondary: nil, + ) + } + return ProjectionLabel( + primary: "\(route.origin.rawValue)→\(route.destination.rawValue)", + primaryRole: .headline, + secondary: callsign, + ) + } + + private static func altitudeText(_ altitude: Altitude) -> String { + let rounded = Int((altitude.feet / 100).rounded()) * 100 + return Measurement(value: Double(rounded), unit: UnitLength.feet) + .formatted(.measurement(width: .abbreviated, usage: .asProvided)) + } +} diff --git a/Throw/ThrowCore/Sources/FlightMotionEstimator.swift b/Throw/ThrowCore/Sources/FlightMotionEstimator.swift new file mode 100644 index 000000000..ac8c9e401 --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightMotionEstimator.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Reconciles provider motion with consecutive positions without retaining +/// aircraft history beyond the active runtime. +struct FlightMotionEstimator { + private static let minimumDerivedSpeedKnots = 10.0 + private static let maximumDerivedSpeedKnots = 1200.0 + private static let maximumTranslationInterval: TimeInterval = 10 * 60 + private static let maximumTurnInterval: TimeInterval = 30 + private static let minimumTurnSpeedKnots = 60.0 + private static let maximumTurnRateDegreesPerSecond = 3.0 + + private struct Entry { + let coordinate: GeoCoordinate + let observedAt: Date + let motion: AircraftMotion + } + + private let engine = ProjectionEngine() + private var entries: [AircraftID: Entry] = [:] + private var source: AircraftSourceKind? + + mutating func resolvedObservations( + for snapshot: AircraftSnapshot, + ) throws -> [ResolvedAircraftObservation] { + if source != snapshot.source { + reset() + source = snapshot.source + } + + var nextEntries: [AircraftID: Entry] = [:] + var observations: [ResolvedAircraftObservation] = [] + nextEntries.reserveCapacity(snapshot.observations.count) + observations.reserveCapacity(snapshot.observations.count) + + for (index, observation) in snapshot.observations.enumerated() { + if index.isMultiple(of: 64) { try Task.checkCancellation() } + let previous = entries[observation.id] + let motion = try resolvedMotion(for: observation, previous: previous) + observations.append(ResolvedAircraftObservation( + observation: observation, + motion: motion, + )) + nextEntries[observation.id] = Entry( + coordinate: observation.coordinate, + observedAt: observation.positionObservedAt, + motion: motion, + ) + } + entries = nextEntries + return observations + } + + private mutating func reset() { + entries = [:] + source = nil + } + + private func resolvedMotion( + for observation: AircraftObservation, + previous: Entry?, + ) throws -> AircraftMotion { + let reported = AircraftMotion.reported(by: observation) + guard let previous else { return reported } + let interval = observation.positionObservedAt.timeIntervalSince(previous.observedAt) + guard interval > 0 else { + guard observation.coordinate == previous.coordinate else { return reported } + if reported.horizontalSource == nil, + previous.motion.horizontalSource == .positionDerived + { + return try AircraftMotion( + horizontal: previous.motion.horizontal, + verticalRateFeetPerMinute: reported.verticalRateFeetPerMinute, + ) + } + let reportsSameMotion = reported.horizontal == previous.motion.horizontal && + reported.verticalRateFeetPerMinute == previous.motion.verticalRateFeetPerMinute + return reportsSameMotion ? previous.motion : reported + } + + let measured = try measuredHorizontalMotion( + from: previous.coordinate, + to: observation.coordinate, + interval: interval, + ) + let horizontal = try reconciledHorizontalMotion( + reported: reported, + measured: measured, + interval: interval, + ) + let turnRate = turnRate( + previous: previous.motion, + current: horizontal, + interval: interval, + ) + let resolvedHorizontal = try applyingTurnRate(turnRate, to: horizontal) + return try AircraftMotion( + horizontal: resolvedHorizontal, + verticalRateFeetPerMinute: reported.verticalRateFeetPerMinute, + ) + } + + private func measuredHorizontalMotion( + from previous: GeoCoordinate, + to current: GeoCoordinate, + interval: TimeInterval, + ) throws -> MeasuredHorizontalMotion? { + guard interval <= Self.maximumTranslationInterval else { return nil } + let position = try engine.greatCirclePosition(from: previous, to: current) + let speedKnots = position.distance.value * 3600 / interval + guard Self.minimumDerivedSpeedKnots ... Self.maximumDerivedSpeedKnots ~= speedKnots + else { return nil } + return MeasuredHorizontalMotion( + track: position.initialBearing, + speedKnots: speedKnots, + ) + } + + private func applyingTurnRate( + _ turnRate: Double?, + to horizontal: AircraftHorizontalMotion, + ) throws -> AircraftHorizontalMotion { + guard let available = horizontal.availableValue else { return horizontal } + return try .available( + AvailableAircraftHorizontalMotion( + track: available.track, + speedKnots: available.speedKnots, + turnRateDegreesPerSecond: turnRate, + source: available.source, + ), + ) + } + + private func reconciledHorizontalMotion( + reported: AircraftMotion, + measured: MeasuredHorizontalMotion?, + interval: TimeInterval, + ) throws -> AircraftHorizontalMotion { + guard let measured else { + return reported.horizontal + } + guard let reportedHorizontal = reported.horizontal.availableValue else { + return try measured.horizontalMotion() + } + if isClearlyInconsistent( + reportedTrack: reportedHorizontal.track, + reportedSpeed: reportedHorizontal.speedKnots, + measured: measured, + interval: interval, + ) { + return try measured.horizontalMotion() + } + return try .available( + AvailableAircraftHorizontalMotion( + track: reportedHorizontal.track, + speedKnots: reportedHorizontal.speedKnots, + turnRateDegreesPerSecond: nil, + source: .provider, + ), + ) + } + + private func isClearlyInconsistent( + reportedTrack: Bearing, + reportedSpeed: Double, + measured: MeasuredHorizontalMotion, + interval: TimeInterval, + ) -> Bool { + let slowFastMismatch = (reportedSpeed < 30 && measured.speedKnots >= 80) || + (measured.speedKnots < 30 && reportedSpeed >= 80) + let ratio = max(reportedSpeed, measured.speedKnots) / + max(1, min(reportedSpeed, measured.speedKnots)) + let speedMismatch = ratio > 3 && abs(reportedSpeed - measured.speedKnots) > 150 + let trackMismatch = interval <= 60 && + courseDifference(reportedTrack, measured.track) > 100 && + min(reportedSpeed, measured.speedKnots) >= 80 + return slowFastMismatch || speedMismatch || trackMismatch + } + + private func turnRate( + previous: AircraftMotion, + current: AircraftHorizontalMotion, + interval: TimeInterval, + ) -> Double? { + guard interval <= Self.maximumTurnInterval, + let previous = previous.horizontal.availableValue, + let current = current.availableValue, + previous.source == .provider, + current.source == .provider, + current.speedKnots >= Self.minimumTurnSpeedKnots + else { return nil } + let rate = signedCourseDifference(from: previous.track, to: current.track) / interval + guard abs(rate) <= Self.maximumTurnRateDegreesPerSecond else { return nil } + return abs(rate) >= 0.03 ? rate : nil + } + + private func courseDifference(_ lhs: Bearing, _ rhs: Bearing) -> Double { + abs(signedCourseDifference(from: lhs, to: rhs)) + } + + private func signedCourseDifference(from: Bearing, to: Bearing) -> Double { + var difference = (to.degrees - from.degrees).truncatingRemainder(dividingBy: 360) + if difference > 180 { difference -= 360 } + if difference < -180 { difference += 360 } + return difference + } + + private struct MeasuredHorizontalMotion { + let track: Bearing + let speedKnots: Double + + func horizontalMotion() throws -> AircraftHorizontalMotion { + try .available( + AvailableAircraftHorizontalMotion( + track: track, + speedKnots: speedKnots, + turnRateDegreesPerSecond: nil, + source: .positionDerived, + ), + ) + } + } +} diff --git a/Throw/ThrowCore/Sources/FlightPredictor.swift b/Throw/ThrowCore/Sources/FlightPredictor.swift new file mode 100644 index 000000000..9c63593ac --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightPredictor.swift @@ -0,0 +1,224 @@ +import Foundation + +public struct FlightPrediction: Hashable, Sendable, + CustomStringConvertible, + CustomDebugStringConvertible +{ + public let mark: ProjectionMark + public let opacity: Double + + public init(mark: ProjectionMark, opacity: Double) { + precondition((0 ... 1).contains(opacity)) + self.mark = mark + self.opacity = opacity + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// Dead-reckons a successful observation until a later poll replaces it. A +/// retryable feed failure keeps predicting for 15 seconds, then fades for 15 seconds. +public enum FlightPredictor { + public static let failureGracePeriod: TimeInterval = 15 + public static let failureFadeDuration: TimeInterval = 15 + static let turnPredictionDuration: TimeInterval = 12 + + public static func prediction( + for mark: ProjectionMark, + at date: Date, + ) throws -> FlightPrediction? { + guard let age = observationAge( + positionObservedAt: mark.freshness.positionObservedAt, + at: date, + ) else { + return nil + } + guard let opacity = availabilityOpacity( + for: mark.freshness.availability, + at: date, + ) else { return nil } + let predictedMark = try predictedMark(for: mark, observationAge: age) + return FlightPrediction(mark: predictedMark, opacity: opacity) + } + + static func predictedMark( + for mark: ProjectionMark, + at date: Date, + ) throws -> ProjectionMark? { + guard let age = observationAge( + positionObservedAt: mark.freshness.positionObservedAt, + at: date, + ) else { + return nil + } + return try predictedMark(for: mark, observationAge: age) + } + + private static func predictedMark( + for mark: ProjectionMark, + observationAge: TimeInterval, + ) throws -> ProjectionMark { + let predictionAge = observationAge + guard predictionAge > 0, + case let .geodetic(anchor) = mark.anchor + else { + return mark + } + + let coordinate: GeoCoordinate = if let track = mark.velocity?.groundTrack, + let speed = mark.velocity?.groundSpeedKnots + { + try predictedCoordinate( + from: anchor.coordinate, + track: track, + speedKnots: speed, + turnRateDegreesPerSecond: mark.velocity?.turnRateDegreesPerSecond, + predictionAge: predictionAge, + ) + } else { + anchor.coordinate + } + let altitude: GeodeticAltitude = switch anchor.altitude { + case .unavailable: + .unavailable + case let .available(current, quality): + if let verticalRate = mark.velocity?.verticalRateFeetPerMinute { + try .available( + predictedAltitude( + current: current, + verticalRateFeetPerMinute: verticalRate, + predictionAge: predictionAge, + ), + quality: quality, + ) + } else { + anchor.altitude + } + } + let predictedAnchor = GeodeticAnchor( + coordinate: coordinate, + altitude: altitude, + ) + return ProjectionMark( + element: mark.element, + anchor: .geodetic(predictedAnchor), + label: mark.label, + prominence: mark.prominence, + velocity: mark.velocity, + freshness: mark.freshness, + ) + } + + static func observationAge(positionObservedAt: Date, at date: Date) -> TimeInterval? { + let age = date.timeIntervalSince(positionObservedAt) + guard age.isFinite, age >= 0 else { return nil } + return age + } + + private static func availabilityOpacity( + for availability: MarkAvailability, + at date: Date, + ) -> Double? { + switch availability { + case .current: + return 1 + case let .retrying(since): + guard let age = observationAge(positionObservedAt: since, at: date) else { + return nil + } + let expiration = failureGracePeriod + failureFadeDuration + guard age < expiration else { return nil } + guard age > failureGracePeriod else { return 1 } + return 1 - (age - failureGracePeriod) / failureFadeDuration + } + } + + private static func predictedAltitude( + current: Altitude, + verticalRateFeetPerMinute: Double, + predictionAge: TimeInterval, + ) throws -> Altitude { + let predictedFeet = current.feet + verticalRateFeetPerMinute * predictionAge / 60 + guard predictedFeet.isNaN == false else { return current } + let boundedFeet = min( + max(predictedFeet, Altitude.allowedFeet.lowerBound), + Altitude.allowedFeet.upperBound, + ) + return try Altitude(feet: boundedFeet) + } + + private static func destination( + from origin: GeoCoordinate, + bearing: Bearing, + distanceNauticalMiles: Double, + ) throws -> GeoCoordinate { + let earthRadiusNauticalMiles = 3440.0695 + let angularDistance = distanceNauticalMiles / earthRadiusNauticalMiles + let latitude1 = origin.latitude * .pi / 180 + let longitude1 = origin.longitude * .pi / 180 + let bearingRadians = bearing.degrees * .pi / 180 + + let latitude2 = asin( + sin(latitude1) * cos(angularDistance) + + cos(latitude1) * sin(angularDistance) * cos(bearingRadians), + ) + let longitude2 = longitude1 + atan2( + sin(bearingRadians) * sin(angularDistance) * cos(latitude1), + cos(angularDistance) - sin(latitude1) * sin(latitude2), + ) + var longitudeDegrees = longitude2 * 180 / .pi + longitudeDegrees = (longitudeDegrees + 540).truncatingRemainder(dividingBy: 360) - 180 + return try GeoCoordinate( + latitude: latitude2 * 180 / .pi, + longitude: longitudeDegrees, + ) + } + + private static func predictedCoordinate( + from origin: GeoCoordinate, + track: Bearing, + speedKnots: Double, + turnRateDegreesPerSecond: Double?, + predictionAge: TimeInterval, + ) throws -> GeoCoordinate { + guard let turnRateDegreesPerSecond, + abs(turnRateDegreesPerSecond) >= 0.03 + else { + return try destination( + from: origin, + bearing: track, + distanceNauticalMiles: speedKnots * predictionAge / 3600, + ) + } + + let turnDuration = min(predictionAge, turnPredictionDuration) + let angularRate = turnRateDegreesPerSecond * .pi / 180 + let initialTrack = track.degrees * .pi / 180 + let finalTrack = initialTrack + angularRate * turnDuration + let speedNauticalMilesPerSecond = speedKnots / 3600 + let north = speedNauticalMilesPerSecond / angularRate * + (sin(finalTrack) - sin(initialTrack)) + let east = speedNauticalMilesPerSecond / angularRate * + (cos(initialTrack) - cos(finalTrack)) + let arcDistance = hypot(east, north) + let arcBearing = try Bearing(degrees: atan2(east, north) * 180 / .pi) + let afterTurn = try destination( + from: origin, + bearing: arcBearing, + distanceNauticalMiles: arcDistance, + ) + let remainingAge = predictionAge - turnDuration + guard remainingAge > 0 else { return afterTurn } + return try destination( + from: afterTurn, + bearing: Bearing(degrees: finalTrack * 180 / .pi), + distanceNauticalMiles: speedKnots * remainingAge / 3600, + ) + } +} diff --git a/Throw/ThrowCore/Sources/FlightRouteModels.swift b/Throw/ThrowCore/Sources/FlightRouteModels.swift new file mode 100644 index 000000000..a4d2e8ef5 --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightRouteModels.swift @@ -0,0 +1,110 @@ +import Foundation + +/// A normalized broadcast callsign used only as the key for route enrichment. +public struct FlightCallsign: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let rawValue: String + + public init?(rawValue: String) { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard (2 ... 12).contains(value.count), + value.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) + else { return nil } + self.rawValue = value + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// An IATA code when available, with ICAO as a fallback for airports without one. +public struct AirportCode: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let rawValue: String + + public init?(rawValue: String) { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard (3 ... 4).contains(value.count), + value.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) + else { return nil } + self.rawValue = value + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public struct FlightRoute: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let origin: AirportCode + public let destination: AirportCode + + public init(origin: AirportCode, destination: AirportCode) { + precondition(origin != destination, "A flight route must connect different airports") + self.origin = origin + self.destination = destination + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +/// A completed route-enrichment result. Absence from a resolver snapshot means +/// that enrichment has not completed for the callsign yet. +public enum FlightRouteResult: Hashable, Sendable { + case route(FlightRoute) + case unavailable + + public var route: FlightRoute? { + switch self { + case let .route(route): route + case .unavailable: nil + } + } +} + +public struct FlightRouteQuery: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let callsign: FlightCallsign + + public init(callsign: FlightCallsign) { + self.callsign = callsign + } + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public protocol FlightRouteSource: Sendable { + func routes(for queries: [FlightRouteQuery]) async throws -> [FlightCallsign: FlightRoute] +} + +public enum FlightRouteLookupError: Error, Equatable, Sendable { + case provider + case transport(AircraftTransportErrorCategory) + case decoding +} diff --git a/Throw/ThrowCore/Sources/FlightRouteResolver.swift b/Throw/ThrowCore/Sources/FlightRouteResolver.swift new file mode 100644 index 000000000..b29f7aaed --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightRouteResolver.swift @@ -0,0 +1,100 @@ +import Foundation + +public enum FlightRouteResolution: Equatable, Sendable { + case noRequestNeeded + case coolingDown + case completed(hasNewRoutes: Bool, hasMoreRequests: Bool) +} + +/// Keeps route enrichment off the projection hot path and bounds provider traffic. +public actor FlightRouteResolver { + private struct Entry { + let route: FlightRoute? + let expiresAt: Date + } + + private static let positiveLifetime: TimeInterval = 6 * 60 * 60 + private static let negativeLifetime: TimeInterval = 60 * 60 + private static let failureCooldown: TimeInterval = 5 * 60 + private static let maximumQueriesPerPass = 12 + + private let source: any FlightRouteSource + private var entries: [FlightCallsign: Entry] = [:] + private var retryNotBefore: Date? + + public init(source: any FlightRouteSource) { + self.source = source + } + + public func cachedResults( + for observations: [AircraftObservation], + at date: Date, + ) -> [FlightCallsign: FlightRouteResult] { + removeExpiredEntries(at: date) + let callsigns = Set(observations.compactMap { observation in + observation.callsign.flatMap(FlightCallsign.init(rawValue:)) + }) + return entries.reduce(into: [:]) { results, item in + guard callsigns.contains(item.key) else { return } + results[item.key] = item.value.route.map(FlightRouteResult.route) ?? .unavailable + } + } + + @discardableResult + public func resolveMissing( + for observations: [AircraftObservation], + at date: Date, + ) async throws -> FlightRouteResolution { + removeExpiredEntries(at: date) + if let retryNotBefore, retryNotBefore > date { + return .coolingDown + } + retryNotBefore = nil + var seen: Set = [] + let queries = observations.compactMap { observation -> FlightRouteQuery? in + guard let callsign = observation.callsign.flatMap(FlightCallsign.init(rawValue:)), + entries[callsign] == nil, + seen.insert(callsign).inserted + else { return nil } + return FlightRouteQuery(callsign: callsign) + } + .prefix(Self.maximumQueriesPerPass) + guard queries.isEmpty == false else { return .noRequestNeeded } + + let queryArray = Array(queries) + let routes: [FlightCallsign: FlightRoute] + do { + routes = try await source.routes(for: queryArray) + } catch is CancellationError { + throw CancellationError() + } catch { + try Task.checkCancellation() + retryNotBefore = date.addingTimeInterval(Self.failureCooldown) + throw error + } + try Task.checkCancellation() + let queriedCallsigns = Set(queryArray.map(\.callsign)) + for callsign in queriedCallsigns { + let route = routes[callsign] + entries[callsign] = Entry( + route: route, + expiresAt: date.addingTimeInterval( + route == nil ? Self.negativeLifetime : Self.positiveLifetime, + ), + ) + } + let hasMoreRequests = observations.contains { observation in + guard let callsign = observation.callsign.flatMap(FlightCallsign.init(rawValue:)) + else { return false } + return entries[callsign] == nil + } + return .completed( + hasNewRoutes: routes.isEmpty == false, + hasMoreRequests: hasMoreRequests, + ) + } + + private func removeExpiredEntries(at date: Date) { + entries = entries.filter { $0.value.expiresAt > date } + } +} diff --git a/Throw/ThrowCore/Sources/Flightradar24RequestFactory.swift b/Throw/ThrowCore/Sources/Flightradar24RequestFactory.swift new file mode 100644 index 000000000..260b03479 --- /dev/null +++ b/Throw/ThrowCore/Sources/Flightradar24RequestFactory.swift @@ -0,0 +1,243 @@ +import Foundation + +/// Number of billed FR24 live-position requests required for one Throw poll. +public enum Flightradar24RequestMultiplicity: Int, Equatable, Sendable { + case single = 1 + case antimeridian = 2 + + public static func livePosition( + for query: AircraftQuery, + ) throws -> Flightradar24RequestMultiplicity { + let plan = try CloudAircraftQuery.plan(for: query) + return PositionBoundsPlan( + center: plan.coarseCenter, + radius: plan.transmittedRadius, + ).multiplicity + } +} + +/// A live-position query is either one valid bounds request or the two valid +/// hemisphere requests needed when its bounds cross the antimeridian. +enum Flightradar24PositionRequestPlan { + case single(HTTPRequest) + case antimeridian( + westernHemisphere: HTTPRequest, + easternHemisphere: HTTPRequest, + ) +} + +/// Constructs authenticated FR24 requests while keeping geographic bounds valid. +struct Flightradar24RequestFactory { + private let baseURL: URL + private let credential: AircraftCredential + + init(baseURL: URL, credential: AircraftCredential) { + self.baseURL = baseURL + self.credential = credential + } + + func livePositionPlan( + for query: AircraftQuery, + ) throws -> Flightradar24PositionRequestPlan { + let plan = try CloudAircraftQuery.plan(for: query) + return try positionPlan( + center: plan.coarseCenter, + radius: plan.transmittedRadius, + ) + } + + func positionPlan( + for query: AircraftQuery, + radius: NauticalMiles, + ) throws -> Flightradar24PositionRequestPlan { + let plan = try CloudAircraftQuery.plan(for: query) + return try positionPlan(center: plan.coarseCenter, radius: radius) + } + + private func positionPlan( + center: GeoCoordinate, + radius: NauticalMiles, + ) throws -> Flightradar24PositionRequestPlan { + try positionRequestPlan(for: PositionBoundsPlan( + center: center, + radius: radius, + )) + } + + func usageRequest(period: Flightradar24UsagePeriod) throws -> HTTPRequest { + var components = URLComponents( + url: baseURL.appending(path: "usage"), + resolvingAgainstBaseURL: false, + ) + components?.queryItems = [URLQueryItem(name: "period", value: period.rawValue)] + guard let url = components?.url else { throw AircraftSourceFailure.invalidConfiguration } + return request(url: url) + } + + private func positionRequest(bounds: PositionBounds) throws -> HTTPRequest { + var components = URLComponents( + url: baseURL.appending(path: "live/flight-positions/full"), + resolvingAgainstBaseURL: false, + ) + components?.queryItems = [URLQueryItem(name: "bounds", value: bounds.queryValue)] + guard let url = components?.url else { throw AircraftSourceFailure.invalidConfiguration } + return request(url: url) + } + + private func request(url: URL) -> HTTPRequest { + HTTPRequest( + method: .get, + url: url, + headers: [ + .accept: "application/json", + .acceptVersion: "v1", + .authorization: "Bearer \(credential.authenticationHeaderValue)", + ], + timeoutSeconds: 8, + ) + } + + private func positionRequestPlan( + for boundsPlan: PositionBoundsPlan, + ) throws -> Flightradar24PositionRequestPlan { + switch boundsPlan { + case let .single(bounds): + try .single(positionRequest(bounds: bounds)) + case let .antimeridian(westernHemisphere, easternHemisphere): + try .antimeridian( + westernHemisphere: positionRequest(bounds: westernHemisphere), + easternHemisphere: positionRequest(bounds: easternHemisphere), + ) + } + } +} + +private enum PositionBoundsPlan { + private static let earthMeanRadiusNauticalMiles = 6_371_008.8 / 1852 + + case single(PositionBounds) + case antimeridian( + westernHemisphere: PositionBounds, + easternHemisphere: PositionBounds, + ) + + init(center: GeoCoordinate, radius: NauticalMiles) { + let centerLatitude = center.latitude * .pi / 180 + let angularRadius = min(.pi, radius.value / Self.earthMeanRadiusNauticalMiles) + let northLatitude = min(.pi / 2, centerLatitude + angularRadius) + let southLatitude = max(-.pi / 2, centerLatitude - angularRadius) + let north = northLatitude * 180 / .pi + let south = southLatitude * 180 / .pi + let reachesPole = centerLatitude + angularRadius >= .pi / 2 || + centerLatitude - angularRadius <= -.pi / 2 + guard reachesPole == false else { + self = .single(PositionBounds( + north: north, + south: south, + west: -180, + east: 180, + )) + return + } + + let longitudeRatio = min( + 1, + max(0, sin(angularRadius) / cos(centerLatitude)), + ) + let longitudeSpan = asin(longitudeRatio) * 180 / .pi + + let rawWest = center.longitude - longitudeSpan + let rawEast = center.longitude + longitudeSpan + if rawWest < -180 { + self = .antimeridian( + westernHemisphere: PositionBounds( + north: north, + south: south, + west: -180, + east: rawEast, + ), + easternHemisphere: PositionBounds( + north: north, + south: south, + west: rawWest + 360, + east: 180, + ), + ) + } else if rawEast > 180 { + self = .antimeridian( + westernHemisphere: PositionBounds( + north: north, + south: south, + west: -180, + east: rawEast - 360, + ), + easternHemisphere: PositionBounds( + north: north, + south: south, + west: rawWest, + east: 180, + ), + ) + } else { + self = .single(PositionBounds( + north: north, + south: south, + west: rawWest, + east: rawEast, + )) + } + } + + var multiplicity: Flightradar24RequestMultiplicity { + switch self { + case .single: .single + case .antimeridian: .antimeridian + } + } +} + +private struct PositionBounds { + let north: Double + let south: Double + let west: Double + let east: Double + + init(north: Double, south: Double, west: Double, east: Double) { + precondition((-90 ... 90).contains(north)) + precondition((-90 ... 90).contains(south)) + precondition((-180 ... 180).contains(west)) + precondition((-180 ... 180).contains(east)) + precondition(north >= south) + precondition(east >= west) + self.north = north + self.south = south + self.west = west + self.east = east + } + + var queryValue: String { + [ + Self.formatUpperBound(north), + Self.formatLowerBound(south), + Self.formatLowerBound(west), + Self.formatUpperBound(east), + ].joined(separator: ",") + } + + private static func formatUpperBound(_ value: Double) -> String { + format(ceil(value * 1000) / 1000) + } + + private static func formatLowerBound(_ value: Double) -> String { + format(floor(value * 1000) / 1000) + } + + private static func format(_ value: Double) -> String { + let normalized = value == 0 ? 0 : value + return String( + format: "%.3f", + locale: Locale(identifier: "en_US_POSIX"), + normalized, + ) + } +} diff --git a/Throw/ThrowCore/Sources/Flightradar24Source.swift b/Throw/ThrowCore/Sources/Flightradar24Source.swift new file mode 100644 index 000000000..8a049495a --- /dev/null +++ b/Throw/ThrowCore/Sources/Flightradar24Source.swift @@ -0,0 +1,421 @@ +import Foundation + +enum Flightradar24DecodingError: Error, Equatable { + case invalidEnvelope +} + +/// Reads FR24 live positions and preserves routes from the matching position record. +struct Flightradar24Decoder { + init() {} + + func decode(_ data: Data, fetchedAt: Date) throws -> AircraftSnapshot { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: data) + } catch { + throw Flightradar24DecodingError.invalidEnvelope + } + + var observations: [AircraftObservation] = [] + var observationIndexByID: [AircraftID: Int] = [:] + var routeResults: [AircraftID: FlightRouteResult] = [:] + var malformedRecordCount = 0 + var missingPositionRecordCount = 0 + observations.reserveCapacity(envelope.data.count) + + for lossyRecord in envelope.data { + try Task.checkCancellation() + guard let record = lossyRecord.value else { + malformedRecordCount += 1 + continue + } + guard let latitude = record.lat, let longitude = record.lon else { + missingPositionRecordCount += 1 + continue + } + do { + let coordinate = try GeoCoordinate(latitude: latitude, longitude: longitude) + let identity: AircraftID? = if let hex = record.hex?.trimmedNonempty { + AircraftID(kind: .icao, rawValue: hex) + } else { + AircraftID(kind: .providerMarkedNonICAO, rawValue: record.fr24ID) + } + guard let identity else { + malformedRecordCount += 1 + continue + } + let observedAt = record.timestamp.flatMap(Self.timestamp) ?? fetchedAt + let observation = try AircraftObservation( + id: identity, + coordinate: coordinate, + geometricAltitude: nil, + barometricAltitude: record.alt.map { try Altitude(feet: $0) }, + airborneState: Self.airborneState(altitudeFeet: record.alt), + groundTrack: record.track.map { try Bearing(degrees: $0) }, + trueHeading: nil, + magneticHeading: nil, + groundSpeedKnots: record.groundSpeed, + verticalRateFeetPerMinute: record.verticalSpeed, + callsign: record.flight?.trimmedNonempty ?? record.callsign?.trimmedNonempty, + registration: record.registration, + aircraftType: record.aircraftType + .flatMap(AircraftTypeDesignator.init(rawValue:)), + emitterCategory: nil, + airlineDesignator: Self.airlineDesignator(record), + messageObservedAt: observedAt, + positionObservedAt: observedAt, + fetchedAt: fetchedAt, + metadata: AircraftObservationMetadata( + source: .flightradar24, + positionSource: record.source, + messageCount: nil, + ), + ) + let routeResult = Self.route(record).map(FlightRouteResult.route) ?? .unavailable + if let existingIndex = observationIndexByID[identity] { + if AircraftSnapshot.prefers(observation, over: observations[existingIndex]) { + observations[existingIndex] = observation + routeResults[identity] = routeResult + } + } else { + observationIndexByID[identity] = observations.count + observations.append(observation) + routeResults[identity] = routeResult + } + } catch { + malformedRecordCount += 1 + } + } + if observations.isEmpty, malformedRecordCount + missingPositionRecordCount > 0 { + throw Flightradar24DecodingError.invalidEnvelope + } + return AircraftSnapshot( + source: .flightradar24, + fetchedAt: fetchedAt, + observations: observations, + routeResultsByAircraft: routeResults, + successfulHTTPStatus: nil, + decodingDiagnostics: AircraftSnapshotDecodingDiagnostics( + malformedRecordCount: malformedRecordCount, + missingPositionRecordCount: missingPositionRecordCount, + ), + ) + } + + private static func airborneState(altitudeFeet: Double?) -> AircraftAirborneState { + guard let altitudeFeet else { return .unknown } + return altitudeFeet <= 0 ? .ground : .airborne + } + + private static func route(_ record: Record) -> FlightRoute? { + guard let origin = AirportCode(rawValue: record.originIATA?.trimmedNonempty + ?? record.originICAO?.trimmedNonempty ?? ""), + let destination = AirportCode(rawValue: record.destinationIATA?.trimmedNonempty + ?? record.destinationICAO?.trimmedNonempty ?? ""), + origin != destination + else { return nil } + return FlightRoute(origin: origin, destination: destination) + } + + private static func airlineDesignator(_ record: Record) -> AirlineICAODesignator? { + for providerValue in [record.paintedAs, record.operatingAs] { + if let providerValue, + let designator = AirlineICAODesignator(rawValue: providerValue) + { + return designator + } + } + + guard let radioCallsign = record.callsign?.trimmedNonempty, + radioCallsign.count >= 3 + else { return nil } + return AirlineICAODesignator(rawValue: String(radioCallsign.prefix(3))) + } + + private static func timestamp(_ value: String) -> Date? { + ISO8601DateFormatter().date(from: value) + } +} + +/// Keeps FR24 JSON decoding and exact geographic filtering off the polling actor. +actor Flightradar24DecodingWorker { + private let decoder: Flightradar24Decoder + + init(decoder: Flightradar24Decoder) { + self.decoder = decoder + } + + func decode( + _ data: Data, + fetchedAt: Date, + query: AircraftQuery, + ) throws -> AircraftSnapshot { + try Task.checkCancellation() + let decoded = try decoder.decode(data, fetchedAt: fetchedAt) + try Task.checkCancellation() + let observations = try CloudAircraftQuery.postFilter(decoded.observations, for: query) + let includedIDs = Set(observations.map(\.id)) + try Task.checkCancellation() + return AircraftSnapshot( + source: .flightradar24, + fetchedAt: fetchedAt, + observations: observations, + routeResultsByAircraft: decoded.routeResultsByAircraft.filter { + includedIDs.contains($0.key) + }, + successfulHTTPStatus: nil, + decodingDiagnostics: decoded.decodingDiagnostics, + ) + } +} + +struct Flightradar24Source: AircraftObservationSource, CustomStringConvertible, + CustomDebugStringConvertible +{ + static let baseURL = URL(string: "https://fr24api.flightradar24.com/api")! + + private let transport: any HTTPTransport + private let decodingWorker: Flightradar24DecodingWorker + private let requestFactory: Flightradar24RequestFactory + private let dateProvider: any DateProvider + + init( + transport: any HTTPTransport, + decoder: Flightradar24Decoder, + credential: AircraftCredential, + dateProvider: any DateProvider, + ) { + self.transport = transport + decodingWorker = Flightradar24DecodingWorker(decoder: decoder) + requestFactory = Flightradar24RequestFactory( + baseURL: Self.baseURL, + credential: credential, + ) + self.dateProvider = dateProvider + } + + var description: String { + ">" + } + + var debugDescription: String { + description + } + + func snapshot(for query: AircraftQuery) async throws -> AircraftSnapshot { + try await snapshot(for: query, plan: requestFactory.livePositionPlan(for: query)) + } + + func credentialTestSnapshot(observer: ObserverPosition) async throws + -> AircraftSnapshot + { + let query = try AircraftQuery( + observer: observer, + center: observer.coordinate, + viewport: .map(MapViewport(radius: NauticalMiles(value: 5))), + includeGroundAircraft: false, + ) + return try await snapshot( + for: query, + plan: requestFactory.positionPlan( + for: query, + radius: NauticalMiles(value: 5), + ), + ) + } + + func usage(period: Flightradar24UsagePeriod) async throws + -> Flightradar24UsageReport + { + do { + let response = try await transport.response( + for: requestFactory.usageRequest(period: period), + ) + let receivedAt = dateProvider.now() + try SourceHTTPValidation.validate( + response, + source: .flightradar24, + receivedAt: receivedAt, + ) + return try Flightradar24UsageDecoder.decode(response.data, period: period) + } catch is CancellationError { + throw CancellationError() + } catch let AircraftSourceFailure.quotaReached(retryAfterSeconds) { + throw Flightradar24UsageError.rateLimited( + retryAfterSeconds: retryAfterSeconds, + ) + } catch let failure as AircraftSourceFailure { + throw failure + } catch let failure as HTTPTransportFailure { + throw AircraftSourceFailure.transport(failure.category) + } catch { + throw AircraftSourceFailure.decoding + } + } + + private func snapshot( + for query: AircraftQuery, + plan: Flightradar24PositionRequestPlan, + ) async throws -> AircraftSnapshot { + switch plan { + case let .single(request): + return try await snapshot(for: query, request: request) + case let .antimeridian(westernHemisphere, easternHemisphere): + let western = try await snapshot(for: query, request: westernHemisphere) + try Task.checkCancellation() + let eastern = try await snapshot(for: query, request: easternHemisphere) + return Self.merge(western: western, eastern: eastern) + } + } + + private func snapshot( + for query: AircraftQuery, + request: HTTPRequest, + ) async throws -> AircraftSnapshot { + do { + let response = try await transport.response(for: request) + let fetchedAt = dateProvider.now() + try SourceHTTPValidation.validate( + response, + source: .flightradar24, + receivedAt: fetchedAt, + ) + let decoded = try await decodingWorker.decode( + response.data, + fetchedAt: fetchedAt, + query: query, + ) + return AircraftSnapshot( + source: .flightradar24, + fetchedAt: fetchedAt, + observations: decoded.observations, + routeResultsByAircraft: decoded.routeResultsByAircraft, + successfulHTTPStatus: response.statusCode, + decodingDiagnostics: decoded.decodingDiagnostics, + ) + } catch is CancellationError { + throw CancellationError() + } catch let failure as AircraftSourceFailure { + throw failure + } catch let failure as HTTPTransportFailure { + throw AircraftSourceFailure.transport(failure.category) + } catch { + throw AircraftSourceFailure.decoding + } + } + + private static func merge( + western: AircraftSnapshot, + eastern: AircraftSnapshot, + ) -> AircraftSnapshot { + precondition(western.source == .flightradar24) + precondition(eastern.source == .flightradar24) + + struct Candidate { + let observation: AircraftObservation + let routeResult: FlightRouteResult? + } + + var candidates: [Candidate] = [] + var candidateIndexByID: [AircraftID: Int] = [:] + func merge(_ snapshot: AircraftSnapshot) { + for observation in snapshot.observations { + let candidate = Candidate( + observation: observation, + routeResult: snapshot.routeResultsByAircraft[observation.id], + ) + if let index = candidateIndexByID[observation.id] { + if AircraftSnapshot.prefers( + observation, + over: candidates[index].observation, + ) { + candidates[index] = candidate + } + } else { + candidateIndexByID[observation.id] = candidates.count + candidates.append(candidate) + } + } + } + merge(western) + merge(eastern) + + var routeResults: [AircraftID: FlightRouteResult] = [:] + for candidate in candidates { + if let routeResult = candidate.routeResult { + routeResults[candidate.observation.id] = routeResult + } + } + let successfulHTTPStatus = western.successfulHTTPStatus == eastern.successfulHTTPStatus + ? western.successfulHTTPStatus + : nil + return AircraftSnapshot( + source: .flightradar24, + fetchedAt: max(western.fetchedAt, eastern.fetchedAt), + observations: candidates.map(\.observation), + routeResultsByAircraft: routeResults, + successfulHTTPStatus: successfulHTTPStatus, + decodingDiagnostics: western.decodingDiagnostics.adding( + eastern.decodingDiagnostics, + ), + ) + } +} + +private struct Envelope: Decodable { + let data: [LossyRecord] +} + +/// Consumes a malformed provider row without rejecting valid neighbors. +private struct LossyRecord: Decodable { + let value: Record? + + init(from decoder: any Decoder) throws { + value = try? Record(from: decoder) + } +} + +private struct Record: Decodable { + let fr24ID: String + let flight: String? + let callsign: String? + let lat: Double? + let lon: Double? + let track: Double? + let alt: Double? + let groundSpeed: Double? + let verticalSpeed: Double? + let timestamp: String? + let source: String? + let hex: String? + let aircraftType: String? + let registration: String? + let paintedAs: String? + let operatingAs: String? + let originIATA: String? + let originICAO: String? + let destinationIATA: String? + let destinationICAO: String? + + enum CodingKeys: String, CodingKey { + case fr24ID = "fr24_id" + case flight, callsign, lat, lon, track, alt, timestamp, source, hex + case groundSpeed = "gspeed" + case verticalSpeed = "vspeed" + case aircraftType = "type" + case registration = "reg" + case paintedAs = "painted_as" + case operatingAs = "operating_as" + case originIATA = "orig_iata" + case originICAO = "orig_icao" + case destinationIATA = "dest_iata" + case destinationICAO = "dest_icao" + } +} + +extension String { + fileprivate var trimmedNonempty: String? { + let value = trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Throw/ThrowCore/Sources/Flightradar24Usage.swift b/Throw/ThrowCore/Sources/Flightradar24Usage.swift new file mode 100644 index 000000000..4f90034ff --- /dev/null +++ b/Throw/ThrowCore/Sources/Flightradar24Usage.swift @@ -0,0 +1,174 @@ +import Foundation + +public enum Flightradar24UsagePeriod: String, Hashable, Sendable { + case last24Hours = "24h" +} + +/// A usage-report limit that is separate from the live-position credit allowance. +public enum Flightradar24UsageError: Error, Equatable, Sendable { + case rateLimited(retryAfterSeconds: Double?) +} + +/// Account usage reported by FR24 for Throw's live full-position endpoint. +public struct Flightradar24UsageReport: Equatable, Sendable { + public let period: Flightradar24UsagePeriod + public let requestCount: Int + public let credits: Int + + public init(period: Flightradar24UsagePeriod, requestCount: Int, credits: Int) { + precondition(requestCount >= 0) + precondition(credits >= 0) + self.period = period + self.requestCount = requestCount + self.credits = credits + } +} + +/// A cadence projection based on the account's observed FR24 cost per request. +public struct Flightradar24CreditEstimate: Equatable, Sendable { + public let averageCreditsPerRequest: Double + public let creditsPerActiveHour: Double + public let thirtyDayUpperBound: Double + + public init( + averageCreditsPerRequest: Double, + creditsPerActiveHour: Double, + thirtyDayUpperBound: Double, + ) { + precondition(averageCreditsPerRequest >= 0) + precondition(creditsPerActiveHour >= 0) + precondition(thirtyDayUpperBound >= 0) + self.averageCreditsPerRequest = averageCreditsPerRequest + self.creditsPerActiveHour = creditsPerActiveHour + self.thirtyDayUpperBound = thirtyDayUpperBound + } +} + +public enum Flightradar24CreditEstimator { + public static func estimate( + report: Flightradar24UsageReport, + pollingInterval: PollingInterval, + quietSchedule: QuietSchedule, + requestMultiplicity: Flightradar24RequestMultiplicity, + ) -> Flightradar24CreditEstimate? { + guard report.requestCount > 0 else { return nil } + let averageCreditsPerRequest = Double(report.credits) / Double(report.requestCount) + let creditsPerActiveHour = averageCreditsPerRequest + * (3600 / Double(pollingInterval.seconds)) + * Double(requestMultiplicity.rawValue) + let quietMinutes = quietSchedule.interval?.durationMinutes ?? 0 + let activeHoursPerDay = Double(24 * 60 - quietMinutes) / 60 + return Flightradar24CreditEstimate( + averageCreditsPerRequest: averageCreditsPerRequest, + creditsPerActiveHour: creditsPerActiveHour, + thirtyDayUpperBound: creditsPerActiveHour * activeHoursPerDay * 30, + ) + } +} + +enum Flightradar24UsageDecoder { + static func decode( + _ data: Data, + period: Flightradar24UsagePeriod, + ) throws -> Flightradar24UsageReport { + let envelope: Envelope + do { + envelope = try JSONDecoder().decode(Envelope.self, from: data) + } catch { + throw Flightradar24DecodingError.invalidEnvelope + } + + var requestCount = 0 + var credits = 0 + for entry in envelope.data where isLiveFullPositionEndpoint(entry.endpoint) { + guard entry.requestCount >= 0, entry.credits >= 0 else { + throw Flightradar24DecodingError.invalidEnvelope + } + let requestSum = requestCount.addingReportingOverflow(entry.requestCount) + let creditSum = credits.addingReportingOverflow(entry.credits) + guard requestSum.overflow == false, creditSum.overflow == false else { + throw Flightradar24DecodingError.invalidEnvelope + } + requestCount = requestSum.partialValue + credits = creditSum.partialValue + } + return Flightradar24UsageReport( + period: period, + requestCount: requestCount, + credits: credits, + ) + } + + private static func isLiveFullPositionEndpoint(_ endpoint: String) -> Bool { + let path = endpoint.split(separator: "?", maxSplits: 1).first.map(String.init) ?? endpoint + return path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + == "live/flight-positions/full" + } +} + +private struct Envelope: Decodable { + let data: [Entry] + + private enum CodingKeys: String, CodingKey { + case data + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard container.contains(.data) else { + data = [] + return + } + data = try container.decode([Entry].self, forKey: .data) + } +} + +private struct Entry: Decodable { + let endpoint: String + let requestCount: Int + let credits: Int + + enum CodingKeys: String, CodingKey { + case endpoint, credits + case requestCount = "request_count" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + endpoint = try container.decode(String.self, forKey: .endpoint) + requestCount = try container.decode(ProviderInteger.self, forKey: .requestCount).value + credits = try container.decode(ProviderInteger.self, forKey: .credits).value + } +} + +/// Matches the integer coercion used by FR24's official Pydantic response model. +private struct ProviderInteger: Decodable { + let value: Int + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self.value = value + return + } + if let value = try? container.decode(String.self), + let integer = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + { + self.value = integer + return + } + if let value = try? container.decode(Double.self), + let integer = Int(exactly: value) + { + self.value = integer + return + } + throw DecodingError.typeMismatch( + Int.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected an integer-compatible provider value", + ), + ) + } +} diff --git a/Throw/ThrowCore/Sources/FlightsLayerRuntime.swift b/Throw/ThrowCore/Sources/FlightsLayerRuntime.swift new file mode 100644 index 000000000..628844a96 --- /dev/null +++ b/Throw/ThrowCore/Sources/FlightsLayerRuntime.swift @@ -0,0 +1,59 @@ +import Foundation + +/// The provider-neutral observations and presentation semantics needed to +/// produce a Flights layer frame. +public struct FlightsLayerInput: Sendable { + public let snapshot: AircraftSnapshot + public let observer: ObserverPosition + public let labelMode: FlightLabelMode + public let routeResults: [FlightCallsign: FlightRouteResult] + public let availability: MarkAvailability + + public init( + snapshot: AircraftSnapshot, + observer: ObserverPosition, + labelMode: FlightLabelMode, + routeResults: [FlightCallsign: FlightRouteResult], + availability: MarkAvailability, + ) { + self.snapshot = snapshot + self.observer = observer + self.labelMode = labelMode + self.routeResults = routeResults + self.availability = availability + } +} + +/// The typed runtime for the enabled Flights catalog entry. Its actor-isolated +/// estimator retains only the consecutive samples needed for smooth motion. +public actor FlightsLayerRuntime: ProjectionLayerRuntime { + private let frameBuilder: FlightLayerFrameBuilder + private var motionEstimator = FlightMotionEstimator() + + public init(typeCatalog: AircraftTypeCatalog, airportCatalog: AirportCatalog) { + frameBuilder = FlightLayerFrameBuilder( + visualClassifier: AircraftVisualClassifier(catalog: typeCatalog), + activityClassifier: FlightActivityClassifier(airportCatalog: airportCatalog), + ) + } + + public func frame( + for input: FlightsLayerInput, + ) async throws -> ProjectionLayerFrame { + let observations = try motionEstimator.resolvedObservations(for: input.snapshot) + return try frameBuilder.frame( + observations: observations, + observedAt: input.snapshot.fetchedAt, + providerRouteResults: input.snapshot.routeResultsByAircraft, + observer: input.observer, + labelMode: input.labelMode, + routeResults: input.routeResults, + availability: input.availability, + ) + } + + /// Clears consecutive-sample motion when a source activation is replaced. + public func reset() { + motionEstimator = FlightMotionEstimator() + } +} diff --git a/Throw/ThrowCore/Sources/GeographyLayerRuntime.swift b/Throw/ThrowCore/Sources/GeographyLayerRuntime.swift new file mode 100644 index 000000000..19b02e81f --- /dev/null +++ b/Throw/ThrowCore/Sources/GeographyLayerRuntime.swift @@ -0,0 +1,198 @@ +import Foundation + +public struct GeographyLayerInput: Sendable { + public init() {} +} + +public protocol GeographyDataSource: Sendable { + func data() async throws -> Data +} + +public struct BundledGeographyDataSource: GeographyDataSource { + public init() {} + + @concurrent public func data() async throws -> Data { + guard let url = Bundle.module.url( + forResource: "geography-v2", + withExtension: "json", + ) else { + throw GeographyDataError.resourceMissing + } + do { + return try Data(contentsOf: url, options: .mappedIfSafe) + } catch is CancellationError { + throw CancellationError() + } catch { + throw GeographyDataError.resourceMissing + } + } +} + +public struct GeographyLayerRuntime: ProjectionLayerRuntime { + private let dataSource: any GeographyDataSource + + public init(dataSource: any GeographyDataSource) { + self.dataSource = dataSource + } + + @concurrent public func frame( + for _: GeographyLayerInput, + ) async throws -> ProjectionLayerFrame { + let data = try await dataSource.data() + let lines = try await GeographyArchiveDecoder.decode(data) + return ProjectionLayerFrame( + observedAt: Date(timeIntervalSince1970: 0), + lines: lines, + ) + } +} + +public enum GeographyArchiveDecoder { + @concurrent public static func decode(_ data: Data) async throws -> [GeographicPolyline] { + do { + try Task.checkCancellation() + let archive = try JSONDecoder().decode(Archive.self, from: data) + try Task.checkCancellation() + return try archive.lines() + } catch is CancellationError { + throw CancellationError() + } catch let error as GeographyDataError { + throw error + } catch { + throw GeographyDataError.invalidArchive + } + } +} + +extension GeographyArchiveDecoder { + fileprivate struct Archive: Decodable { + let version: Int + let coordinateScale: Int + let sources: [StoredSource] + let paths: [StoredPath] + + func lines() throws -> [GeographicPolyline] { + guard version == 2, + (1 ... 1_000_000_000).contains(coordinateScale), + sources.isEmpty == false, + sources.allSatisfy(\.isValid), + Set(sources.map(\.id)).count == sources.count + else { + throw GeographyDataError.invalidArchive + } + var lines: [GeographicPolyline] = [] + lines.reserveCapacity(paths.count) + for (index, path) in paths.enumerated() { + if index.isMultiple(of: 64) { + try Task.checkCancellation() + } + try lines.append(path.polyline(scale: coordinateScale)) + } + try Task.checkCancellation() + return lines + } + } + + fileprivate struct StoredSource: Decodable { + let id: String + let name: String + let release: String + let scale: String + + var isValid: Bool { + let idCharacters = Array(id) + return idCharacters.isEmpty == false && + idCharacters.count <= 64 && + idCharacters.first != "-" && + idCharacters.last != "-" && + id.contains("--") == false && + idCharacters.allSatisfy { character in + character.isASCII && + (character.isLowercase || character.isNumber || character == "-") + } && + [name, release, scale].allSatisfy { value in + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + } + } + + fileprivate struct StoredPath: Decodable { + let kind: GeographyLineKind + let detailLevel: GeographyDetailLevel + let bounds: [Int] + let coordinates: [Int] + + func polyline(scale: Int) throws -> GeographicPolyline { + try GeographyArchiveDecoder.decodePolyline( + kind: kind, + detailLevel: detailLevel, + storedBounds: bounds, + storedCoordinates: coordinates, + coordinateScale: scale, + ) + } + } + + fileprivate static func decodePolyline( + kind: GeographyLineKind, + detailLevel: GeographyDetailLevel, + storedBounds: [Int], + storedCoordinates: [Int], + coordinateScale: Int, + ) throws -> GeographicPolyline { + guard storedBounds.count == 4, + storedCoordinates.count >= 4, + storedCoordinates.count.isMultiple(of: 2) + else { + throw GeographyDataError.invalidArchive + } + let scale = Double(coordinateScale) + let bounds = try GeographicBounds( + southLatitude: Double(storedBounds[0]) / scale, + westLongitude: Double(storedBounds[1]) / scale, + northLatitude: Double(storedBounds[2]) / scale, + eastLongitude: Double(storedBounds[3]) / scale, + ) + var latitude = storedCoordinates[0] + var longitude = storedCoordinates[1] + var decoded = try [coordinate(latitude: latitude, longitude: longitude, scale: scale)] + decoded.reserveCapacity(storedCoordinates.count / 2) + var index = 2 + while index < storedCoordinates.count { + if index.isMultiple(of: 512) { + try Task.checkCancellation() + } + let (nextLatitude, latitudeOverflow) = latitude + .addingReportingOverflow(storedCoordinates[index]) + let (nextLongitude, longitudeOverflow) = longitude + .addingReportingOverflow(storedCoordinates[index + 1]) + guard latitudeOverflow == false, longitudeOverflow == false else { + throw GeographyDataError.invalidArchive + } + latitude = nextLatitude + longitude = nextLongitude + try decoded.append(coordinate(latitude: latitude, longitude: longitude, scale: scale)) + index += 2 + } + try Task.checkCancellation() + return try GeographicPolyline( + kind: kind, + detailLevel: detailLevel, + bounds: bounds, + coordinates: decoded, + ) + } + + fileprivate static func coordinate(latitude: Int, longitude: Int, scale: Double) throws + -> GeoCoordinate + { + do { + return try GeoCoordinate( + latitude: Double(latitude) / scale, + longitude: Double(longitude) / scale, + ) + } catch { + throw GeographyDataError.invalidArchive + } + } +} diff --git a/Throw/ThrowCore/Sources/GeographyModels.swift b/Throw/ThrowCore/Sources/GeographyModels.swift new file mode 100644 index 000000000..6a80d6f5f --- /dev/null +++ b/Throw/ThrowCore/Sources/GeographyModels.swift @@ -0,0 +1,128 @@ +import Foundation + +public enum GeographyLineKind: String, CaseIterable, Codable, Hashable, Sendable { + case coastline + case lake + case river + case nationalBoundary = "national-boundary" + case disputedBoundary = "disputed-boundary" + case regionalBoundary = "regional-boundary" + case countyBoundary = "county-boundary" + case primaryRoad = "primary-road" +} + +/// A style family carried by one semantic and projected line layer. +public protocol ProjectionLineStyle: Hashable, Sendable {} + +extension GeographyLineKind: ProjectionLineStyle {} + +/// The closed style family for the Transit network layer. +public enum TransitNetworkLineStyle: Hashable, Sendable, ProjectionLineStyle { + case route +} + +/// Controls the largest Map radius at which a geographic line can appear. +public enum GeographyDetailLevel: String, CaseIterable, Codable, Hashable, Sendable { + case wide + case standard + case local + + public func includes(mapRadius: NauticalMiles) -> Bool { + switch self { + case .wide: + mapRadius.value <= 240 + case .standard: + mapRadius.value <= 80 + case .local: + mapRadius.value <= 20 + } + } +} + +/// A non-wrapping WGS84 bounding box. Bundled paths split at the antimeridian. +public struct GeographicBounds: Hashable, Sendable { + public let southLatitude: Double + public let westLongitude: Double + public let northLatitude: Double + public let eastLongitude: Double + + public init( + southLatitude: Double, + westLongitude: Double, + northLatitude: Double, + eastLongitude: Double, + ) throws { + guard southLatitude.isFinite, westLongitude.isFinite, + northLatitude.isFinite, eastLongitude.isFinite, + (-90 ... 90).contains(southLatitude), + (-90 ... 90).contains(northLatitude), + (-180 ... 180).contains(westLongitude), + (-180 ... 180).contains(eastLongitude), + southLatitude <= northLatitude, + westLongitude <= eastLongitude + else { + throw GeographyDataError.invalidArchive + } + self.southLatitude = southLatitude + self.westLongitude = westLongitude + self.northLatitude = northLatitude + self.eastLongitude = eastLongitude + } +} + +/// One validated line whose style family is fixed by its generic argument. +public struct ProjectionPolyline: Hashable, Sendable { + public let style: Style + public let detailLevel: GeographyDetailLevel + public let bounds: GeographicBounds + public let coordinates: [GeoCoordinate] + + public init( + style: Style, + detailLevel: GeographyDetailLevel, + bounds: GeographicBounds, + coordinates: [GeoCoordinate], + ) throws { + guard coordinates.count >= 2, + coordinates.allSatisfy({ coordinate in + (bounds.southLatitude ... bounds.northLatitude).contains(coordinate.latitude) && + (bounds.westLongitude ... bounds.eastLongitude).contains( + coordinate.longitude, + ) + }) + else { + throw GeographyDataError.invalidArchive + } + self.style = style + self.detailLevel = detailLevel + self.bounds = bounds + self.coordinates = coordinates + } +} + +extension ProjectionPolyline where Style == GeographyLineKind { + public init( + kind: GeographyLineKind, + detailLevel: GeographyDetailLevel, + bounds: GeographicBounds, + coordinates: [GeoCoordinate], + ) throws { + try self.init( + style: kind, + detailLevel: detailLevel, + bounds: bounds, + coordinates: coordinates, + ) + } + + public var kind: GeographyLineKind { + style + } +} + +public typealias GeographicPolyline = ProjectionPolyline + +public enum GeographyDataError: Error, Equatable, Sendable { + case resourceMissing + case invalidArchive +} diff --git a/Throw/ThrowCore/Sources/GeographyPreferences.swift b/Throw/ThrowCore/Sources/GeographyPreferences.swift new file mode 100644 index 000000000..f04ee2fc7 --- /dev/null +++ b/Throw/ThrowCore/Sources/GeographyPreferences.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct GeographyPreferences: Equatable, Sendable { + public static let allowedIntensityPercent = 0.0 ... 20.0 + public static let defaultValue = try! GeographyPreferences( + isEnabled: true, + intensityPercent: 8, + ) + + public let isEnabled: Bool + public let intensityPercent: Double + + public init(isEnabled: Bool, intensityPercent: Double) throws { + guard intensityPercent.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "geographyIntensity") + } + guard Self.allowedIntensityPercent.contains(intensityPercent) else { + throw ThrowValidationError.outOfRange( + field: "geographyIntensity", + closedRange: Self.allowedIntensityPercent, + ) + } + self.isEnabled = isEnabled + self.intensityPercent = intensityPercent + } + + public func replacingIsEnabled(_ isEnabled: Bool) -> Self { + Self(validatedIsEnabled: isEnabled, intensityPercent: intensityPercent) + } + + public func replacingIntensityPercent(_ intensityPercent: Double) throws -> Self { + try Self(isEnabled: isEnabled, intensityPercent: intensityPercent) + } + + private init(validatedIsEnabled isEnabled: Bool, intensityPercent: Double) { + self.isEnabled = isEnabled + self.intensityPercent = intensityPercent + } +} diff --git a/Throw/ThrowCore/Sources/HTTPTransport.swift b/Throw/ThrowCore/Sources/HTTPTransport.swift new file mode 100644 index 000000000..6d123893e --- /dev/null +++ b/Throw/ThrowCore/Sources/HTTPTransport.swift @@ -0,0 +1,270 @@ +import Foundation + +public enum HTTPMethod: String, Hashable, Sendable { + case get = "GET" +} + +public enum HTTPHeaderField: String, Hashable, Sendable { + case accept = "Accept" + case acceptEncoding = "Accept-Encoding" + case acceptVersion = "Accept-Version" + case authorization = "Authorization" + case rapidAPIHost = "X-RapidAPI-Host" + case rapidAPIKey = "X-RapidAPI-Key" +} + +public struct HTTPRequest: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let method: HTTPMethod + public let url: URL + public let headers: [HTTPHeaderField: String] + public let timeoutSeconds: TimeInterval + + public init( + method: HTTPMethod, + url: URL, + headers: [HTTPHeaderField: String], + timeoutSeconds: TimeInterval, + ) { + precondition(timeoutSeconds > 0 && timeoutSeconds.isFinite) + self.method = method + self.url = url + self.headers = headers + self.timeoutSeconds = timeoutSeconds + } + + /// Deliberately excludes the URL (which may contain observer coordinates or + /// a private receiver address) and every header value (which may be a key). + public var description: String { + " headers=>" + } + + public var debugDescription: String { + description + } +} + +public struct HTTPResponse: Equatable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let statusCode: Int + public let headers: [String: String] + public let data: Data + + public init(statusCode: Int, headers: [String: String], data: Data) { + self.statusCode = statusCode + self.headers = Dictionary( + uniqueKeysWithValues: headers.map { ($0.key.lowercased(), $0.value) }, + ) + self.data = data + } + + public func headerValue(for name: String) -> String? { + headers[name.lowercased()] + } + + /// Response bodies and header values are never included in diagnostics. + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public struct HTTPTransportFailure: Error, Equatable, Sendable { + public let category: AircraftTransportErrorCategory + + public init(category: AircraftTransportErrorCategory) { + self.category = category + } +} + +public protocol HTTPTransport: Sendable { + func response(for request: HTTPRequest) async throws -> HTTPResponse +} + +public enum HTTPNetworkScope: Equatable, Sendable { + case internet + case localNetwork +} + +/// URLSession transport with ephemeral, no-cache sessions suitable for live +/// position feeds. Requests remain inspectable value types in tests. +public struct URLSessionHTTPTransport: HTTPTransport { + private let session: URLSession + private let networkScope: HTTPNetworkScope + + public init(session: URLSession, networkScope: HTTPNetworkScope) { + self.session = session + self.networkScope = networkScope + } + + public static func makeCloud() -> URLSessionHTTPTransport { + URLSessionHTTPTransport( + session: makeSession( + timeoutSeconds: 8, + redirectDelegate: CloudRedirectRejectingDelegate(), + ), + networkScope: .internet, + ) + } + + public static func makeLocal() -> URLSessionHTTPTransport { + URLSessionHTTPTransport( + session: makeSession( + timeoutSeconds: 3, + redirectDelegate: ReadsbRedirectValidatingDelegate(), + ), + networkScope: .localNetwork, + ) + } + + public func response(for request: HTTPRequest) async throws -> HTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.timeoutInterval = request.timeoutSeconds + urlRequest.cachePolicy = .reloadIgnoringLocalCacheData + for (field, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: field.rawValue) + } + + do { + let (data, response) = try await session.data(for: urlRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPTransportFailure(category: .invalidResponse) + } + let headers = httpResponse.allHeaderFields + .reduce(into: [String: String]()) { result, item in + guard let key = item.key as? String else { return } + result[key] = String(describing: item.value) + } + return HTTPResponse(statusCode: httpResponse.statusCode, headers: headers, data: data) + } catch is CancellationError { + throw CancellationError() + } catch let failure as HTTPTransportFailure { + throw failure + } catch let error as URLError { + let failure = try Self.failure(for: error, networkScope: networkScope) + throw failure + } catch { + throw HTTPTransportFailure(category: .other) + } + } + + private static func makeSession( + timeoutSeconds: TimeInterval, + redirectDelegate: (any URLSessionTaskDelegate)?, + ) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.timeoutIntervalForRequest = timeoutSeconds + configuration.timeoutIntervalForResource = timeoutSeconds + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + return URLSession( + configuration: configuration, + delegate: redirectDelegate, + delegateQueue: nil, + ) + } + + static func failure( + for error: URLError, + networkScope: HTTPNetworkScope, + ) throws -> HTTPTransportFailure { + if error.code == .cancelled { + try Task.checkCancellation() + } + return HTTPTransportFailure( + category: category(for: error.code, networkScope: networkScope), + ) + } + + static func category( + for code: URLError.Code, + networkScope: HTTPNetworkScope, + ) -> AircraftTransportErrorCategory { + if code == .notConnectedToInternet, networkScope == .localNetwork { + return .localNetworkDenied + } + return switch code { + case .cancelled: + .cancelled + case .timedOut: + .timedOut + case .notConnectedToInternet, .networkConnectionLost, .dataNotAllowed: + .offline + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, + .secureConnectionFailed, .serverCertificateHasBadDate, + .serverCertificateUntrusted, .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid, .clientCertificateRejected, + .clientCertificateRequired: + .connection + case .badURL, .unsupportedURL, .redirectToNonExistentLocation, + .badServerResponse, .zeroByteResource, .cannotDecodeRawData, + .cannotDecodeContentData, .cannotParseResponse, + .appTransportSecurityRequiresSecureConnection, + .fileDoesNotExist, .fileIsDirectory, .noPermissionsToReadFile, + .dataLengthExceedsMaximum, .internationalRoamingOff, + .callIsActive, .backgroundSessionRequiresSharedContainer, + .backgroundSessionInUseByAnotherProcess, + .backgroundSessionWasDisconnected, + .userAuthenticationRequired, .resourceUnavailable, + .cannotLoadFromNetwork, .downloadDecodingFailedMidStream, + .downloadDecodingFailedToComplete, .httpTooManyRedirects: + .other + // `URLError.Code` is a raw-value struct rather than an enum, so + // callers can construct values beyond Foundation's named cases. + default: + .other + } + } +} + +/// Cloud feeds reject redirects so an authentication header can never be +/// replayed to a redirected origin. The provider endpoint must answer directly. +final class CloudRedirectRejectingDelegate: NSObject, URLSessionTaskDelegate, + @unchecked Sendable +{ + func urlSession( + _: URLSession, + task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest _: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void, + ) { + completionHandler(nil) + } +} + +/// Reapplies the readsb URL policy to every redirected request before the +/// session is allowed to follow it. +final class ReadsbRedirectValidatingDelegate: NSObject, URLSessionTaskDelegate, + @unchecked Sendable +{ + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void, + ) { + guard let url = request.url, + let originalURL = task.originalRequest?.url, + let endpoint = ReadsbJSONEndpoint(url: originalURL) + else { + completionHandler(nil) + return + } + do { + _ = try ReadsbURLValidator.validateRedirectTarget(url, endpoint: endpoint) + completionHandler(request) + } catch { + completionHandler(nil) + } + } +} diff --git a/Throw/ThrowCore/Sources/LayerCatalog.swift b/Throw/ThrowCore/Sources/LayerCatalog.swift new file mode 100644 index 000000000..c281b3976 --- /dev/null +++ b/Throw/ThrowCore/Sources/LayerCatalog.swift @@ -0,0 +1,151 @@ +import Foundation + +public enum LayerAvailability: Hashable, Sendable { + case enabled + case disabled + case planned +} + +/// A typed producer of semantic layer frames. +public protocol ProjectionLayerRuntime: Sendable { + associatedtype Input: Sendable + associatedtype Layer: ProjectionLayerKind + + func frame(for input: Input) async throws -> ProjectionLayerFrame +} + +public struct LayerRuntimeFactory: Sendable { + private let makeRuntime: @Sendable () -> Runtime + + public init(makeRuntime: @escaping @Sendable () -> Runtime) { + self.makeRuntime = makeRuntime + } + + public func callAsFunction() -> Runtime { + makeRuntime() + } +} + +public struct LayerDescriptor: Sendable { + public let id: LayerID + public let availability: LayerAvailability + public let supportedModes: Set + public let zOrder: Int + public let runtimeFactory: LayerRuntimeFactory + + public init( + availability: LayerAvailability, + runtimeFactory: LayerRuntimeFactory, + ) { + id = Runtime.Layer.id + self.availability = availability + supportedModes = Runtime.Layer.supportedModes + zOrder = Runtime.Layer.zOrder + self.runtimeFactory = runtimeFactory + } +} + +/// The sole type-erasure boundary for heterogeneous catalog enumeration. +/// Runtime inputs remain typed everywhere that a runtime is invoked. +public struct AnyLayerRuntimeFactory: Sendable { + private let makeRuntime: @Sendable () -> any ProjectionLayerRuntime + + public init(_ factory: LayerRuntimeFactory) { + makeRuntime = { factory() } + } + + public func callAsFunction() -> any ProjectionLayerRuntime { + makeRuntime() + } +} + +public struct AnyLayerDescriptor: Identifiable, Sendable { + public let id: LayerID + public let availability: LayerAvailability + public let supportedModes: Set + public let zOrder: Int + public let runtimeFactory: AnyLayerRuntimeFactory + + public init(_ descriptor: LayerDescriptor) { + id = descriptor.id + availability = descriptor.availability + supportedModes = descriptor.supportedModes + zOrder = descriptor.zOrder + runtimeFactory = AnyLayerRuntimeFactory(descriptor.runtimeFactory) + } +} + +/// The fixed layer catalog. New layers are source changes, never downloaded +/// runtime plugins. +public struct LayerCatalog: Sendable { + public static let standard = LayerCatalog( + flightsFactory: LayerRuntimeFactory { + FlightsLayerRuntime(typeCatalog: .bundled, airportCatalog: .bundled) + }, + geographyFactory: LayerRuntimeFactory { + GeographyLayerRuntime(dataSource: BundledGeographyDataSource()) + }, + ) + + /// Typed descriptors used to construct the enabled production layers. + public let flights: LayerDescriptor + public let geography: LayerDescriptor + + /// The heterogeneous catalog used for discovery and presentation only. + public let descriptors: [AnyLayerDescriptor] + + public init( + flightsFactory: LayerRuntimeFactory, + geographyFactory: LayerRuntimeFactory, + ) { + let flights = LayerDescriptor( + availability: LayerAvailability.enabled, + runtimeFactory: flightsFactory, + ) + let geography = LayerDescriptor( + availability: LayerAvailability.enabled, + runtimeFactory: geographyFactory, + ) + let stars = LayerDescriptor( + availability: LayerAvailability.planned, + runtimeFactory: LayerRuntimeFactory { + EmptyLayerRuntime() + }, + ) + let satellites = LayerDescriptor( + availability: LayerAvailability.planned, + runtimeFactory: LayerRuntimeFactory { + EmptyLayerRuntime() + }, + ) + let transitNetwork = LayerDescriptor( + availability: LayerAvailability.planned, + runtimeFactory: LayerRuntimeFactory { + EmptyLayerRuntime() + }, + ) + let transitVehicles = LayerDescriptor( + availability: LayerAvailability.planned, + runtimeFactory: LayerRuntimeFactory { + EmptyLayerRuntime() + }, + ) + + self.flights = flights + self.geography = geography + descriptors = [ + AnyLayerDescriptor(flights), + AnyLayerDescriptor(geography), + AnyLayerDescriptor(stars), + AnyLayerDescriptor(satellites), + AnyLayerDescriptor(transitNetwork), + AnyLayerDescriptor(transitVehicles), + ] + } +} + +private struct EmptyLayerRuntime: ProjectionLayerRuntime { + func frame(for date: Date) async throws -> ProjectionLayerFrame { + .empty(observedAt: date) + } +} diff --git a/Throw/ThrowCore/Sources/LocationSource.swift b/Throw/ThrowCore/Sources/LocationSource.swift new file mode 100644 index 000000000..110b24d66 --- /dev/null +++ b/Throw/ThrowCore/Sources/LocationSource.swift @@ -0,0 +1,243 @@ +import CoreLocation +import Foundation + +public enum LocationAuthorization: String, Hashable, Sendable { + case notDetermined + case denied + case restricted + case whenInUse + case always +} + +public struct LocationFix: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + public let position: ObserverPosition + public let horizontalAccuracyMeters: Double + public let observedAt: Date + + public init( + position: ObserverPosition, + horizontalAccuracyMeters: Double, + observedAt: Date, + ) throws { + guard horizontalAccuracyMeters.isFinite, horizontalAccuracyMeters >= 0 else { + throw ThrowValidationError.outOfRange( + field: "horizontalAccuracy", + closedRange: 0 ... Double.greatestFiniteMagnitude, + ) + } + self.position = position + self.horizontalAccuracyMeters = horizontalAccuracyMeters + self.observedAt = observedAt + } + + public var description: String { + ">" + } + + public var debugDescription: String { + description + } +} + +public enum LocationEvent: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + case authorization(LocationAuthorization) + case fix(LocationFix) + case trueHeadingHint(Bearing) + case invalidSample + case failed + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +@MainActor +public protocol ThrowLocationSource: AnyObject, Sendable { + var events: AsyncStream { get } + func requestWhenInUseAuthorization() + func startUpdates() + func stopUpdates() +} + +/// Foreground-only Core Location source. Delegate callbacks expose only true +/// headings; an invalid true heading is ignored rather than using magnetic north. +@MainActor +public final class CoreLocationThrowSource: NSObject, ThrowLocationSource { + public nonisolated let events: AsyncStream + + private let manager: CLLocationManager + private nonisolated let continuation: AsyncStream.Continuation + + override public init() { + let pair = AsyncStream.makeStream( + of: LocationEvent.self, + bufferingPolicy: .bufferingNewest(8), + ) + events = pair.stream + continuation = pair.continuation + manager = CLLocationManager() + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + public func requestWhenInUseAuthorization() { + manager.requestWhenInUseAuthorization() + } + + public func startUpdates() { + manager.startUpdatingLocation() + if CLLocationManager.headingAvailable() { + manager.startUpdatingHeading() + } + } + + public func stopUpdates() { + manager.stopUpdatingLocation() + manager.stopUpdatingHeading() + } + + deinit { + continuation.finish() + } +} + +extension CoreLocationThrowSource: CLLocationManagerDelegate { + public nonisolated func locationManager( + _: CLLocationManager, + didUpdateLocations locations: [CLLocation], + ) { + guard let fix = LocationFixEvaluator.bestValidFix( + from: locations, + at: Date(), + ) else { + continuation.yield(.invalidSample) + return + } + continuation.yield(.fix(fix)) + } + + public nonisolated func locationManager( + _: CLLocationManager, + didUpdateHeading newHeading: CLHeading, + ) { + guard newHeading.trueHeading >= 0 else { return } + do { + try continuation.yield(.trueHeadingHint(Bearing(degrees: newHeading.trueHeading))) + } catch { + continuation.yield(.invalidSample) + } + } + + public nonisolated func locationManager( + _: CLLocationManager, + didFailWithError _: any Error, + ) { + continuation.yield(.failed) + } + + public nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + continuation.yield(.authorization(Self.authorization(manager.authorizationStatus))) + } + + private nonisolated static func authorization( + _ status: CLAuthorizationStatus, + ) -> LocationAuthorization { + switch status { + case .notDetermined: .notDetermined + case .restricted: .restricted + case .denied: .denied + case .authorizedAlways: .always + case .authorizedWhenInUse: .whenInUse + @unknown default: .notDetermined + } + } +} + +public enum LocationFixDecision: Hashable, Sendable, CustomStringConvertible, + CustomDebugStringConvertible +{ + case keepWaiting(best: LocationFix?) + case acceptTarget(LocationFix) + case offerBest(LocationFix?) + + public var description: String { + "" + } + + public var debugDescription: String { + description + } +} + +public enum LocationFixEvaluator { + public static let targetAccuracyMeters = 100.0 + public static let maximumWait: TimeInterval = 20 + public static let maximumSampleAge: TimeInterval = 15 + public static let maximumFutureSkew: TimeInterval = 5 + + public static func isValid(_ fix: LocationFix, at date: Date) -> Bool { + let age = date.timeIntervalSince(fix.observedAt) + return age >= -maximumFutureSkew && age <= maximumSampleAge + } + + /// Selects by accuracy only after rejecting stale, future, and invalid samples. + static func bestValidFix(from locations: [CLLocation], at date: Date) -> LocationFix? { + var bestFix: LocationFix? + for location in locations + where location.horizontalAccuracy >= 0 && location.verticalAccuracy >= 0 + { + let fix: LocationFix + do { + fix = try LocationFix( + position: ObserverPosition( + coordinate: GeoCoordinate( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + ), + altitude: Altitude(feet: location.altitude / 0.3048), + ), + horizontalAccuracyMeters: location.horizontalAccuracy, + observedAt: location.timestamp, + ) + } catch { + continue + } + guard isValid(fix, at: date) else { continue } + if let bestFix, + bestFix.horizontalAccuracyMeters <= fix.horizontalAccuracyMeters + { + continue + } + bestFix = fix + } + return bestFix + } + + public static func decision( + bestFix: LocationFix?, + elapsed: TimeInterval, + at date: Date, + ) -> LocationFixDecision { + let currentBestFix = bestFix.flatMap { fix in + isValid(fix, at: date) ? fix : nil + } + if let currentBestFix, + currentBestFix.horizontalAccuracyMeters <= targetAccuracyMeters + { + return .acceptTarget(currentBestFix) + } + if elapsed >= maximumWait { + return .offerBest(currentBestFix) + } + return .keepWaiting(best: currentBestFix) + } +} diff --git a/Throw/ThrowCore/Sources/Logging/ThrowDurableLogging.swift b/Throw/ThrowCore/Sources/Logging/ThrowDurableLogging.swift new file mode 100644 index 000000000..8a536b58b --- /dev/null +++ b/Throw/ThrowCore/Sources/Logging/ThrowDurableLogging.swift @@ -0,0 +1,290 @@ +import Foundation +import PeriscopeCore +import Synchronization + +/// One process-owned durable logging session after its store is attached. +public protocol ThrowDurableLoggingSession: Sendable { + /// Applies Throw's bounded history policy without changing store readiness. + func pruneHistory() async +} + +/// Opens and attaches Throw's one durable logging session. +public protocol ThrowDurableLoggingStarting: ThrowSessionFailureLogging { + func start() async throws -> any ThrowDurableLoggingSession +} + +/// Opens an on-disk Periscope store and routes Throw's process log into it. +public final class PeriscopeThrowDurableLoggingStarter: ThrowDurableLoggingStarting, Sendable { + private let makeStore: @Sendable () async throws -> PeriscopeStore + private let softwareCreditsLoadFailure: ThrowSoftwareCreditsLoadFailure? + private let now: @Sendable () -> Date + private let preAttachmentRecorder: ThrowPreAttachmentSessionLogRecorder + private let logger: Log + + public convenience init(softwareCreditsLoadFailure: ThrowSoftwareCreditsLoadFailure?) { + self.init( + system: .shared, + pendingSoftwareCreditsLoadFailure: softwareCreditsLoadFailure, + now: { Date() }, + makeStore: { + try await PeriscopeStore.make( + storage: .onDisk, + session: .current(attributes: [:]), + ) + }, + ) + } + + #if DEBUG + @_spi(Testing) public convenience init( + system: Periscope, + storage: PeriscopeStore.Storage, + softwareCreditsLoadFailure: ThrowSoftwareCreditsLoadFailure?, + now: @escaping @Sendable () -> Date, + ) { + self.init( + system: system, + pendingSoftwareCreditsLoadFailure: softwareCreditsLoadFailure, + now: now, + makeStore: { + try await PeriscopeStore.make( + storage: storage, + session: .current(attributes: [:]), + ) + }, + ) + } + + @_spi(Testing) public convenience init( + system: Periscope, + softwareCreditsLoadFailure: ThrowSoftwareCreditsLoadFailure?, + now: @escaping @Sendable () -> Date, + makeStore: @escaping @Sendable () async throws -> PeriscopeStore, + ) { + self.init( + system: system, + pendingSoftwareCreditsLoadFailure: softwareCreditsLoadFailure, + now: now, + makeStore: makeStore, + ) + } + #endif + + private init( + system: Periscope, + pendingSoftwareCreditsLoadFailure: ThrowSoftwareCreditsLoadFailure?, + now: @escaping @Sendable () -> Date, + makeStore: @escaping @Sendable () async throws -> PeriscopeStore, + ) { + self.makeStore = makeStore + softwareCreditsLoadFailure = pendingSoftwareCreditsLoadFailure + self.now = now + let recorder = ThrowPreAttachmentSessionLogRecorder(system: system) + preAttachmentRecorder = recorder + logger = Log(recorder: recorder)(ThrowSessionLogEvent.self) + } + + public func start() async throws -> any ThrowDurableLoggingSession { + do { + let store = try await makeStore() + await preAttachmentRecorder.attach(store) + logger { .durableLoggingReady } + recordSoftwareCreditsLoadFailureIfNeeded() + return PeriscopeThrowDurableLoggingSession( + store: store, + now: now, + logger: logger, + ) + } catch { + preAttachmentRecorder.storeOpenFailed() + recordSoftwareCreditsLoadFailureIfNeeded() + logger(attachments: [.error(error, name: "open-error")]) { + .durableLoggingUnavailable(description: String(describing: error)) + } + throw error + } + } + + public func recordColdLaunchFailure( + at boundary: ThrowSessionLogEvent.ColdLaunchBoundary, + error: any Error, + ) { + ThrowLog.recordColdLaunchFailure( + at: boundary, + error: error, + using: logger, + ) + } + + public func recordPostLaunchFailure( + at operation: ThrowSessionLogEvent.PostLaunchOperation, + error: any Error, + ) { + ThrowLog.recordPostLaunchFailure( + at: operation, + error: error, + using: logger, + ) + } + + private func recordSoftwareCreditsLoadFailureIfNeeded() { + guard let softwareCreditsLoadFailure else { return } + ThrowLog.recordSoftwareCreditsLoadFailure( + softwareCreditsLoadFailure, + using: logger, + ) + } +} + +/// Retains exact typed session records until the durable store has a safe handoff point. +private final class ThrowPreAttachmentSessionLogRecorder: LogRecorder, Sendable { + private struct Buffer { + var scopes: [LogScope] = [] + var scopeIDs: Set = [] + var recordsSentToExistingSinks: [LogRecord] = [] + var recordsHeldForHandoff: [LogRecord] = [] + + mutating func define(_ scope: LogScope) { + guard scopeIDs.insert(scope.id).inserted else { return } + scopes.append(scope) + } + } + + private enum DeliveryState { + case buffering(Buffer) + case handingOff(Buffer) + case attached + case osLogOnly + } + + private let system: Periscope + private let state = Mutex(DeliveryState.buffering(Buffer())) + + init(system: Periscope) { + self.system = system + } + + func defineScope(_ scope: LogScope) { + state.withLock { state in + switch state { + case var .buffering(buffer): + buffer.define(scope) + state = .buffering(buffer) + case var .handingOff(buffer): + buffer.define(scope) + state = .handingOff(buffer) + case .attached, .osLogOnly: + break + } + system.defineScope(scope) + } + } + + func record(_ record: LogRecord) { + state.withLock { state in + switch state { + case var .buffering(buffer): + buffer.recordsSentToExistingSinks.append(record) + state = .buffering(buffer) + system.record(record) + case var .handingOff(buffer): + buffer.recordsHeldForHandoff.append(record) + state = .handingOff(buffer) + case .attached, .osLogOnly: + system.record(record) + } + } + } + + func shouldRecord(level: LogLevel, scopes: [ScopeID]) -> Bool { + system.shouldRecord(level: level, scopes: scopes) + } + + func beginSpan(key: SpanKey, span: OpenSpan, began: LogRecord?) -> OpenSpan? { + system.beginSpan(key: key, span: span, began: began) + } + + func closeSpan(key: SpanKey) -> OpenSpan? { + system.closeSpan(key: key) + } + + func attach(_ durableStore: PeriscopeStore) async { + let startedHandoff = state.withLock { state -> Bool in + guard case let .buffering(buffer) = state else { return false } + state = .handingOff(buffer) + return true + } + precondition(startedHandoff, "Throw's durable log store must attach exactly once") + + await system.flush() + let replay = state.withLock { state -> Buffer in + guard case let .handingOff(buffer) = state else { + preconditionFailure("Throw's durable log handoff changed state unexpectedly") + } + return buffer + } + await durableStore.defineScopes(replay.scopes) + await durableStore.write(replay.recordsSentToExistingSinks) + await durableStore.flush() + + state.withLock { state in + guard case let .handingOff(buffer) = state else { + preconditionFailure("Throw's durable log handoff changed state unexpectedly") + } + _ = system.add(sink: durableStore) + state = .attached + for record in buffer.recordsHeldForHandoff { + system.record(record) + } + } + await system.flush() + } + + func storeOpenFailed() { + state.withLock { state in + guard case .buffering = state else { return } + state = .osLogOnly + } + } +} + +private actor PeriscopeThrowDurableLoggingSession: ThrowDurableLoggingSession { + private static let retentionWindow: TimeInterval = 100 * 24 * 60 * 60 + private static let retainedEventLimit = 50000 + + private let store: PeriscopeStore + private let now: @Sendable () -> Date + private let logger: Log + + init( + store: PeriscopeStore, + now: @escaping @Sendable () -> Date, + logger: Log, + ) { + self.store = store + self.now = now + self.logger = logger + } + + func pruneHistory() async { + do { + let cutoff = now().addingTimeInterval(-Self.retentionWindow) + let expired = try await store.pruneEvents(olderThan: cutoff) + let overflow = try await store.pruneEvents( + keepingNewest: Self.retainedEventLimit, + ) + logger { + .durableLoggingHistoryPruned( + expiredEventCount: expired, + overflowEventCount: overflow, + ) + } + } catch { + logger(attachments: [.error(error, name: "prune-error")]) { + .durableLoggingHistoryPruneFailed( + description: String(describing: error), + ) + } + } + } +} diff --git a/Throw/ThrowCore/Sources/Logging/ThrowLog.swift b/Throw/ThrowCore/Sources/Logging/ThrowLog.swift new file mode 100644 index 000000000..9798374b2 --- /dev/null +++ b/Throw/ThrowCore/Sources/Logging/ThrowLog.swift @@ -0,0 +1,823 @@ +import PeriscopeCore + +public struct ThrowRootLogEvent: LogEvent { + public static let eventName = "Throw" + + public var message: String { + "" + } +} + +/// Process-session events for launch and durable diagnostics availability. +public enum ThrowSessionLogEvent: LogEvent, Equatable { + /// The dependency boundary that prevented Throw from becoming operational. + public enum ColdLaunchBoundary: String, CaseIterable, Codable, Equatable, Sendable { + case preferences + case credential + case unexpected + } + + /// A recoverable operation boundary after the session becomes operational. + public enum PostLaunchOperation: String, CaseIterable, Codable, Equatable, Sendable { + case preferencePersistence = "preference-persistence" + case aircraftSource = "aircraft-source" + case rapidAPICredential = "rapidapi-credential" + case flightradar24Credential = "flightradar24-credential" + case location + case playlist + case onboarding + case projectionPreparation = "projection-preparation" + case projectionRendering = "projection-rendering" + } + + case durableLoggingReady + case durableLoggingUnavailable(description: String) + case durableLoggingHistoryPruned(expiredEventCount: Int, overflowEventCount: Int) + case durableLoggingHistoryPruneFailed(description: String) + case coldLaunchFailed(boundary: ColdLaunchBoundary) + case softwareCreditsLoadFailed + case postLaunchOperationFailed(operation: PostLaunchOperation) + + public static let eventName = "ThrowSession" + + public var level: LogLevel { + switch self { + case .durableLoggingReady, .durableLoggingHistoryPruned: + .info + case .durableLoggingHistoryPruneFailed: + .warning + case .durableLoggingUnavailable, .coldLaunchFailed, + .softwareCreditsLoadFailed, .postLaunchOperationFailed: + .error + } + } + + public var message: String { + switch self { + case .durableLoggingReady: + "Durable logging is ready" + case let .durableLoggingUnavailable(description): + "Durable logging is unavailable: \(description)" + case let .durableLoggingHistoryPruned(expiredEventCount, overflowEventCount): + "Pruned \(expiredEventCount) expired log event(s) and \(overflowEventCount) event(s) past the size limit" + case let .durableLoggingHistoryPruneFailed(description): + "Failed to prune durable log history: \(description)" + case let .coldLaunchFailed(boundary): + "Cold launch failed at the \(boundary.rawValue) boundary" + case .softwareCreditsLoadFailed: + "Software credits failed to load" + case let .postLaunchOperationFailed(operation): + "Post-launch operation failed at the \(operation.rawValue) boundary" + } + } + + public var remoteMessage: String { + switch self { + case .durableLoggingReady: + "Durable logging is ready" + case .durableLoggingUnavailable: + "Durable logging is unavailable" + case .durableLoggingHistoryPruned: + "Durable log history was pruned" + case .durableLoggingHistoryPruneFailed: + "Failed to prune durable log history" + case .coldLaunchFailed: + "Cold launch failed" + case .softwareCreditsLoadFailed: + "Software credits failed to load" + case .postLaunchOperationFailed: + "Post-launch operation failed" + } + } + + public var remoteFields: [RemoteLogField] { + switch self { + case let .durableLoggingHistoryPruned(expiredEventCount, overflowEventCount): + [ + RemoteLogField( + key: RemoteLogFieldKey("expired_event_count"), + value: .count(expiredEventCount), + ), + RemoteLogField( + key: RemoteLogFieldKey("overflow_event_count"), + value: .count(overflowEventCount), + ), + ] + case let .coldLaunchFailed(boundary): + [ + RemoteLogField( + key: RemoteLogFieldKey("boundary"), + value: .category(RemoteLogCategory(boundary)), + ), + ] + case let .postLaunchOperationFailed(operation): + [ + RemoteLogField( + key: RemoteLogFieldKey("operation"), + value: .category(RemoteLogCategory(operation)), + ), + ] + case .durableLoggingReady, .durableLoggingUnavailable, + .durableLoggingHistoryPruneFailed, .softwareCreditsLoadFailed: + [] + } + } +} + +public enum AircraftPollingLogEvent: Hashable, Sendable { + public static let eventName = "AircraftPollingLogEvent" + public static let eventVersion = 3 + + public enum Kind: String, CaseIterable, Codable, Hashable, Sendable { + case sourceActivated = "source-activated" + case receiverMetadataFallback = "receiver-metadata-fallback" + case requestSucceeded = "request-succeeded" + case partialSchemaDrift = "partial-schema-drift" + case requestFailed = "request-failed" + case retryScheduled = "retry-scheduled" + case pollingStopped = "polling-stopped" + } + + public enum FailureCategory: String, CaseIterable, Codable, Hashable, Sendable { + case invalidConfiguration = "invalid-configuration" + case missingCredential = "missing-credential" + case invalidCredential = "invalid-credential" + case subscriptionRequired = "subscription-required" + case entitlementRejected = "entitlement-rejected" + case quotaReached = "quota-reached" + case provider + case transportCancelled = "transport-cancelled" + case transportTimedOut = "transport-timed-out" + case transportOffline = "transport-offline" + case transportLocalNetworkDenied = "transport-local-network-denied" + case transportConnection = "transport-connection" + case transportInvalidResponse = "transport-invalid-response" + case transportOther = "transport-other" + case decoding + } + + public struct SourceActivation: Hashable, Sendable { + public let source: AircraftSourceKind + + public init(source: AircraftSourceKind) { + self.source = source + } + } + + public struct ReceiverMetadataFallback: Hashable, Sendable { + public let failureCategory: FailureCategory + + public init(failureCategory: FailureCategory) { + self.failureCategory = failureCategory + } + } + + public struct RequestSuccess: Hashable, Sendable { + public let source: AircraftSourceKind + public let requestCount: Int + public let durationMilliseconds: Int + public let httpStatus: Int? + public let decodedAircraftCount: Int + + public init( + source: AircraftSourceKind, + requestCount: Int, + durationMilliseconds: Int, + httpStatus: Int?, + decodedAircraftCount: Int, + ) { + self.source = source + self.requestCount = requestCount + self.durationMilliseconds = durationMilliseconds + self.httpStatus = httpStatus + self.decodedAircraftCount = decodedAircraftCount + } + } + + public struct PartialSchemaDrift: Hashable, Sendable { + public let source: AircraftSourceKind + public let requestCount: Int + public let httpStatus: Int? + public let decodedAircraftCount: Int + public let discardedRecords: AircraftSnapshotDecodingDiagnostics.DiscardedRecords + + public init( + source: AircraftSourceKind, + requestCount: Int, + httpStatus: Int?, + decodedAircraftCount: Int, + discardedRecords: AircraftSnapshotDecodingDiagnostics.DiscardedRecords, + ) { + self.source = source + self.requestCount = requestCount + self.httpStatus = httpStatus + self.decodedAircraftCount = decodedAircraftCount + self.discardedRecords = discardedRecords + } + } + + public struct RequestFailure: Hashable, Sendable { + public let source: AircraftSourceKind + public let requestCount: Int + public let durationMilliseconds: Int + public let httpStatus: Int? + public let failureCategory: FailureCategory + + public init( + source: AircraftSourceKind, + requestCount: Int, + durationMilliseconds: Int, + httpStatus: Int?, + failureCategory: FailureCategory, + ) { + self.source = source + self.requestCount = requestCount + self.durationMilliseconds = durationMilliseconds + self.httpStatus = httpStatus + self.failureCategory = failureCategory + } + } + + public struct RetrySchedule: Hashable, Sendable { + public let source: AircraftSourceKind + public let requestCount: Int + public let httpStatus: Int? + public let decodedAircraftCount: Int? + public let backoffSeconds: Double + public let failureCategory: FailureCategory + + public init( + source: AircraftSourceKind, + requestCount: Int, + httpStatus: Int?, + decodedAircraftCount: Int?, + backoffSeconds: Double, + failureCategory: FailureCategory, + ) { + self.source = source + self.requestCount = requestCount + self.httpStatus = httpStatus + self.decodedAircraftCount = decodedAircraftCount + self.backoffSeconds = backoffSeconds + self.failureCategory = failureCategory + } + } + + public struct PollingStop: Hashable, Sendable { + public let source: AircraftSourceKind + public let requestCount: Int + public let decodedAircraftCount: Int? + + public init( + source: AircraftSourceKind, + requestCount: Int, + decodedAircraftCount: Int?, + ) { + self.source = source + self.requestCount = requestCount + self.decodedAircraftCount = decodedAircraftCount + } + } + + case sourceActivated(SourceActivation) + case receiverMetadataFallback(ReceiverMetadataFallback) + case requestSucceeded(RequestSuccess) + case partialSchemaDrift(PartialSchemaDrift) + case requestFailed(RequestFailure) + case retryScheduled(RetrySchedule) + case pollingStopped(PollingStop) + + public var kind: Kind { + switch self { + case .sourceActivated: .sourceActivated + case .receiverMetadataFallback: .receiverMetadataFallback + case .requestSucceeded: .requestSucceeded + case .partialSchemaDrift: .partialSchemaDrift + case .requestFailed: .requestFailed + case .retryScheduled: .retryScheduled + case .pollingStopped: .pollingStopped + } + } + + public var source: AircraftSourceKind { + switch self { + case let .sourceActivated(event): event.source + case .receiverMetadataFallback: .readsb + case let .requestSucceeded(event): event.source + case let .partialSchemaDrift(event): event.source + case let .requestFailed(event): event.source + case let .retryScheduled(event): event.source + case let .pollingStopped(event): event.source + } + } + + public var requestCount: Int { + switch self { + case .sourceActivated, .receiverMetadataFallback: 0 + case let .requestSucceeded(event): event.requestCount + case let .partialSchemaDrift(event): event.requestCount + case let .requestFailed(event): event.requestCount + case let .retryScheduled(event): event.requestCount + case let .pollingStopped(event): event.requestCount + } + } + + public var level: LogLevel { + switch self { + case .sourceActivated, .requestSucceeded, .pollingStopped: + .info + case .receiverMetadataFallback, .partialSchemaDrift, .requestFailed, + .retryScheduled: + .warning + } + } + + public var message: String { + "Aircraft polling \(kind.rawValue) for \(source.rawValue)" + } + + public var remoteMessage: String { + "Aircraft polling \(kind.rawValue)" + } + + public var remoteFields: [RemoteLogField] { + switch self { + case let .partialSchemaDrift(event): + [ + .eventKind(kind), + sourceRemoteField, + RemoteLogField( + key: RemoteLogFieldKey("malformed_record_count"), + value: .count(event.discardedRecords.malformedRecordCount), + ), + RemoteLogField( + key: RemoteLogFieldKey("missing_position_record_count"), + value: .count(event.discardedRecords.missingPositionRecordCount), + ), + ] + case let .receiverMetadataFallback(event): + [ + .eventKind(kind), + sourceRemoteField, + RemoteLogField( + key: RemoteLogFieldKey("failure_category"), + value: .category(RemoteLogCategory(event.failureCategory)), + ), + ] + case .sourceActivated, .requestSucceeded, .requestFailed, .retryScheduled, + .pollingStopped: + [] + } + } + + private var sourceRemoteField: RemoteLogField { + RemoteLogField( + key: RemoteLogFieldKey("source"), + value: .category(RemoteLogCategory(source)), + ) + } +} + +/// Keeps the flat version-three payload stable while the in-memory event uses +/// case-specific state. Records from the polling coordinator stay decodable with +/// the same event name, field names, and kind vocabulary. +extension AircraftPollingLogEvent: LogEvent { + private enum CodingKeys: String, CodingKey { + case kind + case source + case requestCount + case durationMilliseconds + case httpStatus + case decodedAircraftCount + case decodingDiagnostics + case backoffSeconds + case failureCategory + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .kind) + let source = try container.decode(AircraftSourceKind.self, forKey: .source) + let requestCount = try container.decode(Int.self, forKey: .requestCount) + + switch kind { + case .sourceActivated: + guard requestCount == 0 else { + throw DecodingError.dataCorruptedError( + forKey: .requestCount, + in: container, + debugDescription: "Source activation must precede all requests", + ) + } + self = .sourceActivated(SourceActivation(source: source)) + case .receiverMetadataFallback: + guard source == .readsb else { + throw DecodingError.dataCorruptedError( + forKey: .source, + in: container, + debugDescription: "Receiver metadata belongs to readsb", + ) + } + guard requestCount == 0 else { + throw DecodingError.dataCorruptedError( + forKey: .requestCount, + in: container, + debugDescription: "Receiver metadata fallback must precede all requests", + ) + } + self = try .receiverMetadataFallback( + ReceiverMetadataFallback( + failureCategory: container.decode( + FailureCategory.self, + forKey: .failureCategory, + ), + ), + ) + case .requestSucceeded: + self = try .requestSucceeded( + RequestSuccess( + source: source, + requestCount: requestCount, + durationMilliseconds: container.decode( + Int.self, + forKey: .durationMilliseconds, + ), + httpStatus: container.decodeIfPresent(Int.self, forKey: .httpStatus), + decodedAircraftCount: container.decode( + Int.self, + forKey: .decodedAircraftCount, + ), + ), + ) + case .partialSchemaDrift: + let diagnostics = try container.decode( + AircraftSnapshotDecodingDiagnostics.self, + forKey: .decodingDiagnostics, + ) + guard let discardedRecords = diagnostics.discardedRecords else { + throw DecodingError.dataCorruptedError( + forKey: .decodingDiagnostics, + in: container, + debugDescription: "Partial schema drift must discard a record", + ) + } + self = try .partialSchemaDrift( + PartialSchemaDrift( + source: source, + requestCount: requestCount, + httpStatus: container.decodeIfPresent(Int.self, forKey: .httpStatus), + decodedAircraftCount: container.decode( + Int.self, + forKey: .decodedAircraftCount, + ), + discardedRecords: discardedRecords, + ), + ) + case .requestFailed: + self = try .requestFailed( + RequestFailure( + source: source, + requestCount: requestCount, + durationMilliseconds: container.decode( + Int.self, + forKey: .durationMilliseconds, + ), + httpStatus: container.decodeIfPresent(Int.self, forKey: .httpStatus), + failureCategory: container.decode( + FailureCategory.self, + forKey: .failureCategory, + ), + ), + ) + case .retryScheduled: + self = try .retryScheduled( + RetrySchedule( + source: source, + requestCount: requestCount, + httpStatus: container.decodeIfPresent(Int.self, forKey: .httpStatus), + decodedAircraftCount: container.decodeIfPresent( + Int.self, + forKey: .decodedAircraftCount, + ), + backoffSeconds: container.decode( + Double.self, + forKey: .backoffSeconds, + ), + failureCategory: container.decode( + FailureCategory.self, + forKey: .failureCategory, + ), + ), + ) + case .pollingStopped: + self = try .pollingStopped( + PollingStop( + source: source, + requestCount: requestCount, + decodedAircraftCount: container.decodeIfPresent( + Int.self, + forKey: .decodedAircraftCount, + ), + ), + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(source, forKey: .source) + try container.encode(requestCount, forKey: .requestCount) + + switch self { + case .sourceActivated: + break + case let .receiverMetadataFallback(event): + try container.encode(event.failureCategory, forKey: .failureCategory) + case let .requestSucceeded(event): + try container.encode(event.durationMilliseconds, forKey: .durationMilliseconds) + try container.encodeIfPresent(event.httpStatus, forKey: .httpStatus) + try container.encode(event.decodedAircraftCount, forKey: .decodedAircraftCount) + case let .partialSchemaDrift(event): + try container.encodeIfPresent(event.httpStatus, forKey: .httpStatus) + try container.encode(event.decodedAircraftCount, forKey: .decodedAircraftCount) + try container.encode( + AircraftSnapshotDecodingDiagnostics( + malformedRecordCount: event.discardedRecords.malformedRecordCount, + missingPositionRecordCount: event.discardedRecords + .missingPositionRecordCount, + ), + forKey: .decodingDiagnostics, + ) + case let .requestFailed(event): + try container.encode(event.durationMilliseconds, forKey: .durationMilliseconds) + try container.encodeIfPresent(event.httpStatus, forKey: .httpStatus) + try container.encode(event.failureCategory, forKey: .failureCategory) + case let .retryScheduled(event): + try container.encodeIfPresent(event.httpStatus, forKey: .httpStatus) + try container.encodeIfPresent( + event.decodedAircraftCount, + forKey: .decodedAircraftCount, + ) + try container.encode(event.backoffSeconds, forKey: .backoffSeconds) + try container.encode(event.failureCategory, forKey: .failureCategory) + case let .pollingStopped(event): + try container.encodeIfPresent( + event.decodedAircraftCount, + forKey: .decodedAircraftCount, + ) + } + } +} + +/// A redacted failure loading Throw's bundled geographic archive. +public struct GeographyLogEvent: LogEvent { + public enum FailureCategory: String, CaseIterable, Codable, Hashable, Sendable { + case resourceMissing = "resource-missing" + case invalidArchive = "invalid-archive" + case unexpected + } + + public let failureCategory: FailureCategory + + public init(failureCategory: FailureCategory) { + self.failureCategory = failureCategory + } + + public var level: LogLevel { + .error + } + + public var message: String { + "Bundled geography load failed: \(failureCategory.rawValue)" + } + + public var remoteMessage: String { + "Bundled geography load failed" + } +} + +/// A redacted outcome from optional flight-route enrichment. +public struct FlightRouteLogEvent: LogEvent { + public enum Outcome: String, CaseIterable, Codable, Hashable, Sendable { + case succeeded + case providerFailed = "provider-failed" + case transportFailed = "transport-failed" + case decodingFailed = "decoding-failed" + } + + public let outcome: Outcome + + public init(outcome: Outcome) { + self.outcome = outcome + } + + public var level: LogLevel { + outcome == .succeeded ? .info : .warning + } + + public var message: String { + "Flight route enrichment \(outcome.rawValue)" + } + + public var remoteMessage: String { + message + } +} + +/// A privacy-safe aggregate sample of the projection motion pipeline. +public struct ProjectionMotionLogEvent: LogEvent, Equatable { + public let framesPerSecond: Double + public let aircraftCount: Int + public let usableHorizontalMotionPercent: Double? + public let positionDerivedMotionPercent: Double? + public let meanSampleAgeSeconds: Double? + public let meanProjectedSpeedPerSecond: Double? + public let meanCorrectionDistance: Double? + public let previousSnapshotRetainedPercent: Double? + + public init( + framesPerSecond: Double, + aircraftCount: Int, + usableHorizontalMotionPercent: Double?, + positionDerivedMotionPercent: Double?, + meanSampleAgeSeconds: Double?, + meanProjectedSpeedPerSecond: Double?, + meanCorrectionDistance: Double?, + previousSnapshotRetainedPercent: Double?, + ) { + self.framesPerSecond = framesPerSecond + self.aircraftCount = aircraftCount + self.usableHorizontalMotionPercent = usableHorizontalMotionPercent + self.positionDerivedMotionPercent = positionDerivedMotionPercent + self.meanSampleAgeSeconds = meanSampleAgeSeconds + self.meanProjectedSpeedPerSecond = meanProjectedSpeedPerSecond + self.meanCorrectionDistance = meanCorrectionDistance + self.previousSnapshotRetainedPercent = previousSnapshotRetainedPercent + } + + public var level: LogLevel { + .info + } + + public var message: String { + "Projection motion aggregate" + } + + public var remoteMessage: String { + message + } +} + +public enum ThrowLog { + public static let root = Log(system: .shared) + public static let aircraft = root(AircraftPollingLogEvent.self) + public static let geography = root(GeographyLogEvent.self) + public static let flightRoutes = root(FlightRouteLogEvent.self) + public static let projectionMotion = root(ProjectionMotionLogEvent.self) + + static func recordColdLaunchFailure( + at boundary: ThrowSessionLogEvent.ColdLaunchBoundary, + error: any Error, + using logger: Log, + ) { + logger(attachments: [.error(error, name: "launch-error")]) { + .coldLaunchFailed(boundary: boundary) + } + } + + static func recordSoftwareCreditsLoadFailure( + _ failure: ThrowSoftwareCreditsLoadFailure, + using logger: Log, + ) { + logger(attachments: [failure.attachment]) { + .softwareCreditsLoadFailed + } + } + + static func recordPostLaunchFailure( + at operation: ThrowSessionLogEvent.PostLaunchOperation, + error: any Error, + using logger: Log, + ) { + logger(attachments: [.error(error, name: "operation-error")]) { + .postLaunchOperationFailed(operation: operation) + } + } +} + +/// Records typed session failures without exposing the underlying Periscope logger. +public protocol ThrowSessionFailureLogging: Sendable { + func recordColdLaunchFailure( + at boundary: ThrowSessionLogEvent.ColdLaunchBoundary, + error: any Error, + ) + + func recordPostLaunchFailure( + at operation: ThrowSessionLogEvent.PostLaunchOperation, + error: any Error, + ) +} + +/// Drops session failures in fixtures that do not install a diagnostics pipeline. +public struct DiscardingThrowSessionFailureLogger: ThrowSessionFailureLogging { + public init() {} + + public func recordColdLaunchFailure( + at _: ThrowSessionLogEvent.ColdLaunchBoundary, + error _: any Error, + ) {} + + public func recordPostLaunchFailure( + at _: ThrowSessionLogEvent.PostLaunchOperation, + error _: any Error, + ) {} +} + +public protocol AircraftPollingLogging: Sendable { + func record(_ event: AircraftPollingLogEvent) +} + +public struct PeriscopeAircraftPollingLogger: AircraftPollingLogging { + private let log: Log + + public init(log: Log) { + self.log = log + } + + public func record(_ event: AircraftPollingLogEvent) { + log { event } + } +} + +public struct DiscardingAircraftPollingLogger: AircraftPollingLogging { + public init() {} + + public func record(_: AircraftPollingLogEvent) {} +} + +public protocol GeographyLogging: Sendable { + func record(_ event: GeographyLogEvent) +} + +public struct PeriscopeGeographyLogger: GeographyLogging { + private let log: Log + + public init(log: Log) { + self.log = log + } + + public func record(_ event: GeographyLogEvent) { + log { event } + } +} + +public struct DiscardingGeographyLogger: GeographyLogging { + public init() {} + + public func record(_: GeographyLogEvent) {} +} + +public protocol FlightRouteLogging: Sendable { + func record(_ event: FlightRouteLogEvent) +} + +public struct PeriscopeFlightRouteLogger: FlightRouteLogging { + private let log: Log + + public init(log: Log) { + self.log = log + } + + public func record(_ event: FlightRouteLogEvent) { + log { event } + } +} + +public struct DiscardingFlightRouteLogger: FlightRouteLogging { + public init() {} + + public func record(_: FlightRouteLogEvent) {} +} + +public protocol ProjectionMotionLogging: Sendable { + func record(_ event: ProjectionMotionLogEvent) +} + +public struct PeriscopeProjectionMotionLogger: ProjectionMotionLogging { + private let log: Log + + public init(log: Log) { + self.log = log + } + + public func record(_ event: ProjectionMotionLogEvent) { + log { event } + } +} + +public struct DiscardingProjectionMotionLogger: ProjectionMotionLogging { + public init() {} + + public func record(_: ProjectionMotionLogEvent) {} +} diff --git a/Throw/ThrowCore/Sources/Logging/ThrowSoftwareCreditsLoadFailure.swift b/Throw/ThrowCore/Sources/Logging/ThrowSoftwareCreditsLoadFailure.swift new file mode 100644 index 000000000..bc8b8c795 --- /dev/null +++ b/Throw/ThrowCore/Sources/Logging/ThrowSoftwareCreditsLoadFailure.swift @@ -0,0 +1,10 @@ +import PeriscopeCore + +/// A sendable error attachment retained until Throw's durable log store is ready. +public struct ThrowSoftwareCreditsLoadFailure: Equatable, Sendable { + let attachment: LogAttachment + + public init(error: any Error) { + attachment = .error(error, name: "attribution-error") + } +} diff --git a/Throw/ThrowCore/Sources/MapCenterPreferences.swift b/Throw/ThrowCore/Sources/MapCenterPreferences.swift new file mode 100644 index 000000000..a82bf2fdd --- /dev/null +++ b/Throw/ThrowCore/Sources/MapCenterPreferences.swift @@ -0,0 +1,119 @@ +import Foundation + +/// A user-editable map-center offset measured from the observer. +public struct MapCenterOffset: Hashable, Sendable { + public static let allowedNauticalMiles = -50.0 ... 50.0 + + public let eastNauticalMiles: Double + public let northNauticalMiles: Double + + public init(eastNauticalMiles: Double, northNauticalMiles: Double) throws { + guard eastNauticalMiles.isFinite, northNauticalMiles.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "mapCenterOffset") + } + guard Self.isValidComponent(eastNauticalMiles), + Self.isValidComponent(northNauticalMiles) + else { + throw ThrowValidationError.outOfRange( + field: "mapCenterOffset", + closedRange: Self.allowedNauticalMiles, + ) + } + self.eastNauticalMiles = eastNauticalMiles + self.northNauticalMiles = northNauticalMiles + } + + private static func isValidComponent(_ value: Double) -> Bool { + allowedNauticalMiles.contains(value) && value.rounded() == value + && Int(value).isMultiple(of: 5) + } +} + +/// A coarse geographic bucket used to select a fixed Map center without +/// persisting behavior against an exact observer coordinate. +public struct MapRegionID: Hashable, Sendable, CustomStringConvertible { + private static let latitudeBandRange = -90 ... 89 + private static let longitudeBandRange = -180 ... 179 + + public let latitudeBand: Int + public let longitudeBand: Int + + public init(containing coordinate: GeoCoordinate) { + latitudeBand = min( + Self.latitudeBandRange.upperBound, + Int(floor(coordinate.latitude)), + ) + longitudeBand = coordinate.longitude == 180 + ? Self.longitudeBandRange.lowerBound + : Int(floor(coordinate.longitude)) + } + + public init(latitudeBand: Int, longitudeBand: Int) throws { + guard Self.latitudeBandRange.contains(latitudeBand), + Self.longitudeBandRange.contains(longitudeBand) + else { + throw ThrowValidationError.invalidPreferencePayload + } + self.latitudeBand = latitudeBand + self.longitudeBand = longitudeBand + } + + public var description: String { + "" + } +} + +public struct MapCenterProfile: Hashable, Sendable, CustomStringConvertible { + public let regionID: MapRegionID + public let center: GeoCoordinate + + public init(regionID: MapRegionID, center: GeoCoordinate) { + self.regionID = regionID + self.center = center + } + + public var description: String { + ">" + } +} + +/// Fixed Map centers keyed by coarse observer region. True Sky never reads +/// these values. +public struct MapCenterPreferences: Hashable, Sendable, CustomStringConvertible { + public static let defaultValue = try! MapCenterPreferences(profiles: []) + + public let profiles: [MapCenterProfile] + + public init(profiles: [MapCenterProfile]) throws { + guard Set(profiles.map(\.regionID)).count == profiles.count else { + throw ThrowValidationError.invalidPreferencePayload + } + self.profiles = profiles.sorted { + if $0.regionID.latitudeBand == $1.regionID.latitudeBand { + $0.regionID.longitudeBand < $1.regionID.longitudeBand + } else { + $0.regionID.latitudeBand < $1.regionID.latitudeBand + } + } + } + + public func center(for observer: GeoCoordinate) -> GeoCoordinate { + let regionID = MapRegionID(containing: observer) + return profiles.first { $0.regionID == regionID }?.center ?? observer + } + + public func setting(center: GeoCoordinate, for observer: GeoCoordinate) -> Self { + let regionID = MapRegionID(containing: observer) + let profile = MapCenterProfile(regionID: regionID, center: center) + return try! Self(profiles: profiles.filter { $0.regionID != regionID } + [profile]) + } + + public func resetting(for observer: GeoCoordinate) -> Self { + let regionID = MapRegionID(containing: observer) + return try! Self(profiles: profiles.filter { $0.regionID != regionID }) + } + + public var description: String { + ">" + } +} diff --git a/Throw/ThrowCore/Sources/ProjectionEngine.swift b/Throw/ThrowCore/Sources/ProjectionEngine.swift new file mode 100644 index 000000000..5fe402cae --- /dev/null +++ b/Throw/ThrowCore/Sources/ProjectionEngine.swift @@ -0,0 +1,884 @@ +import Foundation + +public struct ProjectionGeometry: Hashable, Sendable { + public let width: Double + public let height: Double + + public init(width: Double, height: Double) throws { + guard width.isFinite, height.isFinite else { + throw ThrowValidationError.nonFiniteValue(field: "projectionGeometry") + } + guard width > 0, height > 0 else { + throw ThrowValidationError.outOfRange( + field: "projectionGeometry", + closedRange: Double.leastNonzeroMagnitude ... Double.greatestFiniteMagnitude, + ) + } + self.width = width + self.height = height + } +} + +public struct GreatCirclePosition: Hashable, Sendable { + public let distance: NauticalMiles + public let initialBearing: Bearing + + public init(distance: NauticalMiles, initialBearing: Bearing) { + self.distance = distance + self.initialBearing = initialBearing + } +} + +public struct HorizontalPosition: Hashable, Sendable { + public let azimuth: Bearing + public let elevation: ElevationAngle + public let slantRange: NauticalMiles + + public init(azimuth: Bearing, elevation: ElevationAngle, slantRange: NauticalMiles) { + self.azimuth = azimuth + self.elevation = elevation + self.slantRange = slantRange + } +} + +/// Pure geographic and WGS84 projection math. The returned points are already +/// calibrated and aspect-correct for the destination geometry. +public struct ProjectionEngine: Sendable { + private static let earthMeanRadiusMeters = 6_371_008.8 + private static let wgs84SemiMajorAxisMeters = 6_378_137.0 + private static let wgs84Flattening = 1.0 / 298.257_223_563 + + public init() {} + + /// Projects one closed experience input into its matching closed output. + public func frame( + input: PreparedProjectionExperienceInput, + observer: ObserverPosition, + mapCenter: GeoCoordinate, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> ProjectedExperienceFrame { + switch input { + case let .airAndSpace(input): + try validateLineContext( + input.geography, + mapCenter: mapCenter, + viewport: input.viewport.viewport, + calibration: calibration, + geometry: geometry, + ) + return try .airAndSpace(airAndSpaceFrame( + input: input, + observer: observer, + mapCenter: mapCenter, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + )) + case let .transit(input): + let viewport = ProjectionViewport.map(input.viewport) + try validateLineContext( + input.geography, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + ) + try validateLineContext( + input.network?.frame, + expectedSourceRevision: input.network?.sourceRevision, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + ) + return try .transit(transitFrame( + input: input, + observer: observer, + mapCenter: mapCenter, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + )) + } + } + + /// Projects one typed semantic line layer and records the source and geometry used. + public func lineFrame( + source: ProjectionLayerFrame, + mapCenter: GeoCoordinate, + viewport: ProjectionViewport, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + ) throws -> ProjectedLayerFrame { + let segments = try lineSegments( + lines: source.lines, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + ) + try Task.checkCancellation() + let provenance = ProjectedLineProvenance( + layerID: Layer.id, + sourceRevision: source.observedAt, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + ) + return ProjectedLayerFrame( + segments: segments, + provenance: provenance, + ) + } + + private func validateLineContext( + _ frame: ProjectedLayerFrame?, + expectedSourceRevision: Date? = nil, + mapCenter: GeoCoordinate, + viewport: ProjectionViewport, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + ) throws { + guard let frame else { return } + guard let provenance = frame.payload.provenance else { + throw ProjectionPreparationError.missingLineProvenance(layerID: Layer.id) + } + guard provenance.layerID == Layer.id else { + throw ProjectionPreparationError.layerIdentityMismatch(layerID: Layer.id) + } + if let expectedSourceRevision, + provenance.sourceRevision != expectedSourceRevision + { + throw ProjectionPreparationError.sourceRevisionMismatch(layerID: Layer.id) + } + guard provenance.mapCenter == mapCenter, + provenance.viewport == viewport, + provenance.calibration == calibration, + provenance.geometry == geometry + else { + throw ProjectionPreparationError.projectionContextMismatch(layerID: Layer.id) + } + } + + private func airAndSpaceFrame( + input: PreparedAirAndSpaceProjectionInput, + observer: ObserverPosition, + mapCenter: GeoCoordinate, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> AirAndSpaceProjectedFrame { + let viewport = input.viewport.viewport + let flights = try projectedMarkLayer( + input.flights, + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ) + let satellites = try projectedMarkLayer( + input.satellites, + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ) + + switch input.viewport { + case .map: + return .map(AirAndSpaceMapProjectedFrame( + generatedAt: generatedAt, + geography: input.geography, + flights: flights, + satellites: satellites, + )) + case .trueSky: + let stars = try projectedMarkLayer( + input.stars, + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ) + return .trueSky(AirAndSpaceTrueSkyProjectedFrame( + generatedAt: generatedAt, + flights: flights, + stars: stars, + satellites: satellites, + )) + } + } + + private func transitFrame( + input: PreparedTransitProjectionInput, + observer: ObserverPosition, + mapCenter: GeoCoordinate, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> TransitProjectedFrame { + let viewport = ProjectionViewport.map(input.viewport) + return try TransitProjectedFrame( + generatedAt: generatedAt, + geography: input.geography, + network: input.network?.frame, + vehicles: projectedMarkLayer( + input.vehicles, + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ), + ) + } + + private func projectedMarkLayer( + _ layerFrame: ProjectionLayerFrame?, + observer: ObserverPosition, + mapCenter: GeoCoordinate, + viewport: ProjectionViewport, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> ProjectedLayerFrame? { + guard let layerFrame else { return nil } + return try ProjectedLayerFrame( + marks: projectedMarks( + layerFrame.marks, + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ), + ) + } + + private func projectedMarks( + _ marks: [ProjectionMark], + observer: ObserverPosition, + mapCenter: GeoCoordinate, + viewport: ProjectionViewport, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> [ProjectedMark] { + try Task.checkCancellation() + let projectionObserver = switch viewport { + case .map: + ObserverPosition(coordinate: mapCenter, altitude: observer.altitude) + case .trueSky: + observer + } + var projected: [ProjectedMark] = [] + for (markIndex, mark) in marks.enumerated() { + if markIndex.isMultiple(of: 64) { + try Task.checkCancellation() + } + if case .airport = mark.glyph, viewport.mode != .map { continue } + guard let prediction = try FlightPredictor.prediction(for: mark, at: generatedAt) + else { + continue + } + guard let radial = try radialPosition( + for: prediction.mark.anchor, + observer: projectionObserver, + viewport: viewport, + screenTopBearing: calibration.screenTopBearing, + ) else { + continue + } + let point = calibratedPoint( + radial: radial, + calibration: calibration, + geometry: geometry, + ) + let orientation = switch mark.glyph { + case let .airport(descriptor): try projectedOrientation( + bearing: descriptor.runwayBearing, + anchor: prediction.mark.anchor, + observer: projectionObserver, + viewport: viewport, + calibration: calibration, + geometry: geometry, + currentPoint: point, + ) + case .aircraft, .star, .satellite, .transitVehicle: try apparentOrientation( + for: mark, + at: generatedAt, + observer: projectionObserver, + viewport: viewport, + calibration: calibration, + geometry: geometry, + currentPoint: point, + ) + } + let altitudeIsApproximate: Bool = switch prediction.mark.anchor { + case let .geodetic(anchor): + anchor.altitude.quality == .barometricApproximation + case .horizontal: + false + } + projected.append( + ProjectedMark( + element: prediction.mark.element, + point: point, + range: radial.range, + label: prediction.mark.label, + secondaryProminence: prediction.mark.prominence == .secondary ? 1 : 0, + orientationDegrees: orientation, + opacity: prediction.opacity, + labelOpacity: 1, + altitudeIsApproximate: altitudeIsApproximate, + ), + ) + } + return projected + } + + #if DEBUG + /// Raw semantic support for focused ThrowUI animation tests. + @_spi(Testing) public func projectedMarksForTesting( + layerFrames: [LayerFrame], + observer: ObserverPosition, + mapCenter: GeoCoordinate, + viewport: ProjectionViewport, + calibration: ProjectionCalibration, + geometry: ProjectionGeometry, + generatedAt: Date, + ) throws -> [TestingProjectedMark] { + try projectedMarks( + layerFrames.flatMap(\.marks), + observer: observer, + mapCenter: mapCenter, + viewport: viewport, + calibration: calibration, + geometry: geometry, + generatedAt: generatedAt, + ) + } + #endif + + /// Projects and clips static geographic or network lines for a Map viewport. Callers + /// can cache the result until the Map center, viewport, or calibration changes. + public func lineSegments( + lines: [ProjectionPolyline