Skip to content

Fix session autosave disk and memory growth#2886

Open
austinywang wants to merge 11 commits intomainfrom
issue-2849-autosave-disk-memory
Open

Fix session autosave disk and memory growth#2886
austinywang wants to merge 11 commits intomainfrom
issue-2849-autosave-disk-memory

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 14, 2026

Summary

  • make autosave dirty-driven and debounced with a 30s default interval, while keeping the earliest pending save deadline instead of extending it on every dirty event
  • persist browser session state from URL/history/profile/zoom/devtools changes, including same-URL navigations that mutate back/forward history
  • avoid unnecessary autosave churn by excluding terminal-output scrollbar updates and runtime-only workspace status entries from dirty tracking, while keeping the autoreleasepool memory fix

Testing

  • not run locally (per repo policy)
  • ./scripts/reload.sh --tag issue-2849-autosave-disk-memory --launch

Notes

  • latest branch updates address follow-up review feedback around browser history persistence and autosave over-triggering
  • CI had one remaining compat job failure on unrelated unit tests in the previous run; fresh checks are running on commit 70a9ef0

Note

Medium Risk
Touches core session autosave/persistence scheduling and adds many new dirty triggers, which could affect write frequency and session restore correctness if any edge cases are missed.

Overview
Session persistence is reworked to be dirty-driven and debounced: autosave now schedules a single timer at the earliest pending deadline (default interval raised to 30s), retries when typing is recent or async writes are in-flight, and tracks dirty state/generations to avoid save storms.

Dirty tracking is broadened and made more precise across UI state changes: window move/resize/key/screen changes, sidebar visibility/width/selection, tab/workspace mutations (selection, panels, splits, focus, tab order), and browser persistence-relevant changes (URL/history availability, profile, zoom, devtools), with a lightweight history signature to detect same-URL navigations.

Snapshot persistence adds async completion handling and uses autoreleasepool around JSON encode/decode, plus updates tests to cover the new scheduling policy and Bonsplit same-pane reorder dirty signaling.

Reviewed by Cursor Bugbot for commit f7a68f3. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • More robust tracking and cleanup for window and session observers to improve session restoration reliability.
  • New Features

    • Finer-grained session-dirty notifications for tabs, workspaces, panels, sidebar (visibility/width/selection), history, devtools and zoom to capture meaningful changes.
  • Performance

    • Autosave refined: default interval increased and debounced scheduling with smarter retry logic to reduce unnecessary writes.
  • Tests

    • Session-autosave tests updated to validate new scheduling policy and interval.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 16, 2026 2:36am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces fingerprint-based autosave skipping with explicit dirty-state tracking and debounced scheduling, adds window geometry/lifecycle observers, wires many state properties to mark session-dirty on semantic changes, increases autosave interval to 30s, and adapts tests to schedule-based behavior.

Changes

Cohort / File(s) Summary
Core Autosave & Scheduler
Sources/AppDelegate.swift
Replaces fingerprint skip logic with dirty-state scheduling: adds requestSessionSnapshotDirty(...), markSessionSnapshotDirty(...), scheduleSessionAutosave(...), shouldKeepExistingSessionAutosaveSchedule(...), dormant timer semantics, pending-write tracking, and window-observer installation/cleanup. Removes fingerprint state and related methods.
State → Dirty Markers
Sources/ContentView.swift, Sources/SidebarSelectionState.swift, Sources/TabManager.swift, Sources/Workspace.swift
Adds didSet observers on many @Published properties (semantic-change guards) to call requestSessionSnapshotDirty(...). TabManager removes sessionAutosaveFingerprint(). Workspace gains markSessionSnapshotDirty(...) and extensive property wiring plus runtime dirtiness hooks.
Panel History & Notifications
Sources/Panels/BrowserPanel.swift
Adds onSessionPersistenceStateChange: ((String) -> Void)?, noteSessionPersistenceStateChanged(reason:), emits stringified reasons for profile/URL/renderability/history/devtools/zoom with history-signature deduplication, and clears callback in deinit.
Persistence & Memory
Sources/SessionPersistence.swift
Increases SessionPersistencePolicy.autosaveInterval 8.0 → 30.0. Wraps file read/decode and snapshot encoding/equality/atomic-write in autoreleasepool blocks.
Tests: Schedule-based Assertions
cmuxTests/SessionPersistenceTests.swift
Removes fingerprint-based tests; adds tests validating shouldKeepExistingSessionAutosaveSchedule(...) behavior and autosave interval expectations.

Sequence Diagram(s)

sequenceDiagram
    participant UI as UI Components
    participant Delegate as AppDelegate
    participant Scheduler as Scheduler
    participant Timer as Timer Tick
    participant Persist as SessionPersistenceStore

    rect rgba(100, 150, 255, 0.5)
    Note over UI,Delegate: Dirty-state request flow
    UI->>Delegate: requestSessionSnapshotDirty(reason)
    Delegate->>Delegate: sessionSnapshotIsDirty = true
    Delegate->>Scheduler: scheduleSessionAutosave(after, source, allowDelayExtension)
    Scheduler->>Scheduler: compute deadline & check shouldKeepExistingSchedule
    Scheduler->>Timer: reschedule timer (dormant/distantFuture semantics)
    end

    rect rgba(150, 200, 100, 0.5)
    Note over Timer,Persist: Autosave execution
    Timer->>Delegate: runSessionAutosaveTick(source)
    alt snapshot clean
        Delegate->>Delegate: skip save
    else snapshot dirty
        Delegate->>Persist: saveSessionSnapshot(...)
        Persist->>Persist: encode & atomic write (autoreleasepool)
        Persist-->>Delegate: completion
        Delegate->>Delegate: completeSessionSnapshotPersistence(...)
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hop and flag a changing scene,
Mark dirt where panels stretch and preen.
Timers sleep till thirty ticks,
Then nibble bytes and save the mix. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The pull request description provides a clear summary of changes and testing methodology, but is missing critical sections from the required template. Add a demo video link or explicitly state why none is needed, include manual verification details, copy/paste the review trigger block, and mark all checklist items.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Fix session autosave disk and memory growth' is closely aligned with the changeset, which addresses excessive autosave disk writes and memory growth through a dirty-driven, debounced autosave system and autoreleasepool wrapping.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2849-autosave-disk-memory

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/AppDelegate.swift (1)

2221-2230: Please verify these MainActor.assumeIsolated fast paths are actor-safe.

Thread.isMainThread and NotificationCenter(..., queue: .main) do not, by themselves, prove main-actor isolation. If either path runs on the main thread outside the main-actor executor, assumeIsolated is fragile here. Hopping explicitly with Task { @mainactor ... } is the safer pattern.

Possible fix
 nonisolated static func requestSessionSnapshotDirty(reason: String) {
-    if Thread.isMainThread {
-        MainActor.assumeIsolated {
-            AppDelegate.shared?.markSessionSnapshotDirty(reason: reason)
-        }
-        return
-    }
-
     Task { `@MainActor` in
         AppDelegate.shared?.markSessionSnapshotDirty(reason: reason)
     }
 }
         let observe: (Notification.Name, String) -> Void = { [weak self, weak window] name, reason in
             guard let self else { return }
             let observer = center.addObserver(
                 forName: name,
                 object: window,
                 queue: .main
             ) { [weak self] _ in
-                guard let self else { return }
-                MainActor.assumeIsolated {
-                    self.markSessionSnapshotDirty(reason: reason)
-                }
+                Task { `@MainActor` [weak self] in
+                    self?.markSessionSnapshotDirty(reason: reason)
+                }
             }
             context.sessionSnapshotWindowObservers.append(observer)
         }

Also applies to: 4770-4780

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 2221 - 2230, The current nonisolated
static requestSessionSnapshotDirty(reason:) uses Thread.isMainThread with
MainActor.assumeIsolated which is unsafe; replace the assumeIsolated fast-path
with an explicit hop to the main actor (use Task { `@MainActor` in
AppDelegate.shared?.markSessionSnapshotDirty(reason: reason) }) so both the
main-thread and non-main-thread paths always execute on the MainActor, and apply
the same change to the other occurrences (the similar block around lines
4770-4780 and any NotificationCenter callbacks that use .main queue or
assumeIsolated) to ensure actor-safe main-actor execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 4519-4520: runSessionAutosaveTick/saveSessionSnapshot currently
clear sessionSnapshotIsDirty and sessionAutosaveScheduledDeadlineUptime
immediately after enqueueing work, which allows overlapping writes because
actual disk I/O runs later on sessionPersistenceQueue; instead, change the flow
so those flags are only cleared on the main actor after the persistence queue
finishes (add a completion callback from the persistence write executed back on
`@MainActor`), keep the write block (the actual
SessionPersistenceStore.save/remove and persistedWindowGeometry writes)
executing on sessionPersistenceQueue, and if sessionSnapshotIsDirty was set
while the write was in flight, schedule an immediate retry; update calls at
runSessionAutosaveTick, saveSessionSnapshot and any places clearing
sessionSnapshotIsDirty (lines near 4540-4541 and 4577-4586) to use this
completion-based clearing via persistSessionSnapshot.

In `@Sources/Workspace.swift`:
- Around line 6626-6630: The statusEntries didSet should not call
markSessionSnapshotDirty for transient/remote-only changes—since
restoreSessionSnapshot(_:) drops statusEntries, stop dirtying the session from
any statusEntries updates (remove the markSessionSnapshotDirty call in the
statusEntries didSet). For surfaceListeningPorts (the other block referenced
around lines 6659-6663), only call markSessionSnapshotDirty when the persisted
subset actually changes: compute a persistable/filtering view that strips
remote-detected ports (the same subset sessionPanelSnapshot(...) omits) and
compare that filtered value to the old filtered value before calling
markSessionSnapshotDirty; reference statusEntries, surfaceListeningPorts,
restoreSessionSnapshot(_:), and sessionPanelSnapshot(...) to locate the relevant
code and
SessionPersistenceTests.testSessionSnapshotSkipsTransientRemoteListeningPorts
for expected behavior.

---

Nitpick comments:
In `@Sources/AppDelegate.swift`:
- Around line 2221-2230: The current nonisolated static
requestSessionSnapshotDirty(reason:) uses Thread.isMainThread with
MainActor.assumeIsolated which is unsafe; replace the assumeIsolated fast-path
with an explicit hop to the main actor (use Task { `@MainActor` in
AppDelegate.shared?.markSessionSnapshotDirty(reason: reason) }) so both the
main-thread and non-main-thread paths always execute on the MainActor, and apply
the same change to the other occurrences (the similar block around lines
4770-4780 and any NotificationCenter callbacks that use .main queue or
assumeIsolated) to ensure actor-safe main-actor execution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c89dc844-44af-4841-abd4-99f6d840d417

📥 Commits

Reviewing files that changed from the base of the PR and between 6a64de4 and 3451df1.

📒 Files selected for processing (8)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/SessionPersistence.swift
  • Sources/SidebarSelectionState.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/Workspace.swift Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 14, 2026

Greptile Summary

This PR replaces the periodic-timer + hash-fingerprint autosave approach with an event-driven dirty-flag approach: any persisted-state change marks the session dirty and schedules a one-shot 30 s debounce timer (using allowDelayExtension: false to prevent deadline-stomping on bursts of dirty events). Browser session state (URL, history, zoom, devtools, profile) is now also tracked via an onSessionPersistenceStateChange callback installed in Workspace.installBrowserPanelSubscription, plugging a gap that caused browser-only workflows to not persist.

Confidence Score: 5/5

  • Safe to merge — debounce logic is correct, all persisted fields are covered by dirty tracking, and no data-integrity or regression issues were found.
  • All findings are P2 (dead code and a default-value API footgun). The core correctness properties are solid: allowDelayExtension: false prevents deadline stomping on bursts, the browser state callback avoids retain cycles, the metadataBlocks/panelPullRequests omissions are intentional (neither field is in SessionWorkspaceSnapshot), and the async-write/dirty-reset ordering is safe.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/AppDelegate.swift Core autosave rework: replaces repeating 8 s timer + fingerprint with a one-shot debounce timer. markSessionSnapshotDirty correctly gates rescheduling with allowDelayExtension: false so deadline is never extended by subsequent dirty events. Window move/resize/screen observers installed via installSessionSnapshotWindowObservers with proper cleanup in deinit. Logic and lifecycle management look correct.
Sources/Workspace.swift Adds didSet dirty observers to all fields present in SessionWorkspaceSnapshot (processTitle, customTitle, currentDirectory, panels, panelDirectories, panelTitles, statusEntries, logEntries, progress, gitBranch, etc.). Fields intentionally omitted—metadataBlocks and panelPullRequests—are confirmed ephemeral (not in SessionPersistence schema). Browser panel subscriptions now wire onSessionPersistenceStateChange.
Sources/Panels/BrowserPanel.swift All persisted browser fields (currentURL, profileID, shouldRenderWebView, canGoBack/Forward, developerTools, zoom, history) now fire noteSessionPersistenceStateChanged. The onSessionPersistenceStateChange var is cleared in deinit and the callback is installed with [weak self, weak browserPanel], avoiding retain cycles.
Sources/SessionPersistence.swift Autosave interval increased from 8 s to 30 s (matching the new debounce window). load and save are now wrapped in autoreleasepool to bound peak memory during JSON decode/encode, addressing the memory growth issue mentioned in the PR title.
Sources/TabManager.swift Removes sessionAutosaveFingerprint() entirely. tabs and selectedTabId didSet observers mark dirty on structural changes. The fingerprint removal eliminates the O(n·workspace) hashing on every autosave tick.
Sources/ContentView.swift Adds didSet to SidebarState.isVisible and persistedWidth with appropriate equality guards (>0.5 threshold for width). Both fields are in SessionSidebarSnapshot, so the tracking is correct.
Sources/SidebarSelectionState.swift Single-line addition: @MainActor-isolated didSet correctly calls requestSessionSnapshotDirty when sidebar selection (tabs vs notifications) changes.
Sources/GhosttyTerminalView.swift Adds shouldMarkSessionSnapshotDirty and calls requestSessionSnapshotDirty("terminal.output") inside flushPendingScrollbar, which runs on main queue. The heuristic (total/len change, or both pinned-to-bottom with offset change) correctly bounds the dirty-marking rate for terminal output.

Sequence Diagram

sequenceDiagram
    participant State as State Change<br/>(Workspace/Sidebar/Browser/Window)
    participant WD as markSessionSnapshotDirty()
    participant SD as scheduleSessionAutosave()<br/>allowDelayExtension:false
    participant Timer as DispatchSourceTimer<br/>(one-shot, .distantFuture)
    participant Tick as runSessionAutosaveTick()
    participant Save as saveSessionSnapshot()
    participant Queue as sessionPersistenceQueue<br/>(async disk write)

    State->>WD: dirty event (e.g. currentDirectory, url, tabs)
    WD->>WD: "sessionSnapshotIsDirty = true"
    WD->>SD: after: 30s
    SD->>SD: "existingDeadline set? (no → schedule)<br/>(yes → now+30s ≥ existing → SKIP)"
    SD->>Timer: reschedule to now+30s

    Note over Timer: Subsequent dirty events<br/>→ SKIP (deadline preserved)

    Timer-->>Tick: fires after 30 s
    Tick->>Tick: "sessionAutosaveScheduledDeadlineUptime = nil"
    alt typing quiet period active
        Tick->>SD: scheduleSessionAutosave(after: remainingQuietPeriod)
        SD->>Timer: reschedule (retry)
    else clean to save
        Tick->>Save: saveSessionSnapshot()
        Save->>Queue: persistSessionSnapshot (async)
        Save->>WD: "sessionSnapshotIsDirty = false"
        Save->>SD: "sessionAutosaveScheduledDeadlineUptime = nil"
    end
Loading

Reviews (1): Last reviewed commit: "Fix autosave debounce regressions" | Re-trigger Greptile

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/AppDelegate.swift
Comment thread Sources/Workspace.swift Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 8 files

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)

4515-4543: ⚠️ Potential issue | 🟠 Major

Dirty/in-flight state is still cleared before the async write finishes.

saveSessionSnapshot clears sessionSnapshotIsDirty / sessionAutosaveScheduledDeadlineUptime immediately after enqueueing onto sessionPersistenceQueue, while runSessionAutosaveTick only guards the enqueue/build phase. A slow disk write can still accumulate later autosaves behind it, which is the same backlog the PR is trying to eliminate.

Also applies to: 4586-4596, 4656-4683

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 4515 - 4543, saveSessionSnapshot
clears sessionSnapshotIsDirty and sessionAutosaveScheduledDeadlineUptime
immediately after calling persistSessionSnapshot, which can enqueue an async
write on sessionPersistenceQueue and allow later autosaves to pile up; change
the logic so those dirty/in-flight flags are only cleared once the persistence
finishes (i.e., move/reset sessionSnapshotIsDirty and
sessionAutosaveScheduledDeadlineUptime into the completion path of
persistSessionSnapshot or make persistSessionSnapshot return a completion/future
and clear flags after it completes), keeping the existing immediate-clear
behavior only for the synchronous branch (when writeSynchronously is true);
update the same pattern for the other occurrences referenced (around lines with
persistSessionSnapshot calls noted in the comment).
🧹 Nitpick comments (1)
cmuxTests/SessionPersistenceTests.swift (1)

517-525: Consider adding an equality boundary case for deadline comparison.

Coverage currently checks target > existing and target < existing; adding target == existing would guard against future off-by-one/regression in schedule-keep logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 517 - 525, Add an
equality-boundary test for the deadline comparison: create a test (e.g.,
testSessionAutosaveScheduleKeepsWhenDeadlinesEqual) that calls
AppDelegate.shouldKeepExistingSessionAutosaveSchedule with
existingDeadlineUptime and targetDeadlineUptime set to the same value (e.g.,
200) and assert the expected behavior (keep the schedule) for the relevant
allowDelayExtension cases; reference the existing test patterns in
SessionPersistenceTests.swift and the target method
shouldKeepExistingSessionAutosaveSchedule to mirror style and ensure future
off-by-one regressions are caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 4515-4543: saveSessionSnapshot clears sessionSnapshotIsDirty and
sessionAutosaveScheduledDeadlineUptime immediately after calling
persistSessionSnapshot, which can enqueue an async write on
sessionPersistenceQueue and allow later autosaves to pile up; change the logic
so those dirty/in-flight flags are only cleared once the persistence finishes
(i.e., move/reset sessionSnapshotIsDirty and
sessionAutosaveScheduledDeadlineUptime into the completion path of
persistSessionSnapshot or make persistSessionSnapshot return a completion/future
and clear flags after it completes), keeping the existing immediate-clear
behavior only for the synchronous branch (when writeSynchronously is true);
update the same pattern for the other occurrences referenced (around lines with
persistSessionSnapshot calls noted in the comment).

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 517-525: Add an equality-boundary test for the deadline
comparison: create a test (e.g.,
testSessionAutosaveScheduleKeepsWhenDeadlinesEqual) that calls
AppDelegate.shouldKeepExistingSessionAutosaveSchedule with
existingDeadlineUptime and targetDeadlineUptime set to the same value (e.g.,
200) and assert the expected behavior (keep the schedule) for the relevant
allowDelayExtension cases; reference the existing test patterns in
SessionPersistenceTests.swift and the target method
shouldKeepExistingSessionAutosaveSchedule to mirror style and ensure future
off-by-one regressions are caught.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 454eef55-b222-4d79-88ed-135a427c352e

📥 Commits

Reviewing files that changed from the base of the PR and between 3451df1 and d87de0a.

📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • cmuxTests/SessionPersistenceTests.swift

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix this issue. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 70a9ef0. Configure here.

Comment thread Sources/ContentView.swift
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
Sources/AppDelegate.swift (1)

4516-4544: ⚠️ Potential issue | 🟠 Major

Keep autosave marked in-flight until sessionPersistenceQueue actually finishes.

persistSessionSnapshot still only queues the write for the async path, but Lines 4522-4524 and 4543-4544 clear the dirty/deadline state before that queued work runs. Once saveSessionSnapshot returns, the guard at Lines 4595-4597 no longer protects you, so a slow SessionPersistenceStore.save/removeSnapshot can still build a backlog of writes behind sessionPersistenceQueue and reintroduce the disk/memory growth this PR is trying to eliminate. Please move the state clearing behind a main-actor completion from persistSessionSnapshot, and immediately reschedule if anything marked the snapshot dirty during the queued write.

Also applies to: 4587-4598

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 4516 - 4544, The code clears
sessionSnapshotIsDirty and sessionAutosaveScheduledDeadlineUptime immediately
after calling persistSessionSnapshot, but persistSessionSnapshot only queues the
async write on sessionPersistenceQueue; move the clearing of
sessionSnapshotIsDirty and sessionAutosaveScheduledDeadlineUptime into a
completion handler (main-actor completion) invoked by persistSessionSnapshot
once the queued save/remove has finished (or expose a completion from
persistSessionSnapshot/saveSessionSnapshot/SessionPersistenceStore.save/removeSnapshot),
and if sessionSnapshotIsDirty was set again while the queued write ran,
immediately reschedule autosave (so you don't lose the dirty state); update both
code paths (the nil snapshot early-return and the normal persist path around
persistSessionSnapshot and saveSessionSnapshot) to use this completion-based
clearing/reschedule logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Sources/AppDelegate.swift`:
- Around line 4516-4544: The code clears sessionSnapshotIsDirty and
sessionAutosaveScheduledDeadlineUptime immediately after calling
persistSessionSnapshot, but persistSessionSnapshot only queues the async write
on sessionPersistenceQueue; move the clearing of sessionSnapshotIsDirty and
sessionAutosaveScheduledDeadlineUptime into a completion handler (main-actor
completion) invoked by persistSessionSnapshot once the queued save/remove has
finished (or expose a completion from
persistSessionSnapshot/saveSessionSnapshot/SessionPersistenceStore.save/removeSnapshot),
and if sessionSnapshotIsDirty was set again while the queued write ran,
immediately reschedule autosave (so you don't lose the dirty state); update both
code paths (the nil snapshot early-return and the normal persist path around
persistSessionSnapshot and saveSessionSnapshot) to use this completion-based
clearing/reschedule logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 35f09bfd-78ea-41b5-9dc4-cb551bf330f8

📥 Commits

Reviewing files that changed from the base of the PR and between 70a9ef0 and 4e9246d.

📒 Files selected for processing (5)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/SessionPersistence.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
✅ Files skipped from review due to trivial changes (1)
  • Sources/ContentView.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • Sources/TabManager.swift
  • Sources/Workspace.swift

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/AppDelegate.swift (1)

4881-4908: ⚠️ Potential issue | 🟠 Major

Reinstall the close observer when a context is rebound to a new window.

Only the created branch attaches NSWindow.willCloseNotification. If this path reuses an existing AppDelegate.MainWindowContext for a different NSWindow, the new window never unregisters, while the old window can still unregister the reused context via the later windowId fallback. That leaves a live main window without a context and breaks follow-on session/save routing.

Possible fix
     private final class MainWindowContext {
         let windowId: UUID
         let tabManager: TabManager
         let sidebarState: SidebarState
         let sidebarSelectionState: SidebarSelectionState
         weak var window: NSWindow?
         var sessionSnapshotWindowObservers: [NSObjectProtocol] = []
+        var windowCloseObserver: NSObjectProtocol?

         init(
             windowId: UUID,
             tabManager: TabManager,
             sidebarState: SidebarState,
@@
         deinit {
             sessionSnapshotWindowObservers.forEach(NotificationCenter.default.removeObserver)
+            if let windowCloseObserver {
+                NotificationCenter.default.removeObserver(windowCloseObserver)
+            }
         }
     }
+    private func installMainWindowCloseObserver(for context: MainWindowContext, window: NSWindow) {
+        if let windowCloseObserver = context.windowCloseObserver {
+            NotificationCenter.default.removeObserver(windowCloseObserver)
+        }
+        context.windowCloseObserver = NotificationCenter.default.addObserver(
+            forName: NSWindow.willCloseNotification,
+            object: window,
+            queue: .main
+        ) { [weak self] note in
+            guard let self, let closing = note.object as? NSWindow else { return }
+            self.unregisterMainWindow(closing)
+        }
+    }
-            NotificationCenter.default.addObserver(
-                forName: NSWindow.willCloseNotification,
-                object: window,
-                queue: .main
-            ) { [weak self] note in
-                guard let self, let closing = note.object as? NSWindow else { return }
-                self.unregisterMainWindow(closing)
-            }
         }
         installSessionSnapshotWindowObservers(for: context, window: window)
+        installMainWindowCloseObserver(for: context, window: window)

Based on learnings: during XCTest launch stabilization, a fallback main window can later be replaced by the real WindowGroup window.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 4881 - 4908, The close-notification
observer is only added in the created branch, so when an existing
MainWindowContext is rebound to a new NSWindow (in the mainWindowContexts[key]
or windowId branches) the new window never gets an
NSWindow.willCloseNotification observer and the old window may later unregister
the reused context; fix by registering the same close observer in both places
where you set existing.window = window (both the key-hit branch and the windowId
fallback branch), using the same pattern as the created branch
(NotificationCenter.default.addObserver with [weak self], guard to cast
note.object to NSWindow, and calling unregisterMainWindow(closing)); ensure you
bind the observer to the current window object and keep
installSessionSnapshotWindowObservers(for: context, window: window) after
registration so session/save routing remains correct.
🧹 Nitpick comments (1)
cmuxTests/SessionPersistenceTests.swift (1)

527-533: Consider adding the equality case when allowDelayExtension is true.

You already pin equality for allowDelayExtension: false; adding the symmetric true case would lock idempotent behavior more explicitly.

Suggested test addition
+    func testSessionAutosaveScheduleKeepsWhenDeadlinesAreEqualWithDelayExtensionEnabled() {
+        XCTAssertTrue(
+            AppDelegate.shouldKeepExistingSessionAutosaveSchedule(
+                allowDelayExtension: true,
+                existingDeadlineUptime: 200,
+                targetDeadlineUptime: 200
+            )
+        )
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/SessionPersistenceTests.swift` around lines 527 - 533, Add a
symmetric assertion that verifies idempotent behavior when allowDelayExtension
is true: in the testSessionAutosaveScheduleKeepsWhenDeadlinesAreEqual test add a
call to AppDelegate.shouldKeepExistingSessionAutosaveSchedule with
allowDelayExtension: true and both existingDeadlineUptime and
targetDeadlineUptime set to the same value (e.g., 200) and assert true, so the
equality case is covered for both allowDelayExtension branches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 4699-4710: The synchronous quit path currently calls writeBlock()
directly and bypasses the serial sessionPersistenceQueue, allowing concurrent
async persistence tasks to later overwrite or delete the final snapshot; change
the synchronous branch so it dispatches writeBlock() synchronously onto
sessionPersistenceQueue (e.g., using sessionPersistenceQueue.sync) and only then
invoke completion (ensuring completion is called on the MainActor if the
existing Task { `@MainActor` in completion() } pattern is desired) so the final
save is serialized with other persistence operations—apply this change to the
code paths where synchronously is checked (used during
applicationWillTerminate(_:)) referencing writeBlock, sessionPersistenceQueue,
synchronously, and completion.

---

Outside diff comments:
In `@Sources/AppDelegate.swift`:
- Around line 4881-4908: The close-notification observer is only added in the
created branch, so when an existing MainWindowContext is rebound to a new
NSWindow (in the mainWindowContexts[key] or windowId branches) the new window
never gets an NSWindow.willCloseNotification observer and the old window may
later unregister the reused context; fix by registering the same close observer
in both places where you set existing.window = window (both the key-hit branch
and the windowId fallback branch), using the same pattern as the created branch
(NotificationCenter.default.addObserver with [weak self], guard to cast
note.object to NSWindow, and calling unregisterMainWindow(closing)); ensure you
bind the observer to the current window object and keep
installSessionSnapshotWindowObservers(for: context, window: window) after
registration so session/save routing remains correct.

---

Nitpick comments:
In `@cmuxTests/SessionPersistenceTests.swift`:
- Around line 527-533: Add a symmetric assertion that verifies idempotent
behavior when allowDelayExtension is true: in the
testSessionAutosaveScheduleKeepsWhenDeadlinesAreEqual test add a call to
AppDelegate.shouldKeepExistingSessionAutosaveSchedule with allowDelayExtension:
true and both existingDeadlineUptime and targetDeadlineUptime set to the same
value (e.g., 200) and assert true, so the equality case is covered for both
allowDelayExtension branches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d57f399-6063-4dba-b78b-fe0884792322

📥 Commits

Reviewing files that changed from the base of the PR and between 4e9246d and d480359.

📒 Files selected for processing (2)
  • Sources/AppDelegate.swift
  • cmuxTests/SessionPersistenceTests.swift

Comment thread Sources/AppDelegate.swift
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