Skip to content

fix(macos): flush pending autosave in DocumentManager before clearing state (LUM-1272)#28681

Merged
ashleeradka merged 2 commits into
mainfrom
devin/1777414852-fix-document-close-flush
Apr 28, 2026
Merged

fix(macos): flush pending autosave in DocumentManager before clearing state (LUM-1272)#28681
ashleeradka merged 2 commits into
mainfrom
devin/1777414852-fix-document-close-flush

Conversation

@ashleeradka

@ashleeradka ashleeradka commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

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 in save() where title and wordCount were read from self inside 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

  • Eliminates silent data loss on document close and document replacement
  • 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 state
  • Clean state boundaries: isSaving/lastSaveError are reset at document open and close, preventing inherited state from a previous document's in-flight save
  • deinit cancels autoSaveTask per @Observable task lifecycle requirements

Why this is safe

  • Single call site for closeDocument() (PanelCoordinator.swift:594); createDocument() now self-guards by flushing before overwriting
  • save() guard on nil surfaceId/conversationId makes it a no-op on clean close (no active document)
  • Fire-and-forget save at close is correct for a network-backed document: the Task captures all state as locals, completes independently, and a failed save at close time can't be meaningfully retried
  • All changes are synchronous on @MainActor — no new concurrency edges or race conditions introduced

References

Alternatives not taken

  • Making closeDocument() async to await save completion — rejected because callers are synchronous SwiftUI closures. Propagating async through the view layer would be a large, disruptive change for marginal benefit (the fire-and-forget save captures everything it needs as locals).
  • Dirty tracking (only save if content differs from last-saved state) — over-engineering. save() is cheap, the server handles idempotent saves, and the nil-guard already prevents saves when no document is active.
  • Adding a "save on close" flag or separate flush method — unnecessary indirection when save() already does the right thing once its state capture is fixed.
  • Adding the surfaceId scope guard to MainWindow.handleDocumentSaveResponse (the gateway WebSocket save-response path) — left as-is because that path may handle daemon-initiated saves not triggered by save(), and the practical impact of the dual-response is negligible (both paths agree on success/failure for the same operation).

Root cause analysis

  1. How did the code get into this state? The cancel-before-flush pattern was original code from the initial document editor implementation. The autosave debounce was added for performance, but the close path was never updated to account for the debounce window.
  2. What mistakes or decisions led to it? save() was written with an implicit assumption that self state would remain stable between the synchronous method call and async Task execution — a natural mistake when all code runs on @MainActor and appears sequential. The inconsistency (capturing contentToSave as a local but reading title/wordCount from self) suggests the capture pattern wasn't systematically applied.
  3. Were there warning signs we missed? The inconsistent capture pattern in save() was a signal. contentToSave was correctly captured as a local, but title and wordCount were not — indicating partial awareness of the issue without follow-through.
  4. What can we do to prevent this pattern from recurring? When spawning unstructured Tasks from actor-isolated methods, capture all mutable state the Task needs as locals before the Task — same discipline as escaping closures. This is already covered by existing AGENTS.md guidance on task lifecycle patterns.
  5. Should we update AGENTS.md? No. The existing guidance on @ObservationIgnored deinit 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 via closeDocument() before overwriting.



Open in Devin Review

…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-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@linear

linear Bot commented Apr 28, 2026

Copy link
Copy Markdown

LUM-1272 fix(macos): DocumentManager.closeDocument() cancels pending autosave without flushing -- potential document data loss

devin-ai-integration[bot]

This comment was marked as resolved.

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>
@devin-ai-integration devin-ai-integration Bot changed the title fix(macos): DocumentManager.closeDocument() cancels pending autosave without flushing (LUM-1272) fix(macos): flush pending autosave in DocumentManager before clearing state (LUM-1272) Apr 28, 2026

@vex-assistant-bot vex-assistant-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vex-assistant-bot

Copy link
Copy Markdown
Contributor

@devin-ai review this PR

@ashleeradka ashleeradka merged commit cc838b8 into main Apr 28, 2026
7 checks passed
@ashleeradka ashleeradka deleted the devin/1777414852-fix-document-close-flush branch April 28, 2026 22:38
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.

1 participant