Skip to content
Merged
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
@@ -1,86 +1,12 @@
public import CMUXMobileCore
internal import Foundation

/// Emits one bounded latency observation when a connectivity phase completes.
/// Reports bounded connectivity and task model discovery outcomes.
///
/// Starts stay local. Only terminal outcomes reach Axiom, which keeps the
/// operational stream useful for latency histograms without turning every
/// retry or state transition into an event. The diagnostic ring remains the
/// source for Sentry's incident policy and the on-device logs.
private func taskModelProperties(
for kind: DiagnosticAppEventKind,
event: DiagnosticEvent
) -> [String: AnalyticsValue]? {
let outcome: String
let phase: String?
switch kind {
case .taskModelListLoadSucceeded:
outcome = "success"
phase = nil
case .taskModelListLoadFailed:
outcome = "failure"
phase = nil
case .taskModelListRetryScheduled:
outcome = "failure"
phase = "retry_scheduled"
case .taskModelListRetryStopped:
outcome = "failure"
phase = "retry_stopped"
default:
return nil
}
let modelCount: Int
switch kind {
case .taskModelListRetryScheduled, .taskModelListRetryStopped:
modelCount = 0
default:
modelCount = event.c ?? 0
}
let requestDurationMilliseconds: Int
switch kind {
case .taskModelListRetryScheduled:
// The event's duration slot carries the backoff for this phase. The
// request duration is unavailable here, so keep latency histograms
// honest instead of treating the sleep as network work.
requestDurationMilliseconds = 0
default:
requestDurationMilliseconds = Int(event.ms ?? 0)
}
var properties: [String: AnalyticsValue] = [
"operation": .string("model_list"),
"outcome": .string(outcome),
// Transport failures can precede a catalog result. The ingress
// requires this field even when discovery produced no models.
"model_count": .int(modelCount),
"duration_ms": .int(requestDurationMilliseconds),
]
if let surface = event.surface {
// This is the existing process-local correlation handle. It lets
// Axiom join one refresh's retries without exporting the Mac ID.
properties["correlation_id"] = .int(Int(surface))
}
if let phase {
properties["phase"] = .string(phase)
}
let failure = DiagnosticEventPresentation().failureKind(of: event)
if let failure, failure != .none {
properties["failure"] = .string(DiagnosticEventPresentation().name(failure))
}
switch kind {
case .taskModelListRetryScheduled:
properties["attempt"] = .int(event.c ?? 0)
properties["retry_delay_ms"] = .int(Int(event.ms ?? 0))
case .taskModelListRetryStopped:
if let reason = event.c.flatMap(DiagnosticTaskModelRetryStopReason.init(rawValue:)) {
properties["stop_reason"] = .string(DiagnosticEventPresentation().name(reason))
}
default:
break
}
return properties
}

/// Reports bounded connectivity and task model discovery outcomes.
public final class MobileNetworkOutcomeReporter: Sendable {
/// The Axiom event name for connectivity latency diagnostics.
public static let eventName = "ios_connectivity_latency"
Expand Down Expand Up @@ -174,7 +100,7 @@ public final class MobileNetworkOutcomeReporter: Sendable {
public func ingest(_ event: DiagnosticEvent) {
if event.code == .appFeatureAction,
let kind = event.a.flatMap(DiagnosticAppEventKind.init(rawValue:)),
let properties = taskModelProperties(for: kind, event: event) {
let properties = Self.taskModelProperties(for: kind, event: event) {
emitter.capture(Self.taskModelEventName, properties)
return
}
Expand All @@ -190,6 +116,81 @@ public final class MobileNetworkOutcomeReporter: Sendable {
await emitter.flush()
}

/// Builds the task model discovery payload for one discovery event kind,
/// or nil when the kind is not part of that group.
private static func taskModelProperties(
for kind: DiagnosticAppEventKind,
event: DiagnosticEvent
) -> [String: AnalyticsValue]? {
let outcome: String
let phase: String?
switch kind {
case .taskModelListLoadSucceeded:
outcome = "success"
phase = nil
case .taskModelListLoadFailed:
outcome = "failure"
phase = nil
case .taskModelListRetryScheduled:
outcome = "failure"
phase = "retry_scheduled"
case .taskModelListRetryStopped:
outcome = "failure"
phase = "retry_stopped"
default:
return nil
}
let modelCount: Int
switch kind {
case .taskModelListRetryScheduled, .taskModelListRetryStopped:
modelCount = 0
default:
modelCount = event.c ?? 0
}
let requestDurationMilliseconds: Int
switch kind {
case .taskModelListRetryScheduled:
// The event's duration slot carries the backoff for this phase. The
// request duration is unavailable here, so keep latency histograms
// honest instead of treating the sleep as network work.
requestDurationMilliseconds = 0
default:
requestDurationMilliseconds = Int(event.ms ?? 0)
}
var properties: [String: AnalyticsValue] = [
"operation": .string("model_list"),
"outcome": .string(outcome),
// Transport failures can precede a catalog result. The ingress
// requires this field even when discovery produced no models.
"model_count": .int(modelCount),
"duration_ms": .int(requestDurationMilliseconds),
]
if let surface = event.surface {
// This is the existing process-local correlation handle. It lets
// Axiom join one refresh's retries without exporting the Mac ID.
properties["correlation_id"] = .int(Int(surface))
}
if let phase {
properties["phase"] = .string(phase)
}
let failure = DiagnosticEventPresentation().failureKind(of: event)
if let failure, failure != .none {
properties["failure"] = .string(DiagnosticEventPresentation().name(failure))
}
switch kind {
case .taskModelListRetryScheduled:
properties["attempt"] = .int(event.c ?? 0)
properties["retry_delay_ms"] = .int(Int(event.ms ?? 0))
case .taskModelListRetryStopped:
if let reason = event.c.flatMap(DiagnosticTaskModelRetryStopReason.init(rawValue:)) {
properties["stop_reason"] = .string(DiagnosticEventPresentation().name(reason))
}
default:
break
}
return properties
}

/// Builds a terminal latency payload for an event that already carries a
/// measured duration. This keeps direct event-level tests simple.
static func properties(for event: DiagnosticEvent) -> [String: AnalyticsValue]? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public struct PushTabNavigationPreviewView: View {

/// Creates the deterministic push-navigation preview fixture.
@MainActor public init() {
_store = State(initialValue: makePushTabNavigationPreviewStore(connectionState: .disconnected))
_store = State(initialValue: Self.makePreviewStore(connectionState: .disconnected))
}

/// Renders the production shell and the fixture controls.
Expand Down Expand Up @@ -217,23 +217,23 @@ public struct PushTabNavigationPreviewView: View {
private var connectedWorkspaces: [MobileWorkspacePreview] {
[Self.homeWorkspace, Self.docsWorkspace]
}
}

@MainActor private func makePushTabNavigationPreviewStore(
connectionState: MobileConnectionState
) -> CMUXMobileShellStore {
CMUXMobileShellStore(
isSignedIn: true,
connectionState: connectionState,
pairingCode: "preview",
workspaces: [
MobileWorkspacePreview(
id: "workspace-home",
name: "Home",
terminals: []
)
]
)
@MainActor private static func makePreviewStore(
connectionState: MobileConnectionState
) -> CMUXMobileShellStore {
CMUXMobileShellStore(
isSignedIn: true,
connectionState: connectionState,
pairingCode: "preview",
workspaces: [
MobileWorkspacePreview(
id: "workspace-home",
name: "Home",
terminals: []
)
]
)
}
}

private extension View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ enum PhotoLibraryTransferError: Error {
}

private final class PhotoLibraryTransferRace: @unchecked Sendable {
// `start` must store the continuation inside the synchronous
// `withCheckedThrowingContinuation` closure, and `cancel` runs in the
// synchronous `onCancel:` of `withTaskCancellationHandler`. Neither can
// await, so an actor would force both through a detached Task and lose the
// ordering that keeps a resume from racing the store. Carve-out: the race
// is settled by `didFinish` under this lock, which resumes exactly once.
private let lock = NSLock()
private var continuation: CheckedContinuation<ImportedPhotoLibraryFile?, Error>?
private var transferTask: Task<Void, Never>?
Expand Down Expand Up @@ -266,24 +272,26 @@ private final class PhotoLibraryTransferRace: @unchecked Sendable {
}
}

/// Loads a Photos library asset with a bounded wait. iCloud-backed assets can
/// otherwise leave a composer staging task waiting indefinitely when the
/// network transfer stalls.
func loadImportedPhotoLibraryFile(
_ item: PhotosPickerItem,
timeout: Duration = .seconds(60)
) async throws -> ImportedPhotoLibraryFile? {
let race = PhotoLibraryTransferRace()
return try await withTaskCancellationHandler(operation: {
try await withCheckedThrowingContinuation { continuation in
race.start(
item: item,
timeout: timeout,
continuation: continuation
)
}
}, onCancel: {
race.cancel()
})
extension ImportedPhotoLibraryFile {
/// Loads a Photos library asset with a bounded wait. iCloud-backed assets can
/// otherwise leave a composer staging task waiting indefinitely when the
/// network transfer stalls.
static func load(
_ item: PhotosPickerItem,
timeout: Duration = .seconds(60)
) async throws -> ImportedPhotoLibraryFile? {
let race = PhotoLibraryTransferRace()
return try await withTaskCancellationHandler(operation: {
try await withCheckedThrowingContinuation { continuation in
race.start(
item: item,
timeout: timeout,
continuation: continuation
)
}
}, onCancel: {
race.cancel()
})
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ extension TaskComposerSheet {
private func stagePhotoPickerItem(
_ item: PhotosPickerItem
) async throws -> TaskComposerAttachment {
guard let imported = try await loadImportedPhotoLibraryFile(item) else {
guard let imported = try await ImportedPhotoLibraryFile.load(item) else {
throw TaskComposerAttachmentStager.StagingError.unreadableFile
}
defer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ struct TerminalComposerView: View {
// before we choose its staging path.
let imported: ImportedPhotoLibraryFile
do {
guard let loaded = try await loadImportedPhotoLibraryFile(item) else {
guard let loaded = try await ImportedPhotoLibraryFile.load(item) else {
store.recordAppEvent(
.attachmentPreparationFailed,
correlationID: terminalID,
Expand Down
Loading