perf: fix main-thread hangs from Observation cascades, synchronous Combine dispatch, and GeometryReader layout#23464
Merged
Conversation
…ing main-thread hang
Replace the withObservationTracking re-entrancy loops in ChatMessageManager
and ChatPaginationState with direct Observation framework participation
(access/withMutation) and Combine pipelines. Each messages mutation now
emits a single Observation notification + a single Combine publish instead
of scheduling N Task { @mainactor } callbacks that cascade through the
SwiftUI transaction system.
Key changes:
- ChatMessageManager: custom getter/setter for messages and isThinking
using access(keyPath:)/withMutation(keyPath:) + CurrentValueSubject
- ChatMessageManager: batchUpdateMessages(_:) for coalesced bulk mutations
- ChatMessageManager: hasPendingConfirmation cached via Combine pipeline
- ChatPaginationState: replace withObservationTracking loop with
messagesPublisher Combine subscription
- ChatViewModel: use batchUpdateMessages for trim operations
- MessageSendCoordinator: use batchUpdateMessages in resetCancelState
- ConversationActivityStore: use cached hasPendingConfirmation, clean up docs
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Contributor
Author
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
GeometryReader creates a synchronous parent-child layout dependency that forces SwiftUI to re-measure the entire view hierarchy from root to leaf on every content change. Replace with .onGeometryChange(for:) which delivers size asynchronously and avoids triggering full-tree measurement cascades when child content (e.g. LazyVStack messages) changes. The width is only used for drag constraints, so async delivery is safe. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…all mutation sites
- _modify accessor now defers publish via Task { @mainactor } so rapid
subscript mutations coalesce into one notification
- Consolidate 4 independent Combine pipelines into single
recomputeDerivedValues(from:) function (4×O(n) → 1×O(n))
- Remove dead isThinkingPublisher / _isThinkingSubject code
- Simplify isThinking to plain stored property
- Batch stopGenerating, watchdog recovery, handleMessageContentResponse,
and updateSurfacePreviewImage mutations via batchUpdateMessages
- Convert ConversationActivityStore observeAssistantActivityLoop and
observeActiveMessageCountLoop from withObservationTracking to
messagesPublisher subscriptions
- Cancel pending deferred task in batchUpdateMessages to prevent stale
double-notification
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…ant notifications Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…Manager - Batch handleConversationError: wrap assistant finalization, empty message removal, inline error insertion, and processing/queued status resets in a single batchUpdateMessages call - Batch stopGenerating orphaned tool call completion in early-return path - Add explicit messagesSub?.cancel() in resetMessagePagination for style consistency with deinit - Add deinit to ChatMessageManager cancelling _deferredPublishTask and derivedValuesSub per AGENTS.md convention Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…omment - In handleError, capture the blocked ChatMessage into a local variable inside the batchUpdateMessages closure *before* removing it from the array. The post-batch 'Send Anyway' context extraction now reads from the captured message instead of re-searching vm.messages (where the message no longer exists after batch removal). - Fix stale comment in ChatViewModel.swift displayedMessageCount setter that still referenced 'async Combine bridge' from the old withObservationTracking approach. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
ashleeradka
approved these changes
Apr 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a severe main-thread hang (20–47 seconds) caused by two compounding issues in the chat message pipeline:
Issue 1 —
withObservationTrackingre-entrancy cascades: Whenmessagesmutated, 5+ independentwithObservationTrackingonChange handlers each scheduled aTask { @MainActor }that re-readmessages, re-registered tracking, and wrote derived@Observableproperties — triggering a cascading SwiftUI transaction flush that measured everyForEachitem in theLazyVStack.withObservationTrackingis designed for single-shot observation; the re-arming loop pattern (onChange → Task → re-observe) creates N async dispatches per mutation that pile up in the same run-loop tick.Issue 2 — Synchronous
GeometryReaderlayout dependency:VAppWorkspaceDockLayoutandVSplitViewusedGeometryReader, which creates a synchronous parent-child layout dependency. When any child's preferred size changes, SwiftUI re-measures the entire tree from theGeometryReaderroot through all children.Bug fix —
secret_blockedmessage capture:handleErrorremoved the blocked user message insidebatchUpdateMessagesbut then attempted to read the message's text and attachments fromvm.messagesafter the batch — when the message no longer existed. This silently dropped all file attachments the user originally included, breaking "Send Anyway" reconstruction. Fixed by capturing the blockedChatMessageinside the batch closure beforemsgs.remove(at:).Approach
Messages property: Custom
access(keyPath:)/withMutation(keyPath:)getter/setter that participates in the Observation framework while also publishing to a CombineCurrentValueSubject. The_modifyaccessor defers the Combine publish via a coalescedTask { @MainActor }, so multiple rapid subscript mutations (e.g.stopGeneratingtouching 5+ fields) result in a single downstream notification instead of one per mutation. Thesetaccessor andbatchUpdateMessagespublish synchronously since they represent intentional complete mutations.Derived values: A single
recomputeDerivedValues(from:)function computes all four cached properties (activePendingRequestId,hasPendingConfirmation,hasNonEmptyMessage,latestPersistedTipDaemonMessageId) in one O(n) pass, replacing four independent Combine pipelines that each did O(n) work. Manual!=checks before writing prevent unnecessary@Observablenotifications.Dead code removal:
isThinkingPublisherand_isThinkingSubjecthad zero subscribers.isThinkingreverted to a plain@Observablestored property.Mutation batching: All multi-mutation sites now use
batchUpdateMessagesto emit a single notification:MessageSendCoordinator:stopGenerating(main path + orphaned tool call early-return),resetCancelStateChatViewModel:handleMessageContentResponse,updateSurfacePreviewImage,trimOldMessagesIfNeeded,trimForBackground, watchdog recoveryChatActionHandler:handleGenerationCancelled,handleError,handleConversationError,handleMessageDequeuedConversationActivityStore:
observeAssistantActivityLoopandobserveActiveMessageCountLoop— which read the heavymessagesarray — replaced withmessagesPublisherCombine subscriptions. Scalar-reading loops (observeBusyStateLoop,observeInteractionStateLoop) retained aswithObservationTrackingsince they read lightweight properties that change infrequently.GeometryReader →
.onGeometryChange:VAppWorkspaceDockLayoutandVSplitViewuse.onGeometryChange(for:)to deliver width asynchronously, avoiding full-tree measurement cascades. The width is only used for drag constraints, so async delivery is safe.Resource cleanup:
ChatMessageManagernow has adeinitthat cancels_deferredPublishTaskandderivedValuesSub, following the established pattern in sibling classes (ChatBtwState,SubagentDetailStore,ChatPaginationState) perAGENTS.mdconvention.ChatPaginationState.resetMessagePaginationexplicitly cancels the old Combine subscription before reassignment for consistency with itsdeinit.Decision Context & Anti-Patterns
This section documents why specific architectural choices were made and what patterns to avoid, for future reference by developers and coding agents.
Why
withObservationTrackingre-arming loops are an anti-pattern formessages:Apple's
withObservationTrackingis single-shot by design — theonChangeclosure fires once and must be re-registered. The common "re-arm in onChange" pattern (onChange → Task { @MainActor in re-observe }) creates a new async dispatch per mutation. When N independent loops each observemessages, a single array mutation generates NTaskdispatches that all land in the same run-loop tick, each writing to@Observableproperties that trigger SwiftUI'sGraphHost.flushTransactions(). This is the thundering-herd cascade that caused 20-47s hangs. For frequently-mutated collections, use Combine subscriptions (which naturally coalesce viaCurrentValueSubject) instead of re-arming observation loops. ThewithObservationTrackingpattern remains appropriate for infrequently-changing scalar properties likeisSendingandisThinking, where the one-shot overhead is negligible.Why custom getter/setter/
_modifyinstead of plain@Observable:Plain
@Observableproperties emit per-mutation notifications — everymessages[i].field = valuetriggers SwiftUI invalidation. We need a dual notification mechanism: (1) Observation framework participation (so SwiftUI tracks reads), and (2) CombineCurrentValueSubjectpublishing (so non-SwiftUI subscribers likeChatPaginationStateandConversationActivityStorecan subscribe with backpressure). The custom accessor pattern is exactly what the@Observablemacro generates internally —access(keyPath:)in the getter,withMutation(keyPath:)wrapping the setter — but adds Combine publishing alongside. The_modifyaccessor useswillSet/didSetdirectly (not thewithMutationclosure) because Swift's_modifycoroutine requiresyield, which is incompatible with closures.Why
GeometryReaderwas replaced with.onGeometryChange(for:of:action:):GeometryReadercreates a synchronous parent-child layout dependency — when any child's preferred size changes, SwiftUI re-measures the entire subtree from theGeometryReaderroot.onGeometryChangedelivers size asynchronously as a state change on the next transaction, breaking the synchronous dependency. Apple recommendsonGeometryChangewhen the geometry value is consumed as state rather than used for immediate child layout (see WWDC23 — Demystify SwiftUI performance). InVAppWorkspaceDockLayoutandVSplitView, the width is only used for drag constraints, making async delivery safe.Why some
withObservationTrackingloops were kept:ConversationActivityStore'sobserveBusyStateLoopandobserveInteractionStateLoopread scalar@Observableproperties (isSending,isThinking,hasPendingConfirmation) that change infrequently (a few times per conversation turn, not per-token). The re-arming overhead is negligible for these, and the simplerwithObservationTrackingpattern avoids introducing Combine subscriptions where they aren't needed.Why
isThinkingPublisherwas removed:_isThinkingSubjectandisThinkingPublisherhad zero subscribers anywhere in the codebase. The Combine subject was being.send()-ed on every mutation for no consumers.isThinkingreverted to a plain@Observablestored property, which is sufficient for SwiftUI observation.Why
recomputeDerivedValuesuses manual!=checks:Writing to an
@Observableproperty always triggers SwiftUI invalidation, even if the new value equals the old value. The Observation framework does not perform equality checks — it fires on anywithMutationcall. Manual!=guards before writing prevent unnecessary SwiftUI transaction flushes when derived values haven't actually changed. SeeObservationRegistrar.willSet/didSet.Why
Task.isCancelledguard is required inscheduleDeferredPublish:When
batchUpdateMessagesor thesetaccessor fires a synchronous publish, it cancels any pending deferredTask. However, Swift's cooperative cancellation is advisory — a cancelledTaskstill executes unless it checksTask.isCancelled. Without the guard, the cancelled task would send a redundant (and potentially stale) notification after the batch's synchronous publish already delivered the authoritative snapshot.What this PR does NOT fix:
A residual ~1s layout pass remains after sending messages, visible in stackshots as 100%
GraphHost.flushTransactions()→ deepStackLayout/_FlexFrameLayout/_PaddingLayoutrecursion with zero application code in the stack. This is the baseline cost of a single SwiftUI layout transaction — always present but previously masked by the 20-47s cascade. Reducing this requires view-layer changes (cell nesting depth,.compositingGroup(), image downscaling) and is tracked in LUM-713. A separate ~10s WindowServer compositing hang (GPU layer tree complexity with blur effects and large textures) is also tracked there.Benefits
secret_blocked"Send Anyway" now correctly preserves attachments from the blocked messagebatchUpdateMessagesat call sites; single derived-value function instead of four pipeline chainsisThinkingPublisher/_isThinkingSubjectWhy it's safe
_modifypattern is semantically equivalent to what the@Observablemacro generates — it adds a Combinesend()alongside each mutationTask { @MainActor }runs on the cooperative executor during the run loop's source-processing phase — before SwiftUI'sCFRunLoopObserverfires its transaction flush — ensuring derived values are up-to-date when views re-evaluate. ATask.isCancelledguard prevents cancelled deferred tasks from sending redundant notifications or clobbering newer task references.batchUpdateMessagescancels any pending deferred publish before firing its synchronous publish, preventing stale double-notificationsrecomputeDerivedValuesproduces the same values as the old four pipelines; manual!=checks before writing prevent unnecessary SwiftUI invalidation.onGeometryChangeis the Apple-recommended replacement forGeometryReaderwhen the size is consumed as state rather than used for child layoutConversationActivityStoreretainswithObservationTrackingloops for lightweight scalar reads (isSending,isThinking) that are not in the hang pathChatActionHandlerbatching preserves identical control flow — conditional logic (error insertion, empty message removal, queued-status resets) runs inside the batch closure with the same guards. Non-message state assignments remain outside the batch.secret_blockedfix: the capturedChatMessageis only used for "Send Anyway" context (text and attachments). Bookkeeping cleanup (pendingQueuedCount,pendingMessageIds, etc.) still happens inside the batch as before.Files changed
ChatMessageManager.swift— Core fix: deferred coalesced_modifypublish withTask.isCancelledguard, singlerecomputeDerivedValuesfunction, removed deadisThinkingPublisher/_isThinkingSubject,batchUpdateMessagescancels pending deferred task, addeddeinitfor resource cleanupChatActionHandler.swift— BatchhandleGenerationCancelled,handleError,handleConversationError,handleMessageDequeuedmulti-mutation sites; fixsecret_blockedmessage capture before batch removalChatPaginationState.swift— ReplacewithObservationTrackingloop withmessagesPublishersubscription; explicitcancel()inresetMessagePaginationChatViewModel.swift— BatchhandleMessageContentResponse,updateSurfacePreviewImage,trimOldMessagesIfNeeded,trimForBackground, watchdog recovery mutations; delegatehasPendingConfirmationto cached valueMessageSendCoordinator.swift— BatchstopGenerating(main path + orphaned tool call early-return) andresetCancelStatemutationsConversationActivityStore.swift— ReplaceobserveAssistantActivityLoopandobserveActiveMessageCountLoopwithmessagesPublishersubscriptions; remove deadactivityGenerations/invalidateActivityGenerationVAppWorkspaceDockLayout.swift— ReplaceGeometryReaderwith.onGeometryChange(for:)VSplitView.swift— SameGeometryReader→.onGeometryChange(for:)migrationReferences
ObservationRegistrar— documentsaccess(_:keyPath:),withMutation(of:keyPath:),willSet/didSetused in the custom property implementationwithObservationTracking(_:onChange:)— single-shot tracking semantics that make re-arming loops problematic@Observablemacro, custom property participation, and migration fromObservableObjectonGeometryChange(for:of:action:)— modern alternative toGeometryReaderthat avoids synchronous layout dependenciesReview & Testing Checklist for Human
_modifyaccessor using_$observationRegistrardirectly, the removal ofisThinkingPublisher/_isThinkingSubject(any remaining references will fail to compile), and the newimport CombineinConversationActivityStore.swift.secret_blocked(e.g. paste an API key). Verify the "Send Anyway" button appears AND preserves the original message text and any attached files. This exercises thecapturedBlockedMessagefix inhandleError..sent, and no orphaned state remains.stopGenerating,resetCancelState,handleGenerationCancelled, andhandleConversationErrornow batch their mutations — confirm no messages are missed.handleErrorandhandleConversationErrormoved their message mutations insidebatchUpdateMessageswhile keeping error state assignments outside.availableWidthnow arrives asynchronously via.onGeometryChange.Recommended test plan: Load a conversation with 100+ messages (the scenario from the hang reports). Interact with it: scroll, send messages, cancel mid-stream, trigger confirmations, trigger errors. Monitor for any stalls using Instruments. Compare against
mainto confirm the 20-47s hang is eliminated. Expect a residual ~1s baseline layout cost (tracked in LUM-713).Notes
_modifyaccessor uses_$observationRegistrar.willSet/didSetdirectly (rather than thewithMutationclosure API) because Swift's_modifycoroutine requiresyield, which is incompatible with a closure. This is the same pattern the@Observablemacro generates internally._modifycoalesces via aTask { @MainActor }guard: if a task is already pending, subsequent_modifycalls are no-ops. The task checksTask.isCancelledbefore executing to prevent redundant notifications whenbatchUpdateMessagesor thesetaccessor cancels the pending task and publishes synchronously.batchUpdateMessagesclosure receivesinout [ChatMessage]— callers must not readself.messagesinside the closure (they'd get stale data). Current call sites only mutate themsgsparameter, which is correct.handleConversationError,typedErroris force-unwrapped (typedError!) in two places — both are guarded by!wasCancelling, which guaranteestypedErroris non-nil (it's set toConversationError(from: msg)when!wasCancelling). This is safe but worth noting for future refactors.Link to Devin session: https://app.devin.ai/sessions/a0f7f0e1f1f6439db9534237d16c377a
Requested by: @ashleeradka