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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Where/WhereCore/Sources/Location/LocationIngestor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ public actor LocationIngestor {
guard !isMonitoring else { return }
isMonitoring = true
await locationSource.start()
guard isMonitoring else { return }
guard LocationIngestorStart
.afterLocationSourceStart(isMonitoring: isMonitoring) == .completeSetup
else {
return
}
Self.logger { .monitoringStarted }
// Seed the in-memory queue from the durable backlog once, so samples that
// failed to persist in a prior launch get retried now.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Pure branch after ``LocationIngestor``'s `LocationSource.start()` await.
///
/// Maps to the re-entrancy boundary in
/// [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md):
/// if `stop()` clears `isMonitoring` while start is parked, setup must not continue.
public enum LocationIngestorStartDecision: Sendable, Hashable {
/// `stop()` ran during the await; leave the ingestor off.
case abortMonitoringStoppedDuringAwait
/// Monitoring is still wanted; run backlog drain and attach the sample stream.
case completeSetup
}

public enum LocationIngestorStart {
public static func afterLocationSourceStart(isMonitoring: Bool)
-> LocationIngestorStartDecision
{
isMonitoring ? .completeSetup : .abortMonitoringStoppedDuringAwait
}
}
56 changes: 56 additions & 0 deletions Where/WhereCore/Sources/Protocols/TrackingReconcile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

/// Pure decision logic for the tracking-toggle protocol in
/// [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md).
///
/// Maps to the TLA+ variables `desired`, `ingestorActive`, `published`, `worker`,
/// and `target`. Async orchestration stays in ``WhereSession``; this type is the
/// declarative digest the spec and production code share.
///
/// - Property ``shouldPublish(target:currentEffective:reconcilePending:)`` ↔
/// `CorrectAtQuiescence` (publish only when intent settled).
/// - Property ``shouldPreemptInFlightStop(targetEffective:)`` ↔ coalesced stop
/// while a start is parked on an await.
public enum TrackingReconcile: Sendable {
/// Worker lane phase in the coalesced design (`Coalesced.cfg`).
public enum WorkerPhase: String, Sendable, Hashable, CaseIterable {
case idle
case ready
case starting
case stopping
}

/// Whether background tracking should be active given intent and authorization.
public static func effectiveTracking(
desired: Bool,
authorizationAllowsBackground: Bool,
) -> Bool {
desired && authorizationAllowsBackground
}

/// When a reconcile is already in flight and the latest intent is *off*,
/// stop the ingestor immediately instead of awaiting the parked start.
public static func shouldPreemptInFlightStop(targetEffective: Bool) -> Bool {
!targetEffective
}

/// After a side effect completes, whether ``WhereSession/isTracking`` may update.
public static func shouldPublish(
target: Bool,
currentEffective: Bool,
reconcilePending: Bool,
) -> Bool {
currentEffective == target && !reconcilePending
}

/// UI published state once every command has settled (`CorrectAtQuiescence`).
public static func publishedAtQuiescence(
desired: Bool,
authorizationAllowsBackground: Bool,
) -> Bool {
effectiveTracking(
desired: desired,
authorizationAllowsBackground: authorizationAllowsBackground,
)
}
}
18 changes: 18 additions & 0 deletions Where/WhereCore/Tests/LocationIngestorStartDecisionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Testing
import WhereCore

struct LocationIngestorStartDecisionTests {
@Test func completeSetupWhenMonitoringStillWanted() {
#expect(
LocationIngestorStart.afterLocationSourceStart(isMonitoring: true)
== .completeSetup,
)
}

@Test func abortWhenStopRanDuringLocationSourceStart() {
#expect(
LocationIngestorStart.afterLocationSourceStart(isMonitoring: false)
== .abortMonitoringStoppedDuringAwait,
)
}
}
75 changes: 75 additions & 0 deletions Where/WhereCore/Tests/TrackingReconcileTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Testing
import WhereCore

/// Mirrors [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md)
/// properties on the pure decision layer — no `Task`, ingestor, or session wiring.
struct TrackingReconcileTests {
@Test func effectiveTrackingRequiresAlwaysAuthorization() {
#expect(TrackingReconcile.effectiveTracking(
desired: true,
authorizationAllowsBackground: true,
))
#expect(!TrackingReconcile.effectiveTracking(
desired: true,
authorizationAllowsBackground: false,
))
#expect(!TrackingReconcile.effectiveTracking(
desired: false,
authorizationAllowsBackground: true,
))
}

@Test func preemptInFlightStopWhenTargetIsOff() {
#expect(TrackingReconcile.shouldPreemptInFlightStop(targetEffective: false))
#expect(!TrackingReconcile.shouldPreemptInFlightStop(targetEffective: true))
}

@Test func publishOnlyWhenTargetMatchesAndNothingPending() {
#expect(TrackingReconcile.shouldPublish(
target: false,
currentEffective: false,
reconcilePending: false,
))
#expect(!TrackingReconcile.shouldPublish(
target: false,
currentEffective: true,
reconcilePending: false,
))
#expect(!TrackingReconcile.shouldPublish(
target: false,
currentEffective: false,
reconcilePending: true,
))
}

@Test func coalescedDisableDuringInFlightStartDoesNotPublishStaleTrue() {
// Modeled sequence: enable, disable while start awaits — target is false,
// current effective false, but an older iteration captured target true.
let targetCapturedForIteration = true
let currentEffective = TrackingReconcile.effectiveTracking(
desired: false,
authorizationAllowsBackground: true,
)
#expect(!TrackingReconcile.shouldPublish(
target: targetCapturedForIteration,
currentEffective: currentEffective,
reconcilePending: false,
))
#expect(TrackingReconcile.publishedAtQuiescence(
desired: false,
authorizationAllowsBackground: true,
) == false)
}

@Test func quiescenceAfterMatchingEnablePublishesTrue() {
#expect(TrackingReconcile.shouldPublish(
target: true,
currentEffective: true,
reconcilePending: false,
))
#expect(TrackingReconcile.publishedAtQuiescence(
desired: true,
authorizationAllowsBackground: true,
))
}
}
23 changes: 18 additions & 5 deletions Where/WhereUI/Sources/Model/WhereSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,11 @@ public final class WhereSession {
private func runTrackingReconcile() async {
if let running = trackingWorkerTask {
trackingReconcilePending = true
let targetEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking
if !targetEffective {
let targetEffective = TrackingReconcile.effectiveTracking(
desired: wantsTracking,
authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking,
)
if TrackingReconcile.shouldPreemptInFlightStop(targetEffective: targetEffective) {
// Stop must not await an in-flight `ingestor.start()` — it can be
// parked on `LocationSource.start()` indefinitely. Pause monitoring
// now; the worker reruns after that await and publishes intent.
Expand All @@ -338,7 +341,10 @@ public final class WhereSession {
while true {
trackingReconcilePending = false

let targetEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking
let targetEffective = TrackingReconcile.effectiveTracking(
desired: wantsTracking,
authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking,
)
let wasTracking = isTracking

if targetEffective {
Expand All @@ -347,8 +353,15 @@ public final class WhereSession {
await services.ingestor.stop()
}

let currentEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking
guard currentEffective == targetEffective, !trackingReconcilePending else {
let currentEffective = TrackingReconcile.effectiveTracking(
desired: wantsTracking,
authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking,
)
guard TrackingReconcile.shouldPublish(
target: targetEffective,
currentEffective: currentEffective,
reconcilePending: trackingReconcilePending,
) else {
continue
}

Expand Down