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:
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 {