native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add canvas exit wake diagnostics

onevcat 5fe7dbc8 32f51451

+312
+179
doc-onevcat/canvas-exit-terminal-blank-tracking.md
··· 1 + # Canvas Exit Terminal Blank Tracking 2 + 3 + Last updated: 2026-04-11 4 + Status: Open, intermittent, likely long-lived native state issue 5 + 6 + ## Symptom 7 + 8 + When leaving Canvas and returning to the normal worktree terminal view, the terminal area can appear blank. 9 + 10 + Typical behavior: 11 + 12 + - The normal terminal view is visible in SwiftUI. 13 + - The selected worktree and tab are correct. 14 + - The terminal becomes visible again only after switching to another tab and back. 15 + 16 + ## Reproduction Profile 17 + 18 + Current evidence suggests this is not a fresh-session deterministic bug. 19 + 20 + - In a newly launched Prowl session, the bug is difficult to reproduce. 21 + - After the app has been running for a long time, especially across system sleep/wake, the bug may start happening. 22 + - Once it starts happening in a given app session, it tends to reproduce reliably on every Canvas exit until the app is restarted. 23 + 24 + This strongly suggests a sticky stale state in the long-lived terminal/native view stack rather than a simple `toggleCanvas` reducer bug. 25 + 26 + ## Relevant Areas 27 + 28 + - `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` 29 + - `supacode/Features/App/Reducer/AppFeature.swift` 30 + - `supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift` 31 + - `supacode/Features/Terminal/Models/WorktreeTerminalState.swift` 32 + - `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift` 33 + - `supacode/Features/Terminal/Views/WindowFocusObserverView.swift` 34 + - `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` 35 + - `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` 36 + - `supacode/Features/Canvas/Views/CanvasView.swift` 37 + 38 + ## Repair History 39 + 40 + Known commits that attempted to address this family of issues: 41 + 42 + - `161f38a0` Fix blank surface when exiting canvas via toggle shortcut 43 + - `516103e4` fix: invalidate occlusion cache when exiting canvas to prevent blank surfaces 44 + - `11e7d16c` Fix occlusion cache invalidation on surface reattachment 45 + - `7ed53813` fix: defer occlusion apply until surface is attached 46 + - `26273089` fix: simplify canvas exit occlusion handling 47 + - `d4e59155` Add canvas exit terminal diagnostics and occlusion refresh 48 + - `e2a29b2c` test: cover Ghostty attachment occlusion behavior 49 + - `32f51451` Fix canvas exit terminal occlusion recovery 50 + 51 + Relevant changelog entries: 52 + 53 + - `2026.4.2`: occlusion restored whenever a surface is reattached 54 + - `2026.4.5`: surface state refreshed immediately on Canvas exit 55 + - current unreleased `main`: occlusion recovery also resumes from `updateSurfaceSize()` 56 + 57 + ## Current Working Theory 58 + 59 + The highest-probability root cause is a stale native terminal surface state after reparenting, likely amplified by long app lifetime and sleep/wake transitions. 60 + 61 + Current best hypothesis: 62 + 63 + - Exiting Canvas causes `GhosttySurfaceView` to be reattached into the normal terminal hierarchy. 64 + - Reparenting invalidates the occlusion-applied cache. 65 + - In some sessions, especially after sleep/wake, the expected "surface is ready again, now reapply visible occlusion" chain does not complete reliably. 66 + - SwiftUI has already switched back to the normal terminal view, but Ghostty's renderer remains effectively paused or not fully resumed for that surface. 67 + - Switching tabs forces another round of visibility/focus activity, which recovers the surface. 68 + 69 + What is less likely at this point: 70 + 71 + - Wrong worktree selection 72 + - Wrong selected tab restoration 73 + - Basic reducer ordering bug in `toggleCanvas` 74 + 75 + Those paths have been observed as correct in diagnostic logs while the surface still remained blank. 76 + 77 + ## Logs Added For Ongoing Investigation 78 + 79 + Two log markers should be collected together: 80 + 81 + - `[CanvasExit]` 82 + - `[TerminalWake]` 83 + 84 + ### `[CanvasExit]` 85 + 86 + Existing and previous diagnostics already cover: 87 + 88 + - `WorktreeTerminalTabsView.onAppear` 89 + - surface attachment changes 90 + - deferred occlusion 91 + - reapply occlusion 92 + - selected worktree transition when leaving Canvas 93 + 94 + ### `[TerminalWake]` 95 + 96 + Added on 2026-04-11 to correlate future failures with sleep/wake and long-lived surface state: 97 + 98 + - `GhosttyRuntime` 99 + - `workspaceWillSleep` 100 + - `workspaceDidWake` 101 + - `screensDidSleep` 102 + - `screensDidWake` 103 + - runtime surface count 104 + - `GhosttySurfaceView` 105 + - per-surface state snapshot on workspace sleep/wake 106 + - per-surface state snapshot on `viewDidMoveToWindow` 107 + - per-surface state snapshot on `viewDidMoveToSuperview` 108 + - `WindowFocusObserverView` 109 + - window activity changes (`key`, `visible`, `force`, `windowNumber`) 110 + 111 + Per-surface wake logs include: 112 + 113 + - `surface` 114 + - `hasSurface` 115 + - `attached` 116 + - `window` 117 + - `desired` 118 + - `focused` 119 + - `firstResponder` 120 + - `bounds` 121 + - `backing` 122 + - `windowVisible` 123 + - `windowKey` 124 + 125 + ## How To Collect Logs 126 + 127 + Use `make log-stream`, then reproduce the issue and save the section covering: 128 + 129 + - the last successful Canvas exit before the bug starts 130 + - the first failed Canvas exit after the bug starts 131 + - any sleep/wake events before that failure 132 + 133 + If filtering manually, focus on lines containing: 134 + 135 + - `[CanvasExit]` 136 + - `[TerminalWake]` 137 + 138 + If using `log stream` directly, a useful predicate is: 139 + 140 + ```bash 141 + log stream --style compact \ 142 + --predicate 'subsystem == "com.onevcat.prowl" && (eventMessage CONTAINS[c] "[CanvasExit]" || eventMessage CONTAINS[c] "[TerminalWake]")' 143 + ``` 144 + 145 + ## What To Compare Next Time 146 + 147 + When the bug reproduces again, compare a healthy exit and a broken exit for: 148 + 149 + - whether `workspaceDidWake` or `screensDidWake` happened shortly before failures started 150 + - whether the affected surface reports `desired=Optional(true)` but never logs `reapplyOcclusion` 151 + - whether `WindowFocusObserverView` still reports the window as visible/key when the blank terminal is shown 152 + - whether the affected surface is attached to a superview and window but still does not recover 153 + - whether `bounds` and `backing` stop changing for the affected surface while the view is visibly present 154 + 155 + ## Open Questions 156 + 157 + - Is sleep/wake the true trigger, or just the most common way to enter the stale state? 158 + - Is the bad state owned by `GhosttySurfaceView`, underlying `ghostty_surface_t`, or AppKit/Metal attachment? 159 + - Does the failure always affect the same surface instance for a worktree, or any surface after the session becomes "poisoned"? 160 + - Would an explicit post-wake surface refresh solve the actual root cause, or only mask a lower-level Ghostty/AppKit lifecycle issue? 161 + 162 + ## Next Step Candidates 163 + 164 + Do not do these preemptively unless new logs support them: 165 + 166 + - add explicit post-wake repair for all active surfaces 167 + - force-resend occlusion and size after wake 168 + - force content-scale/display-id refresh after wake 169 + - invalidate more cached state after wake, not only after attachment changes 170 + 171 + ## Notes 172 + 173 + This document should be updated every time: 174 + 175 + - a new hypothesis is formed 176 + - a new instrumentation point is added 177 + - a repro pattern changes 178 + - a candidate fix is attempted 179 + - a failed fix is ruled out
+7
supacode/Features/Terminal/Views/WindowFocusObserverView.swift
··· 1 1 import AppKit 2 2 import SwiftUI 3 3 4 + private let windowFocusLogger = SupaLogger("WindowFocus") 5 + 4 6 struct WindowActivityState: Equatable { 5 7 let isKeyWindow: Bool 6 8 let isVisible: Bool ··· 92 94 return 93 95 } 94 96 lastEmittedActivity = activity 97 + windowFocusLogger.info( 98 + "[TerminalWake] activityChanged key=\(activity.isKeyWindow) " 99 + + "visible=\(activity.isVisible) force=\(force) " 100 + + "windowNumber=\(window?.windowNumber ?? -1)" 101 + ) 95 102 onWindowActivityChanged(activity) 96 103 } 97 104
+54
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 95 95 ghostty_app_keyboard_changed(app) 96 96 } 97 97 }) 98 + 99 + let workspaceCenter = NSWorkspace.shared.notificationCenter 100 + observers.append( 101 + workspaceCenter.addObserver( 102 + forName: NSWorkspace.willSleepNotification, 103 + object: nil, 104 + queue: .main 105 + ) { [weak self] _ in 106 + MainActor.assumeIsolated { 107 + self?.logLifecycleEvent("workspaceWillSleep") 108 + } 109 + }) 110 + observers.append( 111 + workspaceCenter.addObserver( 112 + forName: NSWorkspace.didWakeNotification, 113 + object: nil, 114 + queue: .main 115 + ) { [weak self] _ in 116 + MainActor.assumeIsolated { 117 + self?.logLifecycleEvent("workspaceDidWake") 118 + } 119 + }) 120 + observers.append( 121 + workspaceCenter.addObserver( 122 + forName: NSWorkspace.screensDidSleepNotification, 123 + object: nil, 124 + queue: .main 125 + ) { [weak self] _ in 126 + MainActor.assumeIsolated { 127 + self?.logLifecycleEvent("screensDidSleep") 128 + } 129 + }) 130 + observers.append( 131 + workspaceCenter.addObserver( 132 + forName: NSWorkspace.screensDidWakeNotification, 133 + object: nil, 134 + queue: .main 135 + ) { [weak self] _ in 136 + MainActor.assumeIsolated { 137 + self?.logLifecycleEvent("screensDidWake") 138 + } 139 + }) 98 140 } 99 141 100 142 isolated deinit { ··· 120 162 if let app { 121 163 ghostty_app_tick(app) 122 164 } 165 + } 166 + 167 + private func logLifecycleEvent(_ event: String) { 168 + let validSurfaceCount = surfaceRefs.reduce(into: 0) { count, ref in 169 + if ref.isValid { 170 + count += 1 171 + } 172 + } 173 + ghosttyLogger.info( 174 + "[TerminalWake] event=\(event) appActive=\(NSApp.isActive) " 175 + + "windows=\(NSApp.windows.count) runtimeSurfaces=\(validSurfaceCount)" 176 + ) 123 177 } 124 178 125 179 func setColorScheme(_ scheme: ColorScheme) {
+72
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 121 121 private var lastSurfaceFocus: Bool? 122 122 private var eventMonitor: Any? 123 123 private var notificationObservers: [NSObjectProtocol] = [] 124 + private var workspaceObservers: [NSObjectProtocol] = [] 124 125 private var prevPressureStage: Int = 0 125 126 private var isBackgroundOpaqueOverride = false 126 127 private lazy var cachedScreenContents = CachedValue<String>(duration: .milliseconds(500)) { ··· 247 248 } 248 249 } 249 250 registerForDraggedTypes(Array(Self.dropTypes)) 251 + registerWorkspaceObservers() 250 252 251 253 eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyUp, .leftMouseDown]) { 252 254 [weak self] event in ··· 263 265 NSEvent.removeMonitor(eventMonitor) 264 266 } 265 267 clearNotificationObservers() 268 + clearWorkspaceObservers() 266 269 let id = ObjectIdentifier(self) 267 270 MainActor.assumeIsolated { 268 271 SecureInput.shared.removeScoped(id) ··· 357 360 }) 358 361 } 359 362 363 + private func registerWorkspaceObservers() { 364 + let center = NSWorkspace.shared.notificationCenter 365 + workspaceObservers.append( 366 + center.addObserver( 367 + forName: NSWorkspace.willSleepNotification, 368 + object: nil, 369 + queue: .main 370 + ) { [weak self] _ in 371 + Task { @MainActor [weak self] in 372 + self?.logLifecycleState("workspaceWillSleep") 373 + } 374 + }) 375 + workspaceObservers.append( 376 + center.addObserver( 377 + forName: NSWorkspace.didWakeNotification, 378 + object: nil, 379 + queue: .main 380 + ) { [weak self] _ in 381 + Task { @MainActor [weak self] in 382 + self?.logLifecycleState("workspaceDidWake") 383 + } 384 + }) 385 + workspaceObservers.append( 386 + center.addObserver( 387 + forName: NSWorkspace.screensDidSleepNotification, 388 + object: nil, 389 + queue: .main 390 + ) { [weak self] _ in 391 + Task { @MainActor [weak self] in 392 + self?.logLifecycleState("screensDidSleep") 393 + } 394 + }) 395 + workspaceObservers.append( 396 + center.addObserver( 397 + forName: NSWorkspace.screensDidWakeNotification, 398 + object: nil, 399 + queue: .main 400 + ) { [weak self] _ in 401 + Task { @MainActor [weak self] in 402 + self?.logLifecycleState("screensDidWake") 403 + } 404 + }) 405 + } 406 + 360 407 private func windowDidChangeScreen() { 361 408 guard let surface, let screen = window?.screen else { return } 362 409 let displayID = ··· 375 422 notificationObservers.removeAll() 376 423 } 377 424 425 + private func clearWorkspaceObservers() { 426 + let center = NSWorkspace.shared.notificationCenter 427 + for observer in workspaceObservers { 428 + center.removeObserver(observer) 429 + } 430 + workspaceObservers.removeAll() 431 + } 432 + 378 433 override func viewDidMoveToWindow() { 379 434 super.viewDidMoveToWindow() 380 435 if window == nil { ··· 386 441 updateContentScale() 387 442 updateSurfaceSize() 388 443 applyWindowBackgroundAppearance() 444 + logLifecycleState("viewDidMoveToWindow") 389 445 handleAttachmentChange() 390 446 } 391 447 392 448 override func viewDidMoveToSuperview() { 393 449 super.viewDidMoveToSuperview() 450 + logLifecycleState("viewDidMoveToSuperview") 394 451 handleAttachmentChange() 395 452 } 396 453 ··· 1054 1111 private func resumeDeferredOcclusionIfNeeded() { 1055 1112 guard isReadyToApplyOcclusion else { return } 1056 1113 reapplyOcclusionIfNeeded() 1114 + } 1115 + 1116 + private func logLifecycleState(_ event: String) { 1117 + let windowVisible = window?.occlusionState.contains(.visible) ?? false 1118 + let windowKey = window?.isKeyWindow ?? false 1119 + let firstResponderMatches = window?.firstResponder === self 1120 + surfaceLogger.info( 1121 + "[TerminalWake] event=\(event) surface=\(debugID) hasSurface=\(surface != nil) " 1122 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow) " 1123 + + "desired=\(String(describing: occlusionState.desired)) " 1124 + + "focused=\(focused) firstResponder=\(firstResponderMatches) " 1125 + + "bounds=\(Int(bounds.width))x\(Int(bounds.height)) " 1126 + + "backing=\(Int(lastBackingSize.width))x\(Int(lastBackingSize.height)) " 1127 + + "windowVisible=\(windowVisible) windowKey=\(windowKey)" 1128 + ) 1057 1129 } 1058 1130 1059 1131 private func reapplyOcclusionIfNeeded() {