native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #201 from onevcat/fix/canvas-exit-surface-reattach

Fix canvas exit terminal host reattachment

authored by

Wei Wang and committed by
GitHub
51a76169 41240300

+227 -25
+60 -21
doc-onevcat/canvas-exit-terminal-blank-tracking.md
··· 1 1 # Canvas Exit Terminal Blank Tracking 2 2 3 - Last updated: 2026-04-11 4 - Status: Open, intermittent, likely long-lived native state issue 3 + Last updated: 2026-04-15 4 + Status: Open, intermittent, narrowed from occlusion-only suspicion to host reattachment failure 5 5 6 6 ## Symptom 7 7 ··· 32 32 - `supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift` 33 33 - `supacode/Features/Terminal/Views/WindowFocusObserverView.swift` 34 34 - `supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift` 35 + - `supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift` 35 36 - `supacode/Infrastructure/Ghostty/GhosttyRuntime.swift` 36 37 - `supacode/Features/Canvas/Views/CanvasView.swift` 37 38 ··· 47 48 - `d4e59155` Add canvas exit terminal diagnostics and occlusion refresh 48 49 - `e2a29b2c` test: cover Ghostty attachment occlusion behavior 49 50 - `32f51451` Fix canvas exit terminal occlusion recovery 51 + - current branch `fix/canvas-exit-surface-reattach` 52 + - add detach-intent stack logging before `GhosttySurfaceView` loses its superview/window 53 + - add wrapper host diagnostics (`hostKind`, wrapper id, surface id) 54 + - add terminal-only defensive reattach in `GhosttySurfaceScrollView.ensureSurfaceAttached()` 55 + - add focused tests for terminal-vs-canvas wrapper ownership behavior 50 56 51 57 Relevant changelog entries: 52 58 ··· 56 62 57 63 ## Current Working Theory 58 64 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. 65 + The highest-probability root cause is no longer "occlusion state got stale while the surface stayed attached". 60 66 61 - Current best hypothesis: 67 + New evidence from a reduced two-tab repro points to a more concrete failure mode: 62 68 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. 69 + - the selected surface (`desired=Optional(true)`, focused, first responder) is briefly attached during Canvas exit 70 + - it reaches the normal terminal layout size 71 + - it is later detached (`attached=false window=false`) 72 + - no subsequent log shows that surface reattached to the final terminal host 73 + 74 + This suggests the blank terminal is caused by host ownership loss: 68 75 69 - What is less likely at this point: 76 + - SwiftUI/AppKit reparenting during Canvas teardown temporarily moves the `GhosttySurfaceView` 77 + - a later teardown or host rebuild removes the surface from the active view tree 78 + - the normal terminal host does not currently guarantee that its `documentView` still owns the surface after updates 79 + - once detached, occlusion recovery is irrelevant because there is no live host left to present the surface 70 80 71 - - Wrong worktree selection 72 - - Wrong selected tab restoration 73 - - Basic reducer ordering bug in `toggleCanvas` 81 + What now looks less likely: 74 82 75 - Those paths have been observed as correct in diagnostic logs while the surface still remained blank. 83 + - wrong worktree selection 84 + - wrong selected tab restoration 85 + - pure reducer ordering bug in `toggleCanvas` 86 + - occlusion cache invalidation as the sole root cause 76 87 77 88 ## Logs Added For Ongoing Investigation 78 89 ··· 86 97 Existing and previous diagnostics already cover: 87 98 88 99 - `WorktreeTerminalTabsView.onAppear` 100 + - `WorktreeTerminalTabsView.onDisappear` 89 101 - surface attachment changes 90 102 - deferred occlusion 91 103 - reapply occlusion 92 104 - selected worktree transition when leaving Canvas 105 + - surface detach intent (`viewWillMove(toSuperview:/toWindow:)`) with call stack 106 + - host wrapper lifecycle (`hostMake`, `hostInit`, `hostUpdate`, `hostDeinit`, `hostReattach`) 107 + - host reattach completion snapshot (`hostReattachComplete`) 108 + - per-surface host metadata (`hostKind`, wrapper id) 93 109 94 110 ### `[TerminalWake]` 95 111 ··· 105 121 - per-surface state snapshot on workspace sleep/wake 106 122 - per-surface state snapshot on `viewDidMoveToWindow` 107 123 - per-surface state snapshot on `viewDidMoveToSuperview` 124 + - detach-time safety-net request back to the last known terminal host 108 125 - `WindowFocusObserverView` 109 126 - window activity changes (`key`, `visible`, `force`, `windowNumber`) 110 127 ··· 147 164 When the bug reproduces again, compare a healthy exit and a broken exit for: 148 165 149 166 - whether `workspaceDidWake` or `screensDidWake` happened shortly before failures started 150 - - whether the affected surface reports `desired=Optional(true)` but never logs `reapplyOcclusion` 167 + - whether the affected surface logs `detachIntent` before going blank, and which stack removes it 168 + - whether a terminal host logs `hostUpdate` but never `hostReattach` for the affected surface 169 + - whether the terminal host does log `hostReattach`, but the surface still remains blank afterward 151 170 - 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 171 155 172 ## Open Questions 156 173 157 174 - 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? 175 + - Which host teardown path actually performs the final detach: Canvas wrapper cleanup, terminal wrapper replacement, or another AppKit rebuild? 176 + - If terminal-side defensive reattach works, is it sufficient as the durable fix or just masking a lower-level host lifecycle race? 177 + - If reattach does not work, is the detached native view still valid, or do we need to recreate the underlying `ghostty_surface_t`? 161 178 162 179 ## Next Step Candidates 163 180 164 181 Do not do these preemptively unless new logs support them: 165 182 183 + - widen defensive reattach beyond the normal terminal host 184 + - recreate a surface when host reattach fails 166 185 - add explicit post-wake repair for all active surfaces 167 186 - force-resend occlusion and size after wake 168 187 - force content-scale/display-id refresh after wake 169 - - invalidate more cached state after wake, not only after attachment changes 170 188 171 189 ## Notes 172 190 ··· 177 195 - a repro pattern changes 178 196 - a candidate fix is attempted 179 197 - a failed fix is ruled out 198 + 199 + ## 2026-04-15 Snapshot 200 + 201 + Latest reduced repro: 202 + 203 + - two tabs only 204 + - selected tab surface detached after briefly reaching terminal-sized bounds 205 + - no reattach log observed afterward 206 + 207 + Current tactical response: 208 + 209 + - add detach stack logging to identify who removes the surface 210 + - add host wrapper diagnostics to correlate `surface ↔ wrapper ↔ canvas/terminal` 211 + - attempt a narrow fix: terminal host reattaches the surface if updates/layout find it missing 212 + - add a detach-time safety net so a just-detached surface asks its last terminal host to try reattachment on the next main-loop turn 213 + 214 + Expected interpretation of the next repro: 215 + 216 + - if `hostReattach` appears and the terminal becomes visible again, the bug is likely host ownership loss during Canvas teardown 217 + - if `hostReattach` appears but the terminal stays blank, the issue may still involve stale native surface/render state after detach 218 + - if no terminal `hostReattach` appears, the active terminal host may not be rebuilding/updating as expected
+8
supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift
··· 67 67 ) 68 68 state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 69 69 } 70 + .onDisappear { 71 + let activity = resolvedWindowActivity 72 + terminalTabsLogger.info( 73 + "[CanvasExit] onDisappear worktree=\(worktree.id) " 74 + + "selectedTab=\(state.tabManager.selectedTabId?.rawValue.uuidString ?? "nil") " 75 + + "windowKey=\(activity.isKeyWindow) windowVisible=\(activity.isVisible)" 76 + ) 77 + } 70 78 .onChange(of: state.tabManager.selectedTabId) { _, newValue in 71 79 if shouldAutoFocusTerminal { 72 80 state.focusSelectedTab()
+95 -3
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 6 6 import SwiftUI 7 7 8 8 private let surfaceLogger = SupaLogger("Surface") 9 + private let surfaceHostLogger = SupaLogger("SurfaceHost") 9 10 10 11 final class GhosttySurfaceView: NSView, Identifiable { 11 12 struct OcclusionState { ··· 98 99 let id = UUID() 99 100 private var debugID: String { 100 101 String(id.uuidString.prefix(8)) 102 + } 103 + var debugIdentifierForLogging: String { 104 + debugID 101 105 } 102 106 let bridge: GhosttySurfaceBridge 103 107 private(set) var surface: ghostty_surface_t? ··· 449 453 super.viewDidMoveToSuperview() 450 454 logLifecycleState("viewDidMoveToSuperview") 451 455 handleAttachmentChange() 456 + } 457 + 458 + override func viewWillMove(toSuperview newSuperview: NSView?) { 459 + if newSuperview == nil { 460 + logDetachIntent(event: "viewWillMoveToSuperview") 461 + } 462 + super.viewWillMove(toSuperview: newSuperview) 463 + } 464 + 465 + override func viewWillMove(toWindow newWindow: NSWindow?) { 466 + if newWindow == nil { 467 + logDetachIntent(event: "viewWillMoveToWindow") 468 + } 469 + super.viewWillMove(toWindow: newWindow) 452 470 } 453 471 454 472 override func viewDidChangeBackingProperties() { ··· 1108 1126 surfaceLogger.info( 1109 1127 "[CanvasExit] attachmentChange surface=\(debugID) " 1110 1128 + "desired=\(String(describing: occlusionState.desired)) " 1111 - + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow)" 1129 + + "attached=\(hasAttachedSuperview) window=\(hasAttachedWindow) " 1130 + + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " 1131 + + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none")" 1112 1132 ) 1113 1133 _ = occlusionState.invalidateForAttachmentChange() 1134 + if superview == nil { 1135 + DispatchQueue.main.async { [weak self] in 1136 + self?.scrollWrapper?.ensureSurfaceAttached() 1137 + } 1138 + } 1114 1139 guard isReadyToApplyOcclusion else { return } 1115 1140 DispatchQueue.main.async { [weak self] in 1116 1141 self?.reapplyOcclusionIfNeeded() ··· 1141 1166 + "focused=\(focused) firstResponder=\(firstResponderMatches) " 1142 1167 + "bounds=\(Int(bounds.width))x\(Int(bounds.height)) " 1143 1168 + "backing=\(Int(lastBackingSize.width))x\(Int(lastBackingSize.height)) " 1144 - + "windowVisible=\(windowVisible) windowKey=\(windowKey)" 1169 + + "windowVisible=\(windowVisible) windowKey=\(windowKey) " 1170 + + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " 1171 + + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none")" 1172 + ) 1173 + } 1174 + 1175 + private func logDetachIntent(event: String) { 1176 + let stack = Thread.callStackSymbols.prefix(12).joined(separator: " | ") 1177 + surfaceLogger.info( 1178 + "[CanvasExit] detachIntent event=\(event) surface=\(debugID) " 1179 + + "host=\(scrollWrapper?.hostKind.rawValue ?? "none") " 1180 + + "wrapper=\(scrollWrapper?.debugIdentifier ?? "none") " 1181 + + "superview=\(String(describing: superview)) window=\(window != nil) " 1182 + + "stack=\(stack)" 1145 1183 ) 1146 1184 } 1147 1185 ··· 2311 2349 } 2312 2350 2313 2351 final class GhosttySurfaceScrollView: NSView { 2352 + enum HostKind: String { 2353 + case terminal 2354 + case canvas 2355 + } 2356 + 2314 2357 private struct ScrollbarState { 2315 2358 let total: UInt64 2316 2359 let offset: UInt64 ··· 2320 2363 private let scrollView: NSScrollView 2321 2364 private let documentView: NSView 2322 2365 private let surfaceView: GhosttySurfaceView 2366 + let hostKind: HostKind 2367 + private let debugID = String(UUID().uuidString.prefix(8)) 2368 + var debugIdentifier: String { 2369 + debugID 2370 + } 2323 2371 private var observers: [NSObjectProtocol] = [] 2324 2372 2325 2373 private var isLiveScrolling = false ··· 2333 2381 /// terminal reflow. 2334 2382 var pinnedSize: CGSize? 2335 2383 2336 - init(surfaceView: GhosttySurfaceView) { 2384 + init(surfaceView: GhosttySurfaceView, hostKind: HostKind) { 2337 2385 self.surfaceView = surfaceView 2386 + self.hostKind = hostKind 2338 2387 scrollView = NSScrollView() 2339 2388 scrollView.hasHorizontalScroller = false 2340 2389 scrollView.autohidesScrollers = false ··· 2348 2397 super.init(frame: .zero) 2349 2398 addSubview(scrollView) 2350 2399 surfaceView.scrollWrapper = self 2400 + surfaceHostLogger.info( 2401 + "[CanvasExit] hostInit wrapper=\(debugID) host=\(hostKind.rawValue) " 2402 + + "surface=\(surfaceView.debugIdentifierForLogging) " 2403 + + "attached=\(isSurfaceAttachedToDocumentView)" 2404 + ) 2351 2405 refreshAppearance() 2352 2406 2353 2407 scrollView.contentView.postsBoundsChangedNotifications = true ··· 2424 2478 } 2425 2479 2426 2480 isolated deinit { 2481 + surfaceHostLogger.info( 2482 + "[CanvasExit] hostDeinit wrapper=\(debugID) host=\(hostKind.rawValue) " 2483 + + "surface=\(surfaceView.debugIdentifierForLogging) " 2484 + + "attached=\(isSurfaceAttachedToDocumentView)" 2485 + ) 2427 2486 observers.forEach { NotificationCenter.default.removeObserver($0) } 2428 2487 } 2429 2488 2430 2489 override func layout() { 2431 2490 super.layout() 2491 + ensureSurfaceAttached() 2432 2492 let effectiveSize = pinnedSize ?? bounds.size 2433 2493 scrollView.frame = CGRect(origin: .zero, size: effectiveSize) 2434 2494 surfaceView.frame.size = effectiveSize ··· 2438 2498 surfaceView.updateSurfaceSize() 2439 2499 } 2440 2500 2501 + override func viewDidMoveToWindow() { 2502 + super.viewDidMoveToWindow() 2503 + ensureSurfaceAttached() 2504 + } 2505 + 2441 2506 func updateSurfaceSize() { 2442 2507 surfaceView.updateSurfaceSize() 2443 2508 needsLayout = true 2509 + } 2510 + 2511 + var isSurfaceAttachedToDocumentView: Bool { 2512 + surfaceView.superview === documentView 2513 + } 2514 + 2515 + func ensureSurfaceAttached(requiresLiveHost: Bool = true) { 2516 + guard hostKind == .terminal else { return } 2517 + if requiresLiveHost { 2518 + guard superview != nil || window != nil else { return } 2519 + } 2520 + guard !isSurfaceAttachedToDocumentView else { return } 2521 + surfaceHostLogger.info( 2522 + "[CanvasExit] hostReattach wrapper=\(debugID) host=\(hostKind.rawValue) " 2523 + + "surface=\(surfaceView.debugIdentifierForLogging) " 2524 + + "currentSuperview=\(String(describing: surfaceView.superview)) " 2525 + + "wrapperWindow=\(window != nil)" 2526 + ) 2527 + documentView.addSubview(surfaceView) 2528 + surfaceView.scrollWrapper = self 2529 + surfaceHostLogger.info( 2530 + "[CanvasExit] hostReattachComplete wrapper=\(debugID) host=\(hostKind.rawValue) " 2531 + + "surface=\(surfaceView.debugIdentifierForLogging) " 2532 + + "superview=\(surfaceView.superview != nil) " 2533 + + "window=\(surfaceView.window != nil) " 2534 + + "bounds=\(Int(surfaceView.bounds.width))x\(Int(surfaceView.bounds.height))" 2535 + ) 2444 2536 } 2445 2537 2446 2538 func updateScrollbar(total: UInt64, offset: UInt64, length: UInt64) {
+19 -1
supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift
··· 1 1 import SwiftUI 2 2 3 + private let terminalHostLogger = SupaLogger("TerminalHost") 4 + 3 5 struct GhosttyTerminalView: NSViewRepresentable { 4 6 let surfaceView: GhosttySurfaceView 5 7 var pinnedSize: CGSize? 6 8 9 + private var hostKind: GhosttySurfaceScrollView.HostKind { 10 + pinnedSize == nil ? .terminal : .canvas 11 + } 12 + 7 13 func makeNSView(context: Context) -> GhosttySurfaceScrollView { 8 - let view = GhosttySurfaceScrollView(surfaceView: surfaceView) 14 + let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind) 9 15 view.pinnedSize = pinnedSize 16 + terminalHostLogger.info( 17 + "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 18 + + "surface=\(surfaceView.debugIdentifierForLogging) " 19 + + "pinned=\(pinnedSize != nil)" 20 + ) 10 21 return view 11 22 } 12 23 13 24 func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { 14 25 view.pinnedSize = pinnedSize 26 + terminalHostLogger.info( 27 + "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 28 + + "surface=\(surfaceView.debugIdentifierForLogging) " 29 + + "pinned=\(pinnedSize != nil) " 30 + + "attached=\(view.isSurfaceAttachedToDocumentView)" 31 + ) 32 + view.ensureSurfaceAttached() 15 33 } 16 34 }
+45
supacodeTests/GhosttySurfaceViewTests.swift
··· 1 + import AppKit 1 2 import Foundation 2 3 import GhosttyKit 3 4 import Testing ··· 215 216 surfaceView.resumeDeferredOcclusionIfNeededForTesting() 216 217 await drainMainQueue() 217 218 #expect(appliedValues == [true, true]) 219 + } 220 + 221 + @Test func terminalHostReattachesDetachedSurface() { 222 + let runtime = GhosttyRuntime() 223 + let surfaceView = GhosttySurfaceView( 224 + runtime: runtime, 225 + workingDirectory: nil, 226 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 227 + skipsSurfaceCreationForTesting: true 228 + ) 229 + let terminalHost = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: .terminal) 230 + let foreignHost = NSView() 231 + 232 + #expect(terminalHost.isSurfaceAttachedToDocumentView) 233 + 234 + foreignHost.addSubview(surfaceView) 235 + #expect(!terminalHost.isSurfaceAttachedToDocumentView) 236 + 237 + terminalHost.ensureSurfaceAttached(requiresLiveHost: false) 238 + 239 + #expect(terminalHost.isSurfaceAttachedToDocumentView) 240 + #expect(surfaceView.scrollWrapper === terminalHost) 241 + } 242 + 243 + @Test func canvasHostDoesNotStealDetachedSurfaceBack() { 244 + let runtime = GhosttyRuntime() 245 + let surfaceView = GhosttySurfaceView( 246 + runtime: runtime, 247 + workingDirectory: nil, 248 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 249 + skipsSurfaceCreationForTesting: true 250 + ) 251 + let canvasHost = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: .canvas) 252 + let foreignHost = NSView() 253 + 254 + #expect(canvasHost.isSurfaceAttachedToDocumentView) 255 + 256 + foreignHost.addSubview(surfaceView) 257 + #expect(!canvasHost.isSurfaceAttachedToDocumentView) 258 + 259 + canvasHost.ensureSurfaceAttached(requiresLiveHost: false) 260 + 261 + #expect(!canvasHost.isSurfaceAttachedToDocumentView) 262 + #expect(surfaceView.superview === foreignHost) 218 263 } 219 264 220 265 private func drainMainQueue() async {