field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit 9335dce30ed1aa74a0f382503dc48050420260d2
parent 2156def908cb25c2e7968dfab91b5edb34292730
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 13:02:23 -0700

background: fail closed on startup lifecycle gaps

- record handler registration failures before propagating startup errors
- prevent background task scheduling before handlers register
- drain pending URLSession completions when startup fails
- disable background event forwarding until startup attaches a fresh executor

Diffstat:
MRadroots/App/AppState.swift | 2++
MRadroots/Runtime/FieldBackgroundExecution.swift | 45++++++++++++++++++++++++++++++++-------------
MRadroots/Runtime/FieldBackgroundURLSessionEvents.swift | 17+++++++++++++++++
3 files changed, 51 insertions(+), 13 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -168,6 +168,8 @@ public final class AppState: ObservableObject { statusTask = nil telemetryProbeTask?.cancel() telemetryProbeTask = nil + await FieldBackgroundURLSessionEvents.shared.completePendingAfterStartupFailure() + backgroundExecution = nil let message = error.localizedDescription bootstrapPhase = .failed(message) telemetry.appStartupFailed(error) diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -100,38 +100,52 @@ actor FieldBackgroundExecution { roots: roots, telemetry: telemetry, registerHandlers: { handlers in - _ = try await scheduler.register( + let refreshRegistered = try await scheduler.register( RadrootsAppleBackgroundTaskRegistration( identifier: identifiers.refresh, kind: .appRefresh, handler: handlers.refresh ) ) - _ = try await scheduler.register( + let processingRegistered = try await scheduler.register( RadrootsAppleBackgroundTaskRegistration( identifier: identifiers.processing, kind: .processing, handler: handlers.processing ) ) + guard refreshRegistered && processingRegistered else { + throw RadrootsBackgroundTaskError.schedulerFailure( + "background task handler registration was rejected" + ) + } } ) } func start() async throws { if !hasRegisteredHandlers { - try await registerHandlers( - FieldBackgroundExecutionHandlers( - refresh: { [weak self] in - await self?.performMaintenance(reason: "refresh_task") ?? false - }, - processing: { [weak self] in - await self?.performMaintenance(reason: "processing_task") ?? false - } + do { + try await registerHandlers( + FieldBackgroundExecutionHandlers( + refresh: { [weak self] in + await self?.performMaintenance(reason: "refresh_task") ?? false + }, + processing: { [weak self] in + await self?.performMaintenance(reason: "processing_task") ?? false + } + ) ) - ) - hasRegisteredHandlers = true - telemetry.backgroundExecution(operation: "handler_registration", outcome: "success", taskCount: 2) + hasRegisteredHandlers = true + telemetry.backgroundExecution(operation: "handler_registration", outcome: "success", taskCount: 2) + } catch { + hasRegisteredHandlers = false + telemetry.backgroundExecution( + operation: "handler_registration", + outcome: FieldTelemetry.backgroundExecutionOutcome(for: error) + ) + throw error + } } _ = try await schedulePermittedTasks(reason: "startup") } @@ -144,6 +158,11 @@ actor FieldBackgroundExecution { @discardableResult func schedulePermittedTasks(reason: String) async throws -> [RadrootsBackgroundTaskSnapshot] { do { + guard hasRegisteredHandlers else { + throw RadrootsBackgroundTaskError.schedulerFailure( + "background task handlers are not registered" + ) + } let refresh = try RadrootsBackgroundTaskRequest( identifier: identifiers.refresh, kind: .appRefresh, diff --git a/Radroots/Runtime/FieldBackgroundURLSessionEvents.swift b/Radroots/Runtime/FieldBackgroundURLSessionEvents.swift @@ -5,13 +5,16 @@ actor FieldBackgroundURLSessionEvents { private var backgroundExecution: FieldBackgroundExecution? private var pendingEvents: [FieldPendingBackgroundURLSessionEvent] + private var completesImmediately: Bool private init() { self.pendingEvents = [] + self.completesImmediately = false } func attach(_ backgroundExecution: FieldBackgroundExecution) async { self.backgroundExecution = backgroundExecution + completesImmediately = false let events = pendingEvents pendingEvents = [] for event in events { @@ -26,6 +29,10 @@ actor FieldBackgroundURLSessionEvents { identifier: String, completionHandler: @escaping @Sendable () -> Void ) async { + guard !completesImmediately else { + completionHandler() + return + } guard let backgroundExecution else { pendingEvents.append( FieldPendingBackgroundURLSessionEvent( @@ -40,6 +47,16 @@ actor FieldBackgroundURLSessionEvents { completionHandler: completionHandler ) } + + func completePendingAfterStartupFailure() { + backgroundExecution = nil + completesImmediately = true + let events = pendingEvents + pendingEvents = [] + for event in events { + event.completionHandler() + } + } } private struct FieldPendingBackgroundURLSessionEvent: Sendable {