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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,18 @@ public final class ProfileBackupCoordinator: @unchecked Sendable {
/// longer matches, and we exit WITHOUT touching shared state (a new
/// publish session may own it) and WITHOUT calling `markPublished`
/// (identity has changed).
/// - Error semantic (ADR-0017): rethrows the *terminal* iteration's error
/// if and only if no successful publish landed during the call window.
/// A coalesced republish that succeeds rescues an earlier failed
/// iteration — the call returns without throw. Coalesced calls (the
/// ones that hit the `shouldQueue` short-circuit) never throw; they
/// have no awaitable publish of their own to fail. A session
/// invalidated by `clearAll()` returns without throw — the caller's
/// identity has been replaced and the error is meaningless.
public func publishAndMark(
settings: UserSettingsRepository,
savedLocations: SavedLocationsRepository
) async {
) async throws {
var entryGeneration: UInt64 = 0
let shouldQueue: Bool = lock.withLock {
if isPublishing {
Expand All @@ -129,6 +137,7 @@ public final class ProfileBackupCoordinator: @unchecked Sendable {
}
guard !shouldQueue else { return }

var lastIterationError: (any Error)?
while true {
let content = buildContent(settings: settings, savedLocations: savedLocations)
do {
Expand All @@ -138,23 +147,35 @@ public final class ProfileBackupCoordinator: @unchecked Sendable {
syncStoreRef?.markPublished(.profileBackup, at: event.createdAt)
RidestrLogger.info("[ProfileBackupCoordinator] Published profile backup")
}
lastIterationError = nil
} catch {
RidestrLogger.info("[ProfileBackupCoordinator] Failed to publish profile backup: \(error.localizedDescription)")
lastIterationError = error
}

// Atomic exit: either continue with a fresh iteration (consuming
// the republish request) OR release isPublishing. If generation
// changed, bail without touching shared state.
// changed, bail without touching shared state. Captures
// `generationStillValid` inside the same lock region so the
// post-loop throw decision doesn't need a second lock acquisition
// (per .claude/CLAUDE.md "atomic single-lock exits").
var generationStillValid = false
let shouldContinue: Bool = lock.withLock {
guard generation == entryGeneration else { return false }
generationStillValid = true
if republishRequested {
republishRequested = false
return true
}
isPublishing = false
return false
}
if !shouldContinue { return }
if !shouldContinue {
if let error = lastIterationError, generationStillValid {
throw error
}
return
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,23 +181,22 @@ public final class RoadflareDomainService: @unchecked Sendable {
// MARK: - Publish-and-Mark Convenience Helpers
//
// Each helper: reads from its repo → builds content → publishes → marks
// syncStore published on success. Throws swallowed by logging.
// syncStore published on success. `publishProfileAndMark` rethrows so the
// onboarding eager-error path (ADR-0017) can surface the banner without
// waiting for the watchdog timeout; the followed-drivers and ride-history
// helpers stay best-effort because no UI surface observes them directly.

public func publishProfileAndMark(
from settings: UserSettingsRepository,
syncStore: RoadflareSyncStateStore
) async {
) async throws {
let profile = UserProfileContent(
name: settings.profileName,
displayName: settings.profileName
)
do {
let event = try await publishProfile(profile)
syncStore.markPublished(.profile, at: event.createdAt)
RidestrLogger.info("[RoadflareDomainService] Published profile")
} catch {
RidestrLogger.info("[RoadflareDomainService] Failed to publish profile: \(error.localizedDescription)")
}
let event = try await publishProfile(profile)
syncStore.markPublished(.profile, at: event.createdAt)
RidestrLogger.info("[RoadflareDomainService] Published profile")
}

public func publishFollowedDriversListAndMark(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ struct ProfileBackupCoordinatorTests {
let kit = try await makeKit()
kit.settings.setRoadflarePaymentMethods(["zelle"])

await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
try await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)

#expect(kit.syncStore.metadata(for: .profileBackup).lastSuccessfulPublishAt > 0)
#expect(kit.relay.publishedEvents.count == 1)
Expand All @@ -99,7 +99,7 @@ struct ProfileBackupCoordinatorTests {
// Fire two concurrent calls — second should coalesce into a republish.
async let first: Void = kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
async let second: Void = kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
_ = await (first, second)
_ = try await (first, second)

// First call publishes once, second sets republishRequested → first loops and publishes again.
// Since the relay is fake and completes immediately, the second call's guard may or may not
Expand All @@ -112,8 +112,13 @@ struct ProfileBackupCoordinatorTests {
kit.relay.shouldFailPublish = true
kit.settings.setRoadflarePaymentMethods(["zelle"])

await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)

// ADR-0017: terminal-iteration failure rethrows so the onboarding
// eager-error path can fire the banner without waiting for the watchdog.
await #expect(throws: (any Error).self) {
try await kit.coordinator.publishAndMark(
settings: kit.settings, savedLocations: kit.savedLocations
)
}
#expect(kit.syncStore.metadata(for: .profileBackup).lastSuccessfulPublishAt == 0)
}

Expand All @@ -136,7 +141,10 @@ struct ProfileBackupCoordinatorTests {
kit.relay.publishDelay = .milliseconds(100)

let publishTask = Task {
await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
// Session crossed by clearAll: ADR-0017 contract says we return
// without throwing — the caller's identity has been replaced and
// the error is meaningless. `try await` accepts both.
try await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
}

// Let publish start its await
Expand All @@ -147,9 +155,9 @@ struct ProfileBackupCoordinatorTests {

// New publish session starts clean
kit.settings.setRoadflarePaymentMethods(["venmo"])
await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
try await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)

await publishTask.value
try await publishTask.value

// The new session's publish reached the relay.
#expect(kit.syncStore.metadata(for: .profileBackup).lastSuccessfulPublishAt > 0)
Expand All @@ -167,12 +175,12 @@ struct ProfileBackupCoordinatorTests {
let kit = try await makeKit()
kit.settings.setRoadflarePaymentMethods(["zelle"])

await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
try await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
let firstTimestamp = kit.syncStore.metadata(for: .profileBackup).lastSuccessfulPublishAt

try await Task.sleep(for: .milliseconds(10))
kit.settings.setRoadflarePaymentMethods(["venmo"])
await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)
try await kit.coordinator.publishAndMark(settings: kit.settings, savedLocations: kit.savedLocations)

let secondTimestamp = kit.syncStore.metadata(for: .profileBackup).lastSuccessfulPublishAt
#expect(secondTimestamp >= firstTimestamp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,12 +340,33 @@ struct RoadflareDomainServiceTests {
let settings = UserSettingsRepository(persistence: InMemoryUserSettingsPersistence())
_ = settings.setProfileName("Alice")

await service.publishProfileAndMark(from: settings, syncStore: syncStore)
try await service.publishProfileAndMark(from: settings, syncStore: syncStore)

#expect(syncStore.metadata(for: .profile).lastSuccessfulPublishAt > 0)
#expect(relay.publishedEvents.count == 1)
}

@Test func publishProfileAndMarkRethrowsRelayFailure() async throws {
// ADR-0017: SDK helper rethrows so the onboarding eager-error path
// can fire the failure banner without waiting for the watchdog.
let keypair = try NostrKeypair.generate()
let relay = FakeRelayManager()
try await relay.connect(to: [URL(string: "wss://fake")!])
relay.shouldFailPublish = true
let service = RoadflareDomainService(relayManager: relay, keypair: keypair)
let syncStore = RoadflareSyncStateStore(
defaults: UserDefaults(suiteName: "test_\(UUID().uuidString)")!,
namespace: UUID().uuidString
)
let settings = UserSettingsRepository(persistence: InMemoryUserSettingsPersistence())
_ = settings.setProfileName("Alice")

await #expect(throws: (any Error).self) {
try await service.publishProfileAndMark(from: settings, syncStore: syncStore)
}
#expect(syncStore.metadata(for: .profile).lastSuccessfulPublishAt == 0)
}

@Test func publishFollowedDriversListAndMarkMarksSyncStore() async throws {
let keypair = try NostrKeypair.generate()
let relay = FakeRelayManager()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct PaymentMethodsScreen: View {
PaymentMethodPicker(settings: appState.settings)
.onChange(of: appState.settings.roadflarePaymentMethods) { oldValue, newValue in
guard oldValue != newValue, appState.authState == .ready else { return }
Task { await appState.publishProfileBackup() }
Task { try? await appState.publishProfileBackup() }
}
}
.padding(.horizontal, 16)
Expand Down
8 changes: 4 additions & 4 deletions RoadFlare/RoadFlare/Views/Settings/SavedLocationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ struct AddFavoriteSheet: View {
timestampMs: Int(Date.now.timeIntervalSince1970 * 1000)
)
appState.saveLocation(loc)
await appState.publishProfileBackup()
try? await appState.publishProfileBackup()
}
} catch {
// Non-fatal
Expand Down Expand Up @@ -302,7 +302,7 @@ struct EditLocationSheet: View {
if !location.isFavorite {
Button {
appState.pinLocation(id: location.id, nickname: nickname.isEmpty ? location.displayName : nickname)
Task { await appState.publishProfileBackup() }
Task { try? await appState.publishProfileBackup() }
dismiss()
} label: {
Label("Save as Favorite", systemImage: "star.fill")
Expand All @@ -314,7 +314,7 @@ struct EditLocationSheet: View {
if !nickname.isEmpty {
appState.pinLocation(id: location.id, nickname: nickname)
}
Task { await appState.publishProfileBackup() }
Task { try? await appState.publishProfileBackup() }
dismiss()
} label: {
Text("Save")
Expand All @@ -326,7 +326,7 @@ struct EditLocationSheet: View {

Button(role: .destructive) {
appState.removeLocation(id: location.id)
Task { await appState.publishProfileBackup() }
Task { try? await appState.publishProfileBackup() }
dismiss()
} label: {
Text(location.isFavorite ? "Remove Favorite" : "Remove")
Expand Down
2 changes: 1 addition & 1 deletion RoadFlare/RoadFlare/Views/Settings/SettingsTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ struct EditProfileSheet: View {
saveState = .saving
appState.settings.setProfileName(trimmed)
Task {
await appState.saveAndPublishSettings()
try? await appState.saveAndPublishSettings()
saveState = .saved
try? await Task.sleep(for: .milliseconds(600))
dismiss()
Expand Down
Loading