Skip to content

Fix EXC_BREAKPOINT reading a deleted entity's timestamp in the console - #377

Merged
kean merged 1 commit into
kean:mainfrom
mthuong:fix/deleted-entity-timestamp-trap
Aug 15, 2026
Merged

Fix EXC_BREAKPOINT reading a deleted entity's timestamp in the console#377
kean merged 1 commit into
kean:mainfrom
mthuong:fix/deleted-entity-timestamp-trap

Conversation

@mthuong

@mthuong mthuong commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

The console crashes with EXC_BREAKPOINT when it renders an entity that the store has just deleted:

Date._unconditionallyBridgeFromObjectiveC(_:)
closure #1 in ConsoleTaskCell.makeHeader(settings:)   (ConsoleTaskCell.swift:78)
ConsoleTaskCell.makeHeader(settings:)                 (ConsoleTaskCell.swift:67)
closure #1 in ConsoleTaskCell.body.getter             (ConsoleTaskCell.swift:41)
protocol witness for View.body.getter in conformance ConsoleTaskCell
ViewBodyAccessor.updateBody(of:changed:)
DynamicBody.updateValue()
...
DynamicLayoutComputer.updateValue()

Caught in the wild on 5.2.3 (iPhone SE 3, iOS 26.5), and main is identical here.

Cause

LoggerMessageEntity.createdAt and NetworkTaskEntity.createdAt are non-optional @NSManaged Dates. deleteEntities(for:) batch-deletes rows and merges the deletions into viewContext with mergeChanges(fromRemoteContextSave:). Core Data then reports every attribute of those objects as NULL, so formattedTimestamptimestampFormatter.string(from: createdAt) — force-bridges nil into Date and traps.

Two things let a deleted entity reach a body evaluation:

  • ConsoleListViewModel.visibleEntities is a snapshot that is deliberately not refreshed while the list is scrolled away from the top (case .middle: break // Don't reload: too expensive and ruins gestures), so the list keeps rendering entities that no longer exist.
  • ConsoleEntityCell already guards this with if entity.isDeleted { EmptyView() }, but entity is a plain let, so that body is never re-evaluated when the deletion merges. Only the inner cell — which holds the task as @ObservedObject — re-runs, and it re-runs straight into the trap.

formattedTimestamp is a lazy var, so the trap needs a body evaluation that happens after the delete and before the timestamp was ever cached — a row materialising or laying out at that moment, which is what the DynamicLayoutComputer frames in the stack show.

Fix

  1. formattedTimestamp reads createdAt through KVC, so a NULL value surfaces as nil and renders "–" instead of trapping. This mirrors how state(in:) already guards session after Crash when hitting "Remove Logs" #242.
  2. ConsoleEntityCell.entity becomes @ObservedObject, so the existing isDeleted guard is actually re-evaluated when the deletion merges and the deleted row leaves the screen. Without this, the same trap simply moves to another non-optional attribute (text, session, taskId, httpHeaders).

Verification

Minimal harness (no UI): record 20 tasks in a LoggerStore, fetch them on viewContext, delete, then read what ConsoleTaskCell:78 reads.

trigger before after
store.removeAll() — the console's Remove Logs action isDeleted=true, value(forKey: "createdAt") = nil, reading formattedTimestampSIGTRAP (exit 133) formattedTimestamp == "–", exit 0
automatic sweep (init + 10 s, trims the oldest entries) same SIGTRAP formattedTimestamp == "–", exit 0

swift build is clean, and xcodebuild -scheme PulseUI -destination 'generic/platform=iOS' succeeds.

Unrelated to #373/#374: that one is an NSInternalInconsistencyException because NSBatchDeleteRequest is unsupported on .inMemory stores, where the batch delete never executes. This one is a Swift trap on a SQLite-backed store where the delete succeeded. They're complementary — once #374 lands and in-memory stores also merge deletions into the view context, they will hit this same trap.

`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 kean#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` (kean#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.
@kean

kean commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Good catch, thank you!

@kean
kean merged commit 687b4fa into kean:main Aug 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants