fix(macos): flush pending autosave in DocumentManager before clearing state (LUM-1272)#28681
Conversation
…ment() (LUM-1272) - closeDocument() now calls save() before cancelling the autoSave task and clearing state, preventing silent data loss when the user closes within the 2-second debounce window. - save() captures title, wordCount, and documentClient as locals before the Task to prevent reading stale/cleared values from self after close. - save() uses [weak self] and a surfaceId scope guard so stale save responses from a previous document cannot clobber a newly-opened document's state. - createDocument() and closeDocument() reset isSaving/lastSaveError for clean state at document boundaries. - Remove redundant scheduleAutoSave() call in updateDocument() (became dead after PR #4834 moved the call before the coordinator guard). - Add deinit to cancel autoSaveTask per @observable task lifecycle guidance. Apple refs checked (2026-04-28): - WWDC21 Protect mutable state with Swift actors - swift#79551 @observable deinit actor isolation Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 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:
|
Callers like handleDocumentEditorShow and handleDocumentLoadResponse call createDocument() directly without closeDocument(). If a document has unsaved edits, the new document would silently overwrite them. Guard at the data layer so all callers get the flush automatically. Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
Vex Review — PR #28681 ✦
Fix: Correct. save() before autoSaveTask?.cancel() in closeDocument() eliminates the cancel-before-flush data loss path exactly as specified in the briefing.
Diff analysis:
✅ closeDocument() — save() called first, then cancel. Fixes the root cause.
✅ save() nil-guard — makes it a safe no-op on clean close (no active document). Fire-and-forget is correct here.
✅ State capture in save() — titleToSave, wordCountToSave, client all captured as locals before the Task. Fixes the stale-value concurrency bug. Correct per WWDC21 actor isolation pattern.
✅ [weak self] + surfaceId scope guard — prevents stale save response from polluting a newly-opened document's state.
✅ createDocument() self-guard — closeDocument() called before overwriting catches the second silent data-loss path (document replacement without flush).
✅ isSaving/lastSaveError reset at open and close — clean state boundaries, no inherited state from prior in-flight saves.
✅ deinit { autoSaveTask?.cancel() } — correct @Observable task lifecycle cleanup. Used @ObservationIgnored on autoSaveTask per swift#79551 — good catch.
✅ Removed duplicate scheduleAutoSave() after coordinator.sendContentUpdate — verified the call at line 99 inside updateDocument() is still present and firing correctly. Autosave behavior unchanged.
✅ FlexFrame Lint passing. No SwiftUI layout changes in this diff — no anti-pattern risk.
No anti-patterns found. No SwiftUI measurement concerns (pure data layer change). No .frame(maxWidth:) or .frame(maxHeight:) changes.
Devin flagged 2 issues — checking: both are COMMENTED reviews, neither is an APPROVE. Per review criteria, Devin COMMENT reviews count as approval when body contains "✅ Devin Review: No Issues Found" — Devin's body here says "found 2 potential issues," so this does NOT qualify as Devin approval. Will need Devin to re-review and clear.
Status: APPROVE from Vex. The fix is correct, safe, and well-documented. Waiting on Devin clearance for merge criteria.
|
@devin-ai review this PR |
closeDocument()cancelled the 2-second autosave debounce and cleared all state without persisting pending content — silently dropping unsaved edits with no error and no Sentry signal. This also fixes a concurrency anti-pattern insave()wheretitleandwordCountwere read fromselfinside an unstructured Task instead of being captured as locals, causing stale values to be persisted when state changed between the synchronous call and async execution.Why this change is needed
The autosave debounce creates a 2-second window where content exists only in memory. Three paths trigger close within this window: the user closing the editor panel, navigating away, or the editor auto-dismissing after a stream ends. All silently dropped edits. Additionally,
createDocument()callers (handleDocumentEditorShow,handleDocumentLoadResponse) overwrote active document state without flushing — another silent data loss path.Benefits
save()correctly captures all mutable state before going async, preventing stale-value persistence regardless of what happens after the call returns[weak self]+ surfaceId scope guard prevents stale save responses from a previous document clobbering a newly-opened document's stateisSaving/lastSaveErrorare reset at document open and close, preventing inherited state from a previous document's in-flight savedeinitcancelsautoSaveTaskper@Observabletask lifecycle requirementsWhy this is safe
closeDocument()(PanelCoordinator.swift:594);createDocument()now self-guards by flushing before overwritingsave()guard on nilsurfaceId/conversationIdmakes it a no-op on clean close (no active document)@MainActor— no new concurrency edges or race conditions introducedReferences
[weak self]alone@Observablemacro prevents accessing stored properties indeinit;@ObservationIgnoredis the workaroundAlternatives not taken
closeDocument()async to await save completion — rejected because callers are synchronous SwiftUI closures. Propagatingasyncthrough the view layer would be a large, disruptive change for marginal benefit (the fire-and-forget save captures everything it needs as locals).save()is cheap, the server handles idempotent saves, and the nil-guard already prevents saves when no document is active.save()already does the right thing once its state capture is fixed.MainWindow.handleDocumentSaveResponse(the gateway WebSocket save-response path) — left as-is because that path may handle daemon-initiated saves not triggered bysave(), and the practical impact of the dual-response is negligible (both paths agree on success/failure for the same operation).Root cause analysis
save()was written with an implicit assumption thatselfstate would remain stable between the synchronous method call and async Task execution — a natural mistake when all code runs on@MainActorand appears sequential. The inconsistency (capturingcontentToSaveas a local but readingtitle/wordCountfromself) suggests the capture pattern wasn't systematically applied.save()was a signal.contentToSavewas correctly captured as a local, buttitleandwordCountwere not — indicating partial awareness of the issue without follow-through.@ObservationIgnoreddeinit patterns and task lifecycle management already covers this. The issue was not applying the existing guidance, not missing guidance.Prompt / plan
LUM-1272: fix(macos): DocumentManager.closeDocument() cancels pending autosave without flushing
Test plan
Code path tracing and review — CI skips macOS checks (no macOS build environment). Verified:
save()guard handles nil state on clean close,[weak self]+ surfaceId scope guard prevents stale response pollution,createDocument()self-guards by flushing viacloseDocument()before overwriting.