Skip to content

Commit c42e979

Browse files
authored
Reduce GPU memory growth and UI sluggishness (#39)
* Reduce GPU memory growth and UI sluggishness Occlude surfaces in non-selected workspaces so libghostty stops their DisplayLink and redraw loops. All split panes in the active workspace remain live regardless of focus; only workspace switches toggle occlusion. Occlusion is applied reactively via SwiftUI re-renders on selectedWorkspaceId change — no manual recompute needed. Also fixes several secondary contributors: - refreshGitBranch: cancel-and-replace per surface to prevent git subprocess pile-up on rapid tab cycling - AgentCompletionEventMonitor: batch all log-line events into one MainActor task per file read instead of one strong-self task per line - CoalescingWorkspacePersistence.save(): async instead of sync so the main thread is never blocked scheduling debounced workspace saves - WorkspacePersistence: reuse JSONEncoder instance - deleteWorkspace: call clearProgressReport for released surfaces - Codex monitor poll loop: cap at 300 iterations (150 s) to prevent orphaned monitors after abrupt close * Fix premature occlusion on detach and git state cancellation race Guard setSurfaceOcclusion(occluded:true) in both detach sites behind a mountedHostCount check so a reparent-in-progress surface is not stalled while a second container still shows it. Reorder dismantleContainerView to decrement first, then check. Add a second Task.isCancelled guard inside the MainActor.run closure in refreshGitBranch to prevent stale git state from landing after a replacement task cancels the in-flight one during the actor hop.
1 parent cebaa43 commit c42e979

11 files changed

Lines changed: 215 additions & 26 deletions

File tree

Sources/Shellraiser/Features/Terminal/GhosttyTerminalView.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ protocol GhosttyTerminalHostView: GhosttyFocusableHost {
2727
protocol GhosttyTerminalRuntimeControlling: AnyObject {
2828
func attachHost(surfaceId: UUID)
2929
func detachHost(surfaceId: UUID)
30+
/// Returns the number of SwiftUI wrapper containers that currently have this surface mounted.
31+
func mountedHostCount(surfaceId: UUID) -> Int
3032
func setSurfaceFocus(surfaceId: UUID, focused: Bool)
33+
func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool)
3134
func restorePendingFocusIfNeeded(surfaceId: UUID, hostView: any GhosttyFocusableHost)
3235
}
3336

@@ -73,6 +76,9 @@ struct GhosttyTerminalView: NSViewRepresentable {
7376
let surface: SurfaceModel
7477
let config: TerminalPanelConfig
7578
let isFocused: Bool
79+
/// Whether this surface's workspace is the currently selected one.
80+
/// Non-selected workspaces are occluded so libghostty stops their render loops.
81+
let isWorkspaceSelected: Bool
7682
let onActivate: () -> Void
7783
let onIdleNotification: () -> Void
7884
let onInput: (SurfaceInputEvent) -> Void
@@ -109,6 +115,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
109115
surface: surface,
110116
config: config,
111117
isFocused: isFocused,
118+
isWorkspaceSelected: isWorkspaceSelected,
112119
onActivate: onActivate,
113120
onIdleNotification: onIdleNotification,
114121
onInput: onInput,
@@ -151,6 +158,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
151158
surface: surface,
152159
config: config,
153160
isFocused: isFocused,
161+
isWorkspaceSelected: isWorkspaceSelected,
154162
onActivate: onActivate,
155163
onIdleNotification: onIdleNotification,
156164
onInput: onInput,
@@ -179,6 +187,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
179187
surface: SurfaceModel,
180188
config: TerminalPanelConfig,
181189
isFocused: Bool,
190+
isWorkspaceSelected: Bool,
182191
onActivate: @escaping () -> Void,
183192
onIdleNotification: @escaping () -> Void,
184193
onInput: @escaping (SurfaceInputEvent) -> Void,
@@ -191,11 +200,19 @@ struct GhosttyTerminalView: NSViewRepresentable {
191200
if container.mountedSurfaceId != surface.id {
192201
if let mountedSurfaceId = container.mountedSurfaceId {
193202
runtime.detachHost(surfaceId: mountedSurfaceId)
203+
// Only occlude when this was the last remaining mount for the surface.
204+
// In reparent scenarios another container may still show it visibly.
205+
if runtime.mountedHostCount(surfaceId: mountedSurfaceId) == 0 {
206+
runtime.setSurfaceOcclusion(surfaceId: mountedSurfaceId, occluded: true)
207+
}
194208
}
195209
runtime.attachHost(surfaceId: surface.id)
196210
}
197211

198212
container.mountHostView(host, surfaceId: surface.id)
213+
// Apply occlusion on every sync so workspace-selection changes take effect
214+
// even when the mounted surface has not changed.
215+
runtime.setSurfaceOcclusion(surfaceId: surface.id, occluded: !isWorkspaceSelected)
199216
syncHostView(
200217
host,
201218
runtime: runtime,
@@ -220,6 +237,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
220237
) {
221238
guard let surfaceId = container.mountedSurfaceId else { return }
222239
runtime.detachHost(surfaceId: surfaceId)
240+
// Only occlude when this was the last remaining mount for the surface.
241+
// In reparent scenarios another container may still show it visibly.
242+
if runtime.mountedHostCount(surfaceId: surfaceId) == 0 {
243+
runtime.setSurfaceOcclusion(surfaceId: surfaceId, occluded: true)
244+
}
223245
container.clearMountedSurface()
224246
}
225247

Sources/Shellraiser/Features/WorkspaceDetail/PaneLeafView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ struct PaneLeafView: View {
203203
surface: activeSurface,
204204
config: activeSurface.terminalConfig,
205205
isFocused: isFocusedPane,
206+
isWorkspaceSelected: manager.window.selectedWorkspaceId == workspaceId,
206207
onActivate: {
207208
manager.activateSurface(workspaceId: workspaceId, paneId: leaf.id, surfaceId: activeSurface.id)
208209
},

Sources/Shellraiser/Infrastructure/Agents/AgentCompletionEventMonitor.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,19 @@ final class AgentCompletionEventMonitor: AgentActivityEventMonitoring {
114114
? lines.map(String.init)
115115
: lines.dropLast().map(String.init)
116116

117-
for line in completeLines where !line.isEmpty {
118-
guard let event = AgentActivityEvent.parse(line) else { continue }
119-
CompletionDebugLogger.log(
120-
"event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)"
121-
)
122-
Task { @MainActor in
123-
self.onEvent?(event)
117+
let events = completeLines.compactMap { line -> AgentActivityEvent? in
118+
guard !line.isEmpty else { return nil }
119+
return AgentActivityEvent.parse(line)
120+
}
121+
if !events.isEmpty {
122+
Task { @MainActor [weak self] in
123+
guard let self else { return }
124+
for event in events {
125+
CompletionDebugLogger.log(
126+
"event runtime=\(event.agentType.rawValue) phase=\(event.phase.rawValue) surface=\(event.surfaceId.uuidString)"
127+
)
128+
onEvent?(event)
129+
}
124130
}
125131
}
126132

Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,8 +437,10 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
437437
[ "$latest_timestamp" = "$candidate_timestamp" ]
438438
}
439439
440-
while :; do
440+
iteration=0
441+
while [ "$iteration" -lt 300 ]; do
441442
[ -f "$stamp_file" ] || exit 0
443+
iteration=$((iteration + 1))
442444
443445
while IFS= read -r session_file; do
444446
[ -f "$session_file" ] || continue

Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ final class GhosttyRuntime {
200200
mountedHostCountsBySurfaceId[surfaceId, default: 0] += 1
201201
}
202202

203+
/// Returns the current number of SwiftUI wrapper containers that have this surface mounted.
204+
func mountedHostCount(surfaceId: UUID) -> Int {
205+
mountedHostCountsBySurfaceId[surfaceId, default: 0]
206+
}
207+
203208
/// Marks a host view as detached and schedules delayed cleanup.
204209
func detachHost(surfaceId: UUID) {
205210
let current = mountedHostCountsBySurfaceId[surfaceId, default: 0]
@@ -343,6 +348,15 @@ final class GhosttyRuntime {
343348
ghostty_surface_set_focus(surface, focused)
344349
}
345350

351+
/// Notifies libghostty whether a surface is occluded (not visible).
352+
///
353+
/// `ghostty_surface_set_occlusion` takes `true` when the surface IS visible,
354+
/// so we invert the `occluded` flag.
355+
func setSurfaceOcclusion(surfaceId: UUID, occluded: Bool) {
356+
guard let surface = surfaceHandlesById[surfaceId] else { return }
357+
ghostty_surface_set_occlusion(surface, !occluded)
358+
}
359+
346360
/// Moves first-responder focus to the host view that owns a surface id.
347361
func focusSurfaceHost(surfaceId: UUID) {
348362
pendingFocusedSurfaceId = surfaceId

Sources/Shellraiser/Services/Persistence/WorkspacePersistence.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ final class WorkspacePersistence: WorkspacePersisting {
2323
private let fileManager = FileManager.default
2424
private let logsErrors: Bool
2525
private let workspaceFileURL: URL
26+
private let encoder: JSONEncoder = {
27+
let encoder = JSONEncoder()
28+
encoder.dateEncodingStrategy = .iso8601
29+
return encoder
30+
}()
2631

2732
/// Returns the directory containing the persisted workspace file.
2833
var directoryURL: URL {
@@ -146,10 +151,7 @@ final class WorkspacePersistence: WorkspacePersisting {
146151
withIntermediateDirectories: true
147152
)
148153

149-
let encoder = JSONEncoder()
150-
encoder.dateEncodingStrategy = .iso8601
151154
let data = try encoder.encode(workspaces)
152-
153155
try data.write(to: workspaceFileURL, options: .atomic)
154156
} catch {
155157
if logsErrors {
@@ -185,20 +187,20 @@ final class CoalescingWorkspacePersistence: WorkspacePersisting {
185187

186188
/// Stores the latest snapshot and resets the debounce timer.
187189
func save(_ workspaces: [WorkspaceModel]) {
188-
coordinationQueue.sync {
189-
pendingWorkspaces = workspaces
190-
saveWorkItem?.cancel()
190+
coordinationQueue.async {
191+
self.pendingWorkspaces = workspaces
192+
self.saveWorkItem?.cancel()
191193

192-
guard debounceInterval > 0 else {
193-
persistPendingWorkspaces()
194+
guard self.debounceInterval > 0 else {
195+
self.persistPendingWorkspaces()
194196
return
195197
}
196198

197199
let workItem = DispatchWorkItem { [weak self] in
198200
self?.persistPendingWorkspaces()
199201
}
200-
saveWorkItem = workItem
201-
coordinationQueue.asyncAfter(deadline: .now() + debounceInterval, execute: workItem)
202+
self.saveWorkItem = workItem
203+
self.coordinationQueue.asyncAfter(deadline: .now() + self.debounceInterval, execute: workItem)
202204
}
203205
}
204206

Sources/Shellraiser/Services/Workspaces/WorkspaceManager+GitBranches.swift

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,27 +29,41 @@ extension WorkspaceManager {
2929
}
3030

3131
/// Refreshes the resolved Git state for a surface working directory.
32+
///
33+
/// Cancels any in-flight task for the same surface before spawning a replacement.
3234
@discardableResult
3335
func refreshGitBranch(workspaceId: UUID, surfaceId: UUID, workingDirectory: String) -> Task<Void, Never> {
36+
gitBranchTasks[surfaceId]?.cancel()
37+
3438
let requestedWorkingDirectory = workingDirectory
3539
let gitStateResolver = self.gitStateResolver
3640

37-
return Task.detached(priority: .utility) {
41+
let task = Task.detached(priority: .utility) { [weak self] in
3842
let gitState = gitStateResolver(requestedWorkingDirectory)
39-
await MainActor.run {
40-
guard let workspace = self.workspace(id: workspaceId),
41-
let surface = self.surface(in: workspace.rootPane, surfaceId: surfaceId),
43+
guard !Task.isCancelled else { return }
44+
await MainActor.run { [weak self] in
45+
guard let self else { return }
46+
// Re-check after the actor hop: a replacement task may have cancelled
47+
// this one between the pre-hop check and the write below.
48+
guard !Task.isCancelled else { return }
49+
guard let workspace = workspace(id: workspaceId),
50+
let surface = surface(in: workspace.rootPane, surfaceId: surfaceId),
4251
surface.terminalConfig.workingDirectory == requestedWorkingDirectory else {
4352
return
4453
}
4554

46-
self.gitStatesBySurfaceId[surfaceId] = gitState
55+
gitStatesBySurfaceId[surfaceId] = gitState
4756
}
4857
}
58+
59+
gitBranchTasks[surfaceId] = task
60+
return task
4961
}
5062

5163
/// Removes cached Git state for a surface that is no longer present.
5264
func clearGitBranch(surfaceId: UUID) {
65+
gitBranchTasks[surfaceId]?.cancel()
66+
gitBranchTasks.removeValue(forKey: surfaceId)
5367
gitStatesBySurfaceId.removeValue(forKey: surfaceId)
5468
}
5569

Sources/Shellraiser/Services/Workspaces/WorkspaceManager+WorkspaceLifecycle.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ extension WorkspaceManager {
7070
completionNotifications.removeNotifications(for: $0)
7171
GhosttyRuntime.shared.releaseSurface(surfaceId: $0)
7272
clearGitBranch(surfaceId: $0)
73+
clearProgressReport(surfaceId: $0)
7374
}
7475
updateDockBadge()
7576
}

Sources/Shellraiser/Services/Workspaces/WorkspaceManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ final class WorkspaceManager: ObservableObject {
7979
var progressClearTimers: [UUID: Timer] = [:]
8080
/// Monotonically-increasing generation counter per surface; used to detect stale timer callbacks.
8181
var progressTimerGeneration: [UUID: Int] = [:]
82+
var gitBranchTasks: [UUID: Task<Void, Never>] = [:]
8283

8384
let persistence: any WorkspacePersisting
8485
let workspaceCatalog: WorkspaceCatalogManager

Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,9 @@ final class AgentRuntimeBridgeTests: XCTestCase {
119119
XCTAssertFalse(codexWrapperContents.contains("surface_matches_current_codex_session"))
120120
XCTAssertTrue(codexWrapperContents.contains("normalize_codex_session_timestamp"))
121121
XCTAssertTrue(codexWrapperContents.contains("timestamp_is_at_or_after"))
122-
XCTAssertTrue(codexWrapperContents.contains("while :; do"))
123-
XCTAssertFalse(codexWrapperContents.contains("while [ \"$attempts\" -lt 40 ]; do"))
124-
XCTAssertFalse(codexWrapperContents.contains("attempts=$((attempts + 1))"))
122+
XCTAssertTrue(codexWrapperContents.contains("while [ \"$iteration\" -lt 300 ]; do"))
123+
XCTAssertTrue(codexWrapperContents.contains("iteration=$((iteration + 1))"))
124+
XCTAssertFalse(codexWrapperContents.contains("while :; do"))
125125
XCTAssertTrue(codexWrapperContents.contains("printf '%-9.9s'"))
126126
XCTAssertTrue(codexWrapperContents.contains("monitor_pid=\"$!\""))
127127
XCTAssertTrue(codexWrapperContents.contains("rm -f \"$stamp_file\""))

0 commit comments

Comments
 (0)