Skip to content
Closed
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
50 changes: 50 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2206,6 +2206,56 @@ class GhosttyApp {
configurationReloadCoordinator.isReloadActive
}

#if DEBUG
/// Whether no configuration reload is active, queued, or waiting behind
/// font work, and no outstanding font work would defer the next one.
@MainActor
var isConfigurationReloadSettledForVerification: Bool {
guard configurationReloadCoordinator
.isSettledForVerification else {
return false
}
guard let arbiter =
AppDelegate.shared?
.workspaceTerminalFontSizeArbiter else {
return true
}
return arbiter.isFontSizeWorkIdleForVerification
}

/// Finishes any configuration reload left in flight by earlier work so a
/// caller starts from a known idle coordinator.
///
/// Surface fanout is cancelled the same way a newer reload cancels it, so
/// the transaction unwinds through its normal completion path instead of
/// waiting one main-actor turn per registered surface. Returns `false` if
/// work remained after `timeout`, which means the font-size arbiter never
/// went idle.
@MainActor
@discardableResult
func settleConfigurationReloadForVerification(
timeout: TimeInterval = 5
) -> Bool {
let deadline = Date(timeIntervalSinceNow: timeout)
while !isConfigurationReloadSettledForVerification {
guard Date() < deadline else { return false }
if terminalConfigurationApplyScheduler.hasPendingWork {
terminalConfigurationApplyScheduler
.cancelPendingWork()
continue
}
RunLoop.main.run(
mode: .default,
before: min(
deadline,
Date(timeIntervalSinceNow: 0.01)
)
)
}
return true
}
#endif

@MainActor
func terminalFontConfigurationSnapshot()
-> WorkspaceTerminalFontConfigurationSnapshot {
Expand Down
11 changes: 11 additions & 0 deletions Sources/TerminalConfigurationReloadCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ final class TerminalConfigurationReloadCoordinator {
phase == .waitingForFontWork
}

#if DEBUG
/// Whether no transaction is active, queued, or waiting for font work.
///
/// Tests share one process-wide coordinator, so a case that depends on
/// taking the font-work barrier must be able to observe that an earlier
/// case left nothing in flight.
var isSettledForVerification: Bool {
phase == .idle && pendingRequest == nil
}
#endif

/// Queues a request while bounding commit and post-fanout callbacks across
/// the active and pending transactions. Reload semantics are retained even
/// when excess callbacks are rejected.
Expand Down
8 changes: 8 additions & 0 deletions Sources/WorkspaceTerminalFontSizeArbiter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,14 @@ final class WorkspaceTerminalFontSizeArbiter {
&& extendedFontSizeWorkIdleBarrierTokens.isEmpty
}

#if DEBUG
/// Whether a configuration reload requested now would take the font-work
/// barrier immediately instead of queueing behind outstanding font work.
var isFontSizeWorkIdleForVerification: Bool {
isFontSizeWorkIdleBeforeBarrier && !hasFontSizeWorkIdleBarrier
}
#endif

@discardableResult
private func appendDeferredCoordinatorJoin(
_ join: DeferredWorkspaceTerminalFontSizeCoordinatorJoin,
Expand Down
42 changes: 41 additions & 1 deletion cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,26 @@ private func waitWhileSuspended(
}
}

#if DEBUG
/// Drives any configuration reload left in flight by an earlier case to
/// completion.
///
/// `GhosttyApp.shared` owns one process-wide reload coordinator, and a reload
/// now spans several main-actor turns, so a case that depends on taking the
/// font-work barrier cannot assume the previous case left it idle.
@MainActor
private func settleConfigurationReload(
_ app: GhosttyApp,
sourceLocation: SourceLocation = #_sourceLocation
) {
XCTAssertTrue(
app.settleConfigurationReloadForVerification(),
"Earlier work left a configuration reload or font-size work in flight",
sourceLocation: sourceLocation
)
}
#endif

@MainActor
private extension TabManager {
@discardableResult
Expand Down Expand Up @@ -5502,8 +5522,16 @@ final class AppDelegateEqualizeSplitsShortcutTests {
throws {
#if DEBUG
let app = GhosttyApp.shared
settleConfigurationReload(app)
let originalProfile =
GhosttyStartupAppearancePreviewState.profile
// Hold one transaction open so the staged reload below is the queued
// case this test is about. It reloads the unchanged configuration, so
// the appearance captured next is still the one to restore.
app.reloadConfiguration(
source: "test.stageAppearance.active",
reloadSettingsFromFile: false
)
let originalBackgroundHex =
app.defaultBackgroundColor.hexString()
let targetProfile: GhosttyStartupAppearancePreviewProfile =
Expand Down Expand Up @@ -5568,7 +5596,7 @@ final class AppDelegateEqualizeSplitsShortcutTests {
XCTAssertEqual(
app.defaultBackgroundColor.hexString(),
originalBackgroundHex,
"A pending full reload must not publish its new background before the matching Ghostty config commits"
"A full reload queued behind an active transaction must not publish its new background before its own Ghostty config commits"
)
wait(for: [reloadCompleted], timeout: 5)
XCTAssertNotEqual(
Expand All @@ -5586,6 +5614,7 @@ final class AppDelegateEqualizeSplitsShortcutTests {
throws {
#if DEBUG
let app = GhosttyApp.shared
settleConfigurationReload(app)
let retainedPanels = (0..<16).map { _ in
TerminalPanel(
workspaceId: UUID(),
Expand Down Expand Up @@ -5704,6 +5733,7 @@ final class AppDelegateEqualizeSplitsShortcutTests {
throws {
#if DEBUG
let app = GhosttyApp.shared
settleConfigurationReload(app)
let retainedPanels = (0..<16).map { _ in
TerminalPanel(
workspaceId: UUID(),
Expand Down Expand Up @@ -6232,6 +6262,9 @@ final class AppDelegateEqualizeSplitsShortcutTests {
XCTFail("Expected AppDelegate.shared")
return
}
#if DEBUG
settleConfigurationReload(GhosttyApp.shared)
#endif
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId,
Expand Down Expand Up @@ -6280,12 +6313,16 @@ final class AppDelegateEqualizeSplitsShortcutTests {
XCTAssertEqual(applyAttemptCount, 2)

var didUpdateGhosttyAppConfig = false
let configUpdated = expectation(
description: "ghostty app config update published"
)
let observer = NotificationCenter.default.addObserver(
forName: .ghosttyConfigDidReload,
object: nil,
queue: .main
) { _ in
didUpdateGhosttyAppConfig = true
configUpdated.fulfill()
}
defer {
NotificationCenter.default.removeObserver(observer)
Expand All @@ -6305,6 +6342,9 @@ final class AppDelegateEqualizeSplitsShortcutTests {
scheduler.fire(at: 2)
}
XCTAssertEqual(applyAttemptCount, 3)
// Releasing the barrier commits the app configuration; the reload
// publishes this notification once its bounded surface fanout drains.
wait(for: [configUpdated], timeout: 5)
XCTAssertTrue(didUpdateGhosttyAppConfig)
}

Expand Down
Loading