From 4f8db36669128b0239f3715b05f2a847e4834658 Mon Sep 17 00:00:00 2001 From: Thuong Nguyen Date: Tue, 28 Jul 2026 17:56:51 +0700 Subject: [PATCH] Fix crash reading a deleted entity's timestamp in the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LoggerMessageEntity.createdAt` and `NetworkTaskEntity.createdAt` are non-optional `@NSManaged Date`s. `LoggerStore.deleteEntities` batch-deletes rows and merges the deletions into `viewContext`, after which Core Data reports every attribute of the affected objects as NULL. Any console cell still holding one of those entities then force-bridges `nil` into `Date` and traps: Date._unconditionallyBridgeFromObjectiveC(_:) closure #1 in ConsoleTaskCell.makeHeader(settings:) (ConsoleTaskCell.swift:78) ConsoleTaskCell.body.getter ... EXC_BREAKPOINT Read `createdAt` through KVC in `formattedTimestamp` so a NULL value surfaces as `nil` instead of trapping, matching how `state(in:)` already guards `session` (#242). Also observe the entity in `ConsoleEntityCell` so its existing `if entity.isDeleted { EmptyView() }` guard is actually re-evaluated when the deletion merges. With `entity` as a plain `let`, that body never re-runs on deletion, and the list keeps deleted entities on screen (`ConsoleListViewModel.visibleEntities` is deliberately not refreshed while the list is scrolled away from the top) — which would otherwise move the same trap onto `text`, `session`, `taskId` or `httpHeaders`. Reproduced with `removeAll()` (the console's "Remove Logs" action) and with the automatic sweep: both leave a fetched entity `isDeleted` with `value(forKey: "createdAt") == nil`, and reading `formattedTimestamp` raises SIGTRAP before this change, returning "–" after it. --- .../LoggerStore/LoggerStore+Entities.swift | 17 +++++++++++++++-- .../Console/Views/ConsoleEntityCell.swift | 8 +++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/Pulse/LoggerStore/LoggerStore+Entities.swift b/Sources/Pulse/LoggerStore/LoggerStore+Entities.swift index 42fb5a519..ba11d7364 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore+Entities.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore+Entities.swift @@ -11,6 +11,19 @@ private let timestampFormatter: DateFormatter = { return f }() +/// Formats the entity's timestamp for display. +/// +/// Reads `createdAt` via KVC so a NULL stored value (which Core Data reports for +/// every attribute of a deleted object once the deletion is merged into the +/// context) is surfaced as `nil` instead of trapping the ObjC->Swift `Date` +/// bridge. +private func makeFormattedTimestamp(for object: NSManagedObject) -> String { + guard let createdAt = object.value(forKey: "createdAt") as? Date else { + return "–" + } + return timestampFormatter.string(from: createdAt) +} + public final class LoggerSessionEntity: NSManagedObject { @NSManaged public var createdAt: Date @NSManaged public var id: UUID @@ -32,7 +45,7 @@ public final class LoggerMessageEntity: NSManagedObject { @NSManaged public var task: NetworkTaskEntity? public lazy var metadata = { KeyValueEncoding.decodeKeyValuePairs(rawMetadata) }() - public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt) + public lazy var formattedTimestamp: String = makeFormattedTimestamp(for: self) } public final class NetworkTaskEntity: NSManagedObject { @@ -112,7 +125,7 @@ public final class NetworkTaskEntity: NSManagedObject { // MARK: Helpers public lazy var metadata = { rawMetadata.map(KeyValueEncoding.decodeKeyValuePairs) }() - public lazy var formattedTimestamp: String = timestampFormatter.string(from: createdAt) + public lazy var formattedTimestamp: String = makeFormattedTimestamp(for: self) public lazy var parsedURLComponents: URLComponents? = url.flatMap { URLComponents(string: $0) } // View-layer formatting caches populated by PulseUI. Keyed on the input diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift index de40ed5b4..9ecdcee35 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleEntityCell.swift @@ -11,7 +11,13 @@ import CoreData @available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *) struct ConsoleEntityCell: View { - let entity: NSManagedObject + /// Observed so that the `isDeleted` check below is re-evaluated when the + /// entity is deleted from under the list. The console keeps rendering the + /// entities it last fetched (`ConsoleListViewModel.visibleEntities` is not + /// refreshed while the list is scrolled away from the top), so without the + /// observation this view keeps a deleted entity on screen and the cell + /// traps reading its non-optional attributes. + @ObservedObject var entity: NSManagedObject var urlMatch: ConsoleSearchMatch? init(entity: NSManagedObject) {