Skip to content

Fix QuickLook preview crash on deactivated QLPreviewView - #6402

Merged
azooz2003-bit merged 5 commits into
manaflow-ai:mainfrom
thiveeiyan:fix/quicklook-deactivated-preview-crash
Jun 21, 2026
Merged

azooz2003-bit merged 5 commits into
manaflow-ai:mainfrom
thiveeiyan:fix/quicklook-deactivated-preview-crash

Conversation

@thiveeiyan

@thiveeiyan thiveeiyan commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes a fatal QuickLook abort when previewing files. QLPreviewView calls abort() if a non-nil previewItem is assigned after AppKit has deactivated the view (i.e. after it has left the window hierarchy):

[QL] -[QLPreviewView setPreviewItem:blockingUntilLoading:timeoutDate:transition:]:
item == nil || _reserved->internalState != QLPreviewDeactivatedInternalState

This is the still-recurring crash tracked in #4453.

Root cause

FilePreviewQuickLookSession vends a bare QLPreviewView straight to the SwiftUI representable. SwiftUI keeps that NSView mounted across tab switches, visibility toggles, and panel reuse, and hands the same instance back to updateNSView. AppKit deactivates a QLPreviewView whenever it leaves its window — routine in a tabbed terminal — and the next configure() runs previewView.previewItem = … on the now-deactivated view, tripping the assertion and aborting the whole app.

#4453's fix directions #2 (drop close()) and #3 (add dismantleNSView) shipped, and they stop cmux-initiated reuse of a closed view. But they don't cover system-initiated deactivation (the window detach AppKit does on its own), so the abort still fires. This reproduces on macOS 26 (Tahoe) and the QuickLook code is identical across 0.64.14 → 0.64.16 → main.

Evidence

Decoded the minidumps from a user's 35 .ghosttycrash reports — every one is this crash, in two forms of the same QuickLook abort:

  • QuickLookPreviewView.make/updateNSView → QLPreviewView setPreviewItem:blockingUntilLoading:… → _QLRaiseAssert → _QLCrash → abort()
  • _QLDumpMachPortRights (QuickLook's crash handler dumping mach-port rights before the same abort())

Dates run from May through today, on the latest builds.

Fix

Implements #4453's remaining fix direction #1: host the fragile QLPreviewView inside a stable container view (the same pattern already used by the PDF/image sessions) and swap in a fresh preview view once the previous instance has detached from its window. A non-nil preview item is therefore never assigned to a deactivated view, while SwiftUI never has to re-mount the representable. A small QLPreviewView subclass records the window-detach transition since the deactivated state has no public accessor.

Testing

swiftc -parse passes and the new AppKit/Quartz helper classes type-check standalone. I was not able to run a full app build (developed against the crash reports on a machine with only Command Line Tools, which can't parse the macOS 26.5 SDK) — please run it through CI / a local Xcode build before merging. Manual repro to verify: open a QuickLook-previewed file, switch tabs/windows away and back (or close the pane) so the view detaches and re-attaches, which previously aborted.

Refs #4453.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Fu21KvnjR3dAe6sjKaVUw


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes a Quick Look crash by hosting QLPreviewView in a stable container and swapping in a fresh view after window detaches. Stops the abort when switching tabs or panes, resolving #4453.

  • Bug Fixes
    • Added FilePreviewQuickLookContainerView that vends a safe, live QLPreviewView and recreates it after deactivation.
    • Introduced TrackedQLPreviewView to detect window detaches and avoid assigning a non-nil previewItem to a deactivated view.
    • Updated view creation, release, and focus attachment to use the container and clear items safely (never calls close()).
    • Extracted the preview host views and updated Quick Look tests to find nested QLPreviewViews and validate the container-based setup.

Written for commit 0818a42. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced file preview stability by improving preview view lifecycle management to ensure reliable QuickLook functionality across application state transitions.

QLPreviewView aborts the process when a non-nil preview item is assigned
after AppKit has deactivated the view (it leaves the window hierarchy):

    [QL] -[QLPreviewView setPreviewItem:blockingUntilLoading:timeoutDate:transition:]:
    item == nil || _reserved->internalState != QLPreviewDeactivatedInternalState

SwiftUI keeps the representable's NSView mounted across tab switches,
visibility toggles, and panel reuse, then re-runs configure() ->
previewView.previewItem = ... on a view AppKit already deactivated. This
is the still-recurring crash from manaflow-ai#4453: dropping close() and adding
dismantleNSView (its fix directions manaflow-ai#2/manaflow-ai#3) prevented cmux-initiated reuse
but not system-initiated deactivation, so the abort still fires on
macOS 26 (Tahoe).

Implements fix direction manaflow-ai#1: host the QLPreviewView inside a stable
container view (matching the PDF/image session pattern) and swap in a
fresh preview view once the previous instance has detached from its
window, so a non-nil item is never assigned to a deactivated view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Fu21KvnjR3dAe6sjKaVUw
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

@thiveeiyan is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces TrackedQLPreviewView (a QLPreviewView subclass tracking window-detach state) and FilePreviewQuickLookContainerView (a stable host that retires and remounts detached instances). FilePreviewQuickLookSession is updated to create, release, and configure previews through this container instead of directly using QLPreviewView. Both new files are registered in the Xcode project.

QuickLook Container and Session Wiring

Layer / File(s) Summary
TrackedQLPreviewView and FilePreviewQuickLookContainerView
Sources/Panels/TrackedQLPreviewView.swift, Sources/Panels/FilePreviewQuickLookContainerView.swift, cmux.xcodeproj/project.pbxproj
TrackedQLPreviewView sets didDetachFromWindow when moved to a nil window. FilePreviewQuickLookContainerView overrides previewItem to route through livePreviewView(), which reuses the tracked instance when healthy or retires and remounts a fresh one when detached; clearPreviewItem() nils the item without disturbing the container lifecycle. Both files are wired into the Xcode project build phase.
Session lifecycle wired to container
Sources/Panels/FilePreviewQuickLookSession.swift
makeView() returns a FilePreviewQuickLookContainerView; releaseView(_:) calls clearPreviewItem() on the container instead of setting previewItem = nil on a raw QLPreviewView; configure(...) uses container.livePreviewView() as the primaryResponder for QuickLook focus attachment.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#4298: Introduced the FilePreviewQuickLookSession architecture that this PR now extends with a container-based QLPreviewView host.
  • manaflow-ai/cmux#4459: Modifies releaseView/previewItem teardown behavior in FilePreviewQuickLookSession, overlapping directly with clearPreviewItem semantics introduced here.
  • manaflow-ai/cmux#4460: Changes QuickLook lifecycle handling around detach and remount in FilePreviewQuickLookSession, closely related to the tracked-detach strategy introduced by TrackedQLPreviewView.

Poem

🐇 A preview that crashed when its window was gone,
Now tracked by a flag — "detached" carries on.
The container stands firm, swaps the old for the new,
No fatal assertion to ruin your view.
Hop, hop — stable QuickLook the whole session through! ✨

🚥 Pre-merge checks | ✅ 22 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (22 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing a QuickLook preview crash on deactivated QLPreviewView instances.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New classes FilePreviewQuickLookContainerView and TrackedQLPreviewView are NSView subclasses inheriting MainActor isolation from QLPreviewView base class. No Sendable violations or shared mutable r...
Cmux Swift Blocking Runtime ✅ Passed PR contains no blocking runtime primitives (semaphores, sleep calls, DispatchQueue.main.sync, polling, etc.). Uses @MainActor for thread safety and responds to AppKit events (window detachment) via...
Cmux Expensive Synchronous Load ✅ Passed PR adds only lightweight UI operations (view creation, hierarchy management, property assignment) to the main-actor FilePreviewQuickLookSession; no expensive synchronous loaders like disk reads, sy...
Cmux Cache Substitution Correctness ✅ Passed PR changes involve only transient UI view caching with no persistence, history, undo, or snapshot paths affected. The cached previewView is recreated on window detachment, and the item is fresh...
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift and project configuration files; the rule applies only to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts, with Swift covered separately.
Cmux Algorithmic Complexity ✅ Passed Code uses O(1) hash-table operations on bounded-size liveViews collection (one view per preview panel); no nested full-collection scans, no per-target rescans, no rebuilds in hot paths.
Cmux Swift Concurrency ✅ Passed No legacy async patterns introduced. Code uses only @MainActor, AppKit-required callbacks (viewDidMoveToWindow), and synchronous property management for QuickLook view lifecycle.
Cmux Swift @Concurrent ✅ Passed All three modified/new Swift files contain only synchronous UI view management code. FilePreviewQuickLookSession is correctly marked @MainActor. No async/await, no @concurrent annotations, no viola...
Cmux Swift File And Package Boundaries ✅ Passed Focused bug fix adding 76+24=100 lines in two small, single-responsibility AppKit/Quartz glue files (both under 400-line threshold) and 8 lines to a 124-line existing file. Matches "small UI/AppKit...
Cmux Swiftpm Lockfiles ✅ Passed PR adds only Swift source files and build references to pbxproj, with no SwiftPM dependency changes, .gitignore modifications, or Package.resolved updates required.
Cmux Swift Logging ✅ Passed No logging violations found. The three Swift files (FilePreviewQuickLookSession, FilePreviewQuickLookContainerView, TrackedQLPreviewView) contain zero instances of print, NSLog, debugPrint, dump, L...
Cmux User-Facing Error Privacy ✅ Passed PR contains only internal implementation code with developer-only comments; no user-facing error messages, alerts, credentials, or upstream vendor details are exposed to end users.
Cmux Full Internationalization ✅ Passed PR contains only internal Swift/AppKit infrastructure code with developer-only documentation comments. No user-facing text, localization APIs, string catalogs, or web UI changes introduced.
Cmux Swiftui State Layout ✅ Passed All changes are pure AppKit/QuickLook bridge classes with no SwiftUI state management, property wrappers, GeometryReader, lazy collections, or render-time mutations. Complies with allowed AppKit br...
Cmux Architecture Rethink ✅ Passed PR follows established patterns (PDF/Image sessions), implements a small correctness fix with clear invariants, uses required AppKit platform bridge (viewDidMoveToWindow), and introduces zero timin...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed The PR adds only NSView subclasses (FilePreviewQuickLookContainerView and TrackedQLPreviewView), not standalone NSWindow/NSPanel/NSWindowController or SwiftUI Window/WindowGroup. These view classes...
Cmux Source Artifacts ✅ Passed All PR files are intentional hand-written source code or required build configuration; no source control artifacts (logs, generated files, caches, temp folders) are present.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No test/debug seams found in production Swift files. All new internal properties and methods are legitimate product functionality for the QuickLook crash fix with no #if DEBUG blocks or test-naming...
Description check ✅ Passed PR description comprehensively covers what changed, why it was needed, root cause analysis, evidence from crash reports, and testing approach, matching template sections despite not explicitly following markdown headings.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@greptile-apps

greptile-apps Bot commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the recurring QuickLook abort (#4453) by wrapping the fragile QLPreviewView inside a stable FilePreviewQuickLookContainerView that SwiftUI holds across tab/window lifecycle events. A TrackedQLPreviewView subclass detects window-detach (the trigger for QL's internal deactivation) with a monotonic flag, and the container retires any deactivated instance — setting its previewItem to nil and removing it as a subview — before mounting a fresh view, so a non-nil item is never assigned to a deactivated view.

  • New TrackedQLPreviewView records didDetachFromWindow permanently on the first viewDidMoveToWindow call where window == nil; the flag intentionally never resets since QL deactivation is irreversible.
  • FilePreviewQuickLookContainerView overrides previewItem to proxy through the inner view, calling livePreviewView() to guarantee a live instance before any non-nil assignment.
  • FilePreviewQuickLookSession is updated to use the container in makeView, releaseView, and configure, where livePreviewView() is called directly so the inner view reference can be passed as primaryResponder to attachPreviewFocus.

Confidence Score: 5/5

The crash fix is sound — a non-nil preview item is never assigned to a deactivated QLPreviewView on any code path through the new container.

The core lifecycle invariant is correctly maintained: livePreviewView() always returns either an existing non-deactivated TrackedQLPreviewView or a freshly created one, and the stale view's item is cleared (nil assignment passes QL's own assertion guard) before it's removed. The monotonic didDetachFromWindow flag correctly models QL's irreversible deactivation. The only concern is that the container itself inherits from QLPreviewView when NSView would be the cleaner and safer base class, but this does not affect correctness of the fix as written.

FilePreviewQuickLookContainerView.swift — the container's QLPreviewView base class is unnecessary and adds QL lifecycle overhead to the stable host view.

Important Files Changed

Filename Overview
Sources/Panels/TrackedQLPreviewView.swift New QLPreviewView subclass that records window detachment; correctly uses a monotonic flag since QL deactivation is irreversible.
Sources/Panels/FilePreviewQuickLookContainerView.swift Stable container for TrackedQLPreviewView; correctly prevents the deactivated-view crash, but inherits from QLPreviewView rather than NSView, giving it unnecessary QL lifecycle overhead and deactivation machinery.
Sources/Panels/FilePreviewQuickLookSession.swift Correctly threads the container through makeView/releaseView/configure; configure() calls livePreviewView() directly for the attachPreviewFocus root/responder split, which is sound.
cmux.xcodeproj/project.pbxproj Registers both new Swift files in the app target's Sources build phase; GUIDs are syntactically correct and unique in the diff.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant SwiftUI
    participant Container as FilePreviewQuickLookContainerView
    participant Tracked as TrackedQLPreviewView
    participant QL as QuickLook (QLPreviewView internals)

    SwiftUI->>Container: makeNSView → make()
    Container->>Tracked: livePreviewView() creates fresh inner view
    Tracked-->>Container: addSubview(fresh)
    Container-->>SwiftUI: return container

    Note over Container,Tracked: Tab switch / window detach
    QL->>Tracked: "viewDidMoveToWindow() [window == nil]"
    Tracked->>Tracked: "didDetachFromWindow = true (monotonic)"

    SwiftUI->>Container: updateNSView → configure()
    Container->>Container: livePreviewView()
    Container->>Tracked: "stale.previewItem = nil (safe: nil branch)"
    Container->>Tracked: stale.removeFromSuperview()
    Container->>Tracked: create fresh TrackedQLPreviewView
    Tracked-->>Container: addSubview(fresh)
    Container->>Tracked: "fresh.previewItem = previewItem (safe: fresh, active)"
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant SwiftUI
    participant Container as FilePreviewQuickLookContainerView
    participant Tracked as TrackedQLPreviewView
    participant QL as QuickLook (QLPreviewView internals)

    SwiftUI->>Container: makeNSView → make()
    Container->>Tracked: livePreviewView() creates fresh inner view
    Tracked-->>Container: addSubview(fresh)
    Container-->>SwiftUI: return container

    Note over Container,Tracked: Tab switch / window detach
    QL->>Tracked: "viewDidMoveToWindow() [window == nil]"
    Tracked->>Tracked: "didDetachFromWindow = true (monotonic)"

    SwiftUI->>Container: updateNSView → configure()
    Container->>Container: livePreviewView()
    Container->>Tracked: "stale.previewItem = nil (safe: nil branch)"
    Container->>Tracked: stale.removeFromSuperview()
    Container->>Tracked: create fresh TrackedQLPreviewView
    Tracked-->>Container: addSubview(fresh)
    Container->>Tracked: "fresh.previewItem = previewItem (safe: fresh, active)"
Loading

Reviews (5): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +64 to +85
func livePreviewView() -> QLPreviewView? {
if let previewView, !previewView.didDetachFromWindow {
return previewView
}

// Retire a deactivated instance before mounting a fresh one. Assigning
// `nil` is always safe (the assertion's `item == nil` branch holds).
if let stale = previewView {
stale.previewItem = nil
stale.removeFromSuperview()
}
previewView = nil

guard let fresh = TrackedQLPreviewView(frame: bounds, style: .normal) else {
return nil
}
fresh.autostarts = true
fresh.autoresizingMask = [.width, .height]
addSubview(fresh)
previewView = fresh
return fresh
}

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.

P2 clearPreviewItem() leaves a deactivated inner view as a subview of the container

clearPreviewItem() reads previewView?.previewItem = nil directly. If a deactivated view is the current previewView at the time releaseView() is called (i.e., the detach happened but configure() hasn't run yet to replace it), the container's previewView property still points to the stale TrackedQLPreviewView. clearPreviewItem() safely sets previewItem = nil on it (nil assignment passes the QL assertion), but the stale view remains as a subview of the container until ARC deallocates the container.

This is harmless for the current lifecycle (container is removed from its superview immediately after by view.removeFromSuperview()), but a removeFromSuperview() call on the stale inner view inside clearPreviewItem() would make the cleanup explicit and match the retirement logic in livePreviewView().

Comment on lines +33 to +43
private final class TrackedQLPreviewView: QLPreviewView {
private(set) var didDetachFromWindow = false

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
// `viewDidMoveToWindow` fires both on attach (window != nil) and detach
// (window == nil). Only the detach transition deactivates the view.
if window == nil {
didDetachFromWindow = true
}
}

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.

P2 didDetachFromWindow is monotonic — re-attaching to a window does not reset it

This is intentional and correctly documented: once QL deactivates a view, the deactivated internal state is permanent, so the flag should never flip back. Worth making this invariant explicit in the doc comment (e.g., "once set, this flag is never cleared; a deactivated view cannot be reactivated") so future maintainers don't add a didDetachFromWindow = false reset in the attach branch of viewDidMoveToWindow, which would silently re-introduce the crash.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@azooz2003-bit

Copy link
Copy Markdown
Collaborator

Maintainer verification pass:

  • Sentry representative event 4f42c41ca4104af1ae2ad9f4b7d3377d in https://manaflow.sentry.io/issues/7523655264/ crashes in FilePreviewQuickLookSession.configure when QLPreviewView.setPreviewItem(...) hits _reserved->internalState != QLPreviewDeactivatedInternalState.
  • Local isolated repro: bare QLPreviewView mounted in a window, assigned a preview item, detached, then assigned a non-nil item again aborts with _QLRaiseAssert / _QLCrash and exits 134.
  • Same repro using this PR's stable container + tracked replacement pattern survives: fixed survived firstDetached=true freshDetached=false, exit 0.
  • Pushed 3ca604a41b3d60a982b66bbe0e527be525d9c249 to update stale Quick Look tests that assumed the representable root was itself a QLPreviewView; the runtime now intentionally returns a stable container.
  • Focused verification passed locally:
    xcodebuild test -project cmux.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-ql6402-test -only-testing:cmuxTests/FilePreviewReviewFeedbackTests/testQuickLookSessionCloseDoesNotDeactivateMountedRepresentableView -only-testing:cmuxTests/FilePreviewReviewFeedbackTests/testQuickLookSessionDismantlingRetiredViewDoesNotResetActivePreviewItem -only-testing:cmuxTests/PanelOwnedNativeViewSessionTests/testQuickLookSessionCreatesFreshViewForEachRepresentableMount

Conclusion: the fix matches the Sentry crash path and prevents the concrete AppKit assertion repro. Remaining PR blocker is external to this crash fix: fork Vercel deploy authorization is required for Vercel – cmux and Vercel – cmux-staging; CodeRabbit/Greptile are rerunning on the test-only follow-up commit.

@vercel

vercel Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 21, 2026 3:18am

This branch was successfully deployed

1 active deployment
Preview – cmux — 0818a42e Deployed Jun 21, 2026 by vercel[bot]
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