From 7b85f3f7f5719e87c8ba4547709b9823a5c25754 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 16 Mar 2026 14:26:35 +0200 Subject: [PATCH] fix: create offscreen NSWindow for background workspace PTY initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background workspaces created via `workspace.create` (socket API) have dead PTY surfaces because ghostty_surface_new() requires a view backed by a live NSWindow for Metal layer setup and backing context. When requestBackgroundSurfaceStartIfNeeded() is called for a workspace that hasn't been displayed, the surfaceView has no window — so the attachToView → createSurface chain silently defers creation. The PTY is never forked, leaving the terminal as a ghost: listed in surface health but unable to accept input or return screen content. Fix: when the surfaceView has no window, create a small offscreen NSWindow positioned at (-10000, -10000) and temporarily host the hostedView in it. This triggers the standard viewDidMoveToWindow → attachToView → createSurface path with a valid window context. The offscreen window is released when the view migrates to a real window (workspace is displayed) or on teardown. Fixes #1472 Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/GhosttyTerminalView.swift | 41 ++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c9d0..3fa81ad22f98 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3412,6 +3412,10 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingSocketInputBytes: Int = 0 private let maxPendingSocketInputBytes = 1_048_576 private var backgroundSurfaceStartQueued = false + /// Offscreen window used to host the view hierarchy during background surface creation. + /// Ghostty requires a live NSWindow for Metal layer setup and backing context. + /// Released when the view moves to a real window or on teardown. + private var offscreenHostWindow: NSWindow? private var surfaceCallbackContext: Unmanaged? /// The desired focus state for the Ghostty C surface. May be set before the /// C surface exists (e.g. during layout restoration); `createSurface` @@ -3844,6 +3848,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func teardownSurface() { recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") + offscreenHostWindow = nil let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil @@ -3960,6 +3965,10 @@ final class TerminalSurface: Identifiable, ObservableObject { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") #endif + // Release the offscreen host window once the view has moved to a real window. + if let offscreen = offscreenHostWindow, view.window !== offscreen { + offscreenHostWindow = nil + } if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0, @@ -4610,7 +4619,37 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard allowsRuntimeSurfaceCreation() else { return } - guard surface == nil, attachedView != nil else { return } + guard surface == nil else { return } + + // Background workspaces may not be in any NSWindow yet — ghostty requires + // a live window for Metal layer setup and backing context. Provide an offscreen + // host window so the standard viewDidMoveToWindow → attachToView → createSurface + // chain fires with a valid window. + if surfaceView.window == nil && offscreenHostWindow == nil { + let window = NSWindow( + contentRect: NSRect(x: -10000, y: -10000, width: 800, height: 600), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.orderOut(nil) + offscreenHostWindow = window + if let contentView = window.contentView { + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + } + // viewDidMoveToWindow fires on surfaceView → attachToView → createSurface + if surface != nil { +#if DEBUG + dlog("surface.background_start.offscreen surface=\(id.uuidString.prefix(8)) ready=1") +#endif + return + } + } + + guard attachedView != nil else { return } guard !backgroundSurfaceStartQueued else { return } backgroundSurfaceStartQueued = true