Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ public nonisolated final class TerminalFrameChanges: Sendable {
tell?()
}

/// A change of whether the terminal shows (shown, hidden, drawn, paused, closed): tells the observer even when it
/// already was told and has not read since. A hidden terminal's keeper does not read after its hide (SWITCH FRAMES
/// SF-5: a read re-armed the tell, so every hidden output chunk woke the main actor about twice a second), so
/// output while hidden only counts; the next visibility change wakes it.
func wake() {
let tell = state.withLock { state -> (@Sendable () -> Void)? in
state.generation &+= 1
state.told = state.onChange != nil
return state.onChange
}
tell?()
}

/// The changes so far; the next change tells the observer again.
func take() -> UInt64 {
state.withLock { state in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ final class TerminalLaunchFrameKeeper {
private(set) var saves = 0
/// Files removed (a terminal hidden or closed, or one this run does not show).
private(set) var removals = 0
/// Checks run on the main actor (each one a timer wake): tests count the ones a hidden terminal costs.
private(set) var checks = 0
/// Runs on the files lane after each write or removal of a key's file (tests).
var onWritten: (@Sendable (String) -> Void)?

Expand Down Expand Up @@ -112,9 +114,11 @@ final class TerminalLaunchFrameKeeper {
/// quiet for ``idle`` (one still changing after ``busyWait`` of a due save saves anyway), else checks again later.
func check(_ key: String) async { // perf-hot: 1ms launch-first-frame (one terminal's frame keep check)
guard !stopped, var entry = kept[key] else { return }
// Not on screen (hidden, its workspace away, closed): its file goes at once, with no interval.
checks += 1
// Not on screen (hidden, its workspace away, closed): its file goes at once, with no interval. No read of the
// changes: output while hidden only counts (the model), with no hop or timer here; the next visibility change
// (``TerminalFrameChanges/wake()``: shown, drawn, closed) wakes the keeper again (SWITCH FRAMES SF-5).
guard let session = entry.session, Self.isOnScreen(session) else {
_ = entry.changes.take()
remove(key)
// Closed: nothing visits it again. One only out of a window for now (a move) stays: its next window wakes it.
if entry.session?.isClosed ?? true {
Expand Down Expand Up @@ -197,10 +201,8 @@ final class TerminalLaunchFrameKeeper {
}
}

private static func isOnScreen(_ session: TerminalSession) -> Bool {
!session.isRenderingSuspended && session.surfaceView.window?.isVisible == true
&& !session.surfaceView.isHiddenOrHasHiddenAncestor
}
/// The surface view's own rule, which also tells the keeper when its answer changes (``TerminalSurfaceView/updateOcclusion()``).
private static func isOnScreen(_ session: TerminalSession) -> Bool { session.surfaceView.showsForLaunchFrame }

private func remove(_ key: String) {
guard kept[key]?.onDisk ?? true else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ public final class TerminalLaunchFrames {

/// Frames the keeper saved so far (tests, `debug.timings`).
var keepSaves: Int { keeper?.saves ?? 0 }
/// The keeper's checks so far (each a main-actor timer wake).
var keepChecks: Int { keeper?.checks ?? 0 }
/// The size of `key`'s last kept save (tests).
func keepSavedSize(_ key: String) -> CGSize? { keeper?.savedSize(key) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public final class TerminalSession {
public var isRenderingSuspended = false {
didSet {
surfaceView.isRenderingSuspended = isRenderingSuspended
if isRenderingSuspended != oldValue { frameChanges.bump() } // shown or hidden: its kept frame follows
if isRenderingSuspended != oldValue { frameChanges.wake() } // shown or hidden: its kept frame follows
}
}
/// This terminal's content changes, for the launch frame keeper (``TerminalLaunchFrameKeeper``).
Expand Down Expand Up @@ -422,7 +422,7 @@ public final class TerminalSession {
/// whether the underlying terminal keeps running.
public func close() {
isClosed = true
frameChanges.bump() // its kept frame goes (TerminalLaunchFrameKeeper)
frameChanges.wake() // its kept frame goes (TerminalLaunchFrameKeeper)
let onClose = view.onClose
view.onClose = nil
onClose?()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ public final class TerminalSurfaceView: NSView {
}

private(set) var lastOcclusionVisible: Bool?
/// ``showsForLaunchFrame`` at the last ``updateOcclusion()``.
private var lastShowsForLaunchFrame = false

/// Whether the launch-frame keeper counts this terminal as on screen: in a window that is ordered in, not hidden,
/// parked or suspended. A covered window still counts (it shows again at a relaunch); forced drawing (a hover
/// mirror) does not make a hidden terminal count.
var showsForLaunchFrame: Bool {
Self.showsForLaunchFrame(WindowDrawPolicy.inputs(for: self, suspended: isRenderingSuspended), window: window)
}

static func showsForLaunchFrame(_ inputs: SurfaceDrawInputs, window: NSWindow?) -> Bool {
inputs.inWindow && !inputs.hidden && !inputs.parked && !inputs.suspended && window?.isVisible == true
}
private(set) var pause: SurfacePause? = .notInWindow // why it does not draw (nil: draws)
/// The presented frame made volatile while the surface does not draw (`holdPresentedFrame`).
private var volatileFrame: IOSurface?
Expand Down Expand Up @@ -490,11 +503,19 @@ public final class TerminalSurfaceView: NSView {
/// keep parsing output; Ghostty draws the current content on its `.visible` message.
func updateOcclusion() {
guard let surface else { return }
pause = WindowDrawPolicy.inputs(for: self, suspended: isRenderingSuspended, forced: mirrorDemand > 0).pause
let inputs = WindowDrawPolicy.inputs(for: self, suspended: isRenderingSuspended, forced: mirrorDemand > 0)
pause = inputs.pause
// The keeper's own "on screen" (``showsForLaunchFrame``) can change while drawing does not (a hover mirror
// forces drawing; an automation launch draws occluded windows): it is told of either change.
let shows = Self.showsForLaunchFrame(inputs, window: window)
if shows != lastShowsForLaunchFrame {
lastShowsForLaunchFrame = shows
session?.frameChanges.wake()
}
let drawing = pause == nil
guard drawing != lastOcclusionVisible else { return }
lastOcclusionVisible = drawing
session?.frameChanges.bump() // drawn or paused (a window shown, minimized, occluded): its kept frame follows
session?.frameChanges.wake() // drawn or paused (a window shown, minimized, occluded): its kept frame follows
holdPresentedFrame(volatile: pause != nil)
ghostty_surface_set_occlusion(surface, drawing)
// A surface can parse a renderer-affecting OSC action (for example Copilot's OSC 11
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,59 @@ import Testing
let atNewSize = try await Self.eventually(3) { frames.keepSavedSize("tab_busy_resized") == resized }
#expect(atNewSize, "the save was not at the new size")
}

/// SWITCH FRAMES SF-5 (busy gate on a 2x display, 2026-11-02: 8 hidden terminals printing 20 lines/s woke
/// `terminal.launch-frames.keep-frame` 12.6 times a second, about 2 per hidden terminal): a hidden terminal that
/// prints costs the keeper nothing. Its first check after the hide removes its file; later output only counts
/// (the model), with no main-actor hop and no timer, until it shows again, which saves it again.
@Test func aHiddenBusyTerminalCostsTheKeeperNoWakes() async throws {
let shown = try await Shown()
defer { shown.close() }
let directory = FileManager.default.temporaryDirectory.appending(path: "keeper-hidden-busy-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: directory) }
let frames = TerminalLaunchFrames(directory: directory)
frames.keep("tab_hidden_busy", session: shown.session, work: Self.work)
try #require(try await Self.eventually { !Self.files("tab_hidden_busy", in: directory).isEmpty }, "the shown frame was never kept")
shown.session.isRenderingSuspended = true
try #require(try await Self.eventually { Self.files("tab_hidden_busy", in: directory).isEmpty }, "the hidden frame was never removed")
try await Task.sleep(for: .milliseconds(700)) // test: the hide's own check ends
let checks = frames.keepChecks
// 3 s of a busy program while hidden: a line every 50 ms (20 lines/s, as the busy gate).
let end = ContinuousClock.now.advanced(by: .seconds(3))
var lines = 0
while ContinuousClock.now < end {
shown.io.send(.output(Data("busy line \(lines)\r\n".utf8)))
lines += 1
try await Task.sleep(for: .milliseconds(50)) // test: the busy program's line rate
}
try await Task.sleep(for: .milliseconds(700)) // test: a check the last line asked for would run by now
#expect(lines >= 20, "only \(lines) lines in 3 s: the test did not keep the terminal busy")
let extra = frames.keepChecks - checks
#expect(extra == 0, "\(extra) keeper checks (main-actor timer wakes) for \(lines) lines of a hidden terminal: hidden output must update the model only")
// Shown again: it is kept again (the show is a change that wakes the keeper).
shown.session.isRenderingSuspended = false
let rekept = try await Self.eventually { !Self.files("tab_hidden_busy", in: directory).isEmpty }
#expect(rekept, "a terminal shown again after hidden output was never kept again")
}

/// Review of #32384: the keeper is told when its own "on screen" changes, not only when drawing changes. With a
/// live hover mirror (forced drawing) a hide leaves drawing on, so the keeper never heard of it: the hidden
/// terminal's frame stayed on disk, and after a hidden check nothing kept it again when it showed.
@Test func aHideWhileMirroredStillTellsTheKeeper() async throws {
let shown = try await Shown()
defer { shown.close() }
let directory = FileManager.default.temporaryDirectory.appending(path: "keeper-mirrored-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: directory) }
let frames = TerminalLaunchFrames(directory: directory)
frames.keep("tab_mirrored", session: shown.session, work: Self.work)
try #require(try await Self.eventually { !Self.files("tab_mirrored", in: directory).isEmpty }, "the shown frame was never kept")
shown.session.mirrorDemand = 1 // a hover card mirrors it: it draws whatever else holds
defer { shown.session.mirrorDemand = 0 }
shown.session.view.isHidden = true
let removed = try await Self.eventually(3) { Self.files("tab_mirrored", in: directory).isEmpty }
#expect(removed, "a terminal hidden while mirrored kept its frame on disk: the keeper was never told")
shown.session.view.isHidden = false
let rekept = try await Self.eventually { !Self.files("tab_mirrored", in: directory).isEmpty }
#expect(rekept, "a terminal shown again while mirrored was never kept again")
}
}
Loading