native macOS codings agent orchestrator
6
fork

Configure Feed

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

Fix canvas entry surface ownership race

onevcat 0b23ce41 e5d032ff

+50 -2
+18 -1
doc-onevcat/canvas-exit-terminal-blank-tracking.md
··· 1 1 # Canvas Exit Terminal Blank Tracking 2 2 3 3 Last updated: 2026-04-15 4 - Status: Open, intermittent, narrowed from occlusion-only suspicion to host reattachment failure 4 + Status: Open, intermittent, now confirmed to affect both Canvas exit and Canvas entry via host ownership races 5 5 6 6 ## Symptom 7 7 8 8 When leaving Canvas and returning to the normal worktree terminal view, the terminal area can appear blank. 9 + 10 + As of 2026-04-15, the reverse direction is also reproducible: 11 + 12 + - after the app has been running for a while, entering Canvas from a normal worktree tab can open a blank Canvas card 13 + - the selected tab/worktree remains logically correct 14 + - unlike the earlier exit symptom, tab switching, creating a new tab, or switching away and back does not reliably recover the blank card 9 15 10 16 Typical behavior: 11 17 ··· 77 83 - a later teardown or host rebuild removes the surface from the active view tree 78 84 - the normal terminal host does not currently guarantee that its `documentView` still owns the surface after updates 79 85 - once detached, occlusion recovery is irrelevant because there is no live host left to present the surface 86 + 87 + The newly observed Canvas-entry failure sharpens the theory further: 88 + 89 + - the canvas host can successfully take ownership of the selected surface 90 + - the previous terminal host may still run a defensive `ensureSurfaceAttached()` while it is already leaving the window hierarchy 91 + - because that reattach path only checked "not attached to my document view", it could steal the surface back from the live canvas host 92 + - once the stale terminal host deinitializes, AppKit removes that stolen surface again, leaving Canvas blank with no active host 80 93 81 94 What now looks less likely: 82 95 ··· 203 216 - two tabs only 204 217 - selected tab surface detached after briefly reaching terminal-sized bounds 205 218 - no reattach log observed afterward 219 + - reverse repro also confirmed: entering Canvas can blank the selected card immediately 220 + - in the failing entry log, `hostReattach wrapper=<terminal>` fires after `host=canvas` is already attached and visible 221 + - the stale terminal wrapper later deinitializes and the surface ends up detached (`attached=false window=false`) 206 222 207 223 Current tactical response: 208 224 ··· 210 226 - add host wrapper diagnostics to correlate `surface ↔ wrapper ↔ canvas/terminal` 211 227 - attempt a narrow fix: terminal host reattaches the surface if updates/layout find it missing 212 228 - 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 229 + - refine that narrow fix so terminal reattach only runs after the surface has actually left the view tree; it must not steal a surface currently owned by Canvas 213 230 214 231 Expected interpretation of the next repro: 215 232
+1
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 2518 2518 guard superview != nil || window != nil else { return } 2519 2519 } 2520 2520 guard !isSurfaceAttachedToDocumentView else { return } 2521 + guard surfaceView.superview == nil else { return } 2521 2522 surfaceHostLogger.info( 2522 2523 "[CanvasExit] hostReattach wrapper=\(debugID) host=\(hostKind.rawValue) " 2523 2524 + "surface=\(surfaceView.debugIdentifierForLogging) "
+31 -1
supacodeTests/GhosttySurfaceViewTests.swift
··· 218 218 #expect(appliedValues == [true, true]) 219 219 } 220 220 221 - @Test func terminalHostReattachesDetachedSurface() { 221 + @Test func terminalHostReattachesSurfaceOnlyAfterItLeavesTheViewTree() { 222 222 let runtime = GhosttyRuntime() 223 223 let surfaceView = GhosttySurfaceView( 224 224 runtime: runtime, ··· 236 236 237 237 terminalHost.ensureSurfaceAttached(requiresLiveHost: false) 238 238 239 + #expect(!terminalHost.isSurfaceAttachedToDocumentView) 240 + #expect(surfaceView.superview === foreignHost) 241 + 242 + surfaceView.removeFromSuperview() 243 + #expect(surfaceView.superview == nil) 244 + 245 + terminalHost.ensureSurfaceAttached(requiresLiveHost: false) 246 + 239 247 #expect(terminalHost.isSurfaceAttachedToDocumentView) 240 248 #expect(surfaceView.scrollWrapper === terminalHost) 249 + } 250 + 251 + @Test func terminalHostDoesNotStealSurfaceFromCanvasHost() { 252 + let runtime = GhosttyRuntime() 253 + let surfaceView = GhosttySurfaceView( 254 + runtime: runtime, 255 + workingDirectory: nil, 256 + context: GHOSTTY_SURFACE_CONTEXT_TAB, 257 + skipsSurfaceCreationForTesting: true 258 + ) 259 + let terminalHost = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: .terminal) 260 + let canvasHost = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: .canvas) 261 + 262 + #expect(!terminalHost.isSurfaceAttachedToDocumentView) 263 + #expect(canvasHost.isSurfaceAttachedToDocumentView) 264 + #expect(surfaceView.scrollWrapper === canvasHost) 265 + 266 + terminalHost.ensureSurfaceAttached(requiresLiveHost: false) 267 + 268 + #expect(!terminalHost.isSurfaceAttachedToDocumentView) 269 + #expect(canvasHost.isSurfaceAttachedToDocumentView) 270 + #expect(surfaceView.scrollWrapper === canvasHost) 241 271 } 242 272 243 273 @Test func canvasHostDoesNotStealDetachedSurfaceBack() {