From bf90d4382dcbed278eead2c880f48fd9c4c7a815 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:20:25 -0400 Subject: [PATCH 1/4] Allow redraws to consume pending configures --- .../Internal/Windowing/TopLevelWindow.swift | 22 +++++++++- .../Internal/Binding/XDGConfigureState.swift | 10 +++++ .../Windowing/TopLevelWindowRedrawTests.swift | 42 +++++++++++++++++++ .../XDGConfigureStateTests.swift | 16 +++++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index c11258bd..eff6e831 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -1154,11 +1154,29 @@ extension TopLevelWindow { } private func redrawBufferAvailability() throws -> RedrawBufferAvailability { - surfaceRuntime.redrawBufferAvailability( - matching: try currentSurfaceGeometry().bufferSize.rawSize + try Self.resolveRedrawBufferAvailability( + hasPendingSurfaceConfigure: configureState.hasPendingSurfaceConfigure, + currentBufferAvailability: surfaceRuntime.redrawBufferAvailability( + matching: try currentSurfaceGeometry().bufferSize.rawSize + ) ) } + /// Resolves whether a redraw can start before it consumes a pending configure. + /// + /// A complete configure supersedes the current geometry, so the old buffer pool + /// cannot decide whether the next frame can be drawn. + package static func resolveRedrawBufferAvailability( + hasPendingSurfaceConfigure: Bool, + currentBufferAvailability: @autoclosure () throws -> RedrawBufferAvailability + ) rethrows -> RedrawBufferAvailability { + guard !hasPendingSurfaceConfigure else { + return .available + } + + return try currentBufferAvailability() + } + // swiftlint:disable:next cyclomatic_complexity private func interpretWindowEffects(_ effects: [WindowEffect]) throws { for effect in effects { diff --git a/Sources/WaylandRaw/Internal/Binding/XDGConfigureState.swift b/Sources/WaylandRaw/Internal/Binding/XDGConfigureState.swift index b47ae67e..c79cff4f 100644 --- a/Sources/WaylandRaw/Internal/Binding/XDGConfigureState.swift +++ b/Sources/WaylandRaw/Internal/Binding/XDGConfigureState.swift @@ -168,6 +168,16 @@ package final class XDGConfigureState { recoverablePhase.hasReceivedInitialConfigure } + /// Whether a complete surface configure is waiting to be consumed. + package var hasPendingSurfaceConfigure: Bool { + switch recoverablePhase { + case .collectingInitial, .collecting: + false + case .ready: + true + } + } + package init(initialSize: TopLevelSize = .unspecified) { phase = .collectingInitial( XDGConfigureCollection( diff --git a/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift b/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift new file mode 100644 index 00000000..45323b75 --- /dev/null +++ b/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift @@ -0,0 +1,42 @@ +import Testing + +@testable import WaylandClient + +@Suite +struct TopLevelWindowRedrawTests { + @Test + func pendingConfigurePublishesRedrawWithoutOldBuffer() { + var redraw = WindowRedrawState() + let availability = TopLevelWindow.resolveRedrawBufferAvailability( + hasPendingSurfaceConfigure: true, + currentBufferAvailability: { + Issue.record("The old buffer pool should not be checked.") + return .unavailable + }() + ) + + let effects = redraw.reduce( + .contentInvalidated, + bufferAvailability: availability + ) + + #expect(effects == [.publishRedrawRequested]) + } + + @Test + func unavailableBufferWithoutPendingConfigureWaits() { + var redraw = WindowRedrawState() + let availability = TopLevelWindow.resolveRedrawBufferAvailability( + hasPendingSurfaceConfigure: false, + currentBufferAvailability: .unavailable + ) + + let effects = redraw.reduce( + .contentInvalidated, + bufferAvailability: availability + ) + + #expect(effects.isEmpty) + #expect(redraw.isWaitingForBuffer) + } +} diff --git a/Tests/WaylandRawTests/ProtocolObjects/XDGConfigureStateTests.swift b/Tests/WaylandRawTests/ProtocolObjects/XDGConfigureStateTests.swift index 383c794f..711298bd 100644 --- a/Tests/WaylandRawTests/ProtocolObjects/XDGConfigureStateTests.swift +++ b/Tests/WaylandRawTests/ProtocolObjects/XDGConfigureStateTests.swift @@ -34,6 +34,22 @@ struct XDGConfigureStateTests { _ = state.consumeLatestConfigure() #expect(state.hasReceivedInitialConfigure) } + + @Test + func pendingSurfaceConfigureFollowsConsumption() { + let state = XDGConfigureState() + + #expect(!state.hasPendingSurfaceConfigure) + state.handleTopLevelConfigure(width: 800, height: 600) + #expect(!state.hasPendingSurfaceConfigure) + + _ = state.handleSurfaceConfigure(serial: 1) + #expect(state.hasPendingSurfaceConfigure) + + _ = state.consumeLatestConfigure() + #expect(!state.hasPendingSurfaceConfigure) + } + @Test func decorationConfigureIsConsumedBySurfaceConfigure() { let state = XDGConfigureState() From b87c636be9a52ea92080ecc60157babe27b6609f Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:26:17 -0400 Subject: [PATCH 2/4] Clear presentation state for canceled frame leases --- .../WaylandDisplay+GraphicsPreview.swift | 8 +++ .../Internal/Windowing/TopLevelWindow.swift | 14 +++- .../Public/Display/WaylandDisplay.swift | 6 ++ .../WaylandClient/Public/Window/Window.swift | 4 ++ .../WaylandGraphicsManagedWindowBacking.swift | 7 ++ .../WaylandGraphicsSubmissionStorage.swift | 10 ++- ...raphicsExternalBufferSubmissionTests.swift | 67 +++++++++++++++++-- 7 files changed, 108 insertions(+), 8 deletions(-) diff --git a/Sources/WaylandClient/Internal/Display/WaylandDisplay+GraphicsPreview.swift b/Sources/WaylandClient/Internal/Display/WaylandDisplay+GraphicsPreview.swift index 821ddcf9..d48fddaf 100644 --- a/Sources/WaylandClient/Internal/Display/WaylandDisplay+GraphicsPreview.swift +++ b/Sources/WaylandClient/Internal/Display/WaylandDisplay+GraphicsPreview.swift @@ -294,6 +294,14 @@ extension DisplayCore { } } + func cancelGraphicsPreviewPresentation(for windowID: WindowID) { + withFatalFailureFinalization { + guard let window = surfaces.window(windowID) else { return } + + window.cancelGraphicsPreviewPresentationOnOwnerThread() + } + } + func presentGraphicsPreviewBuffer( _ buffer: RawSurfaceBuffer, on windowID: WindowID, diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index eff6e831..ea8f87c6 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -788,8 +788,12 @@ package final class TopLevelWindow { } private func resetTransientState() { + surfaceRuntime.resetTransientTransactionState() + resetTransientPresentationState() + } + + private func resetTransientPresentationState() { do { - surfaceRuntime.resetTransientTransactionState() _ = try model.reduce(.transientStateReset) } catch let error as ClientError { reportCallbackFailure(operation: .transientStateReset, error: error) @@ -1320,6 +1324,14 @@ extension TopLevelWindow { return try currentSurfaceGeometry() } + /// Cancels the redraw request held by a graphics frame that wasn't submitted. + /// + /// Surface transaction state belongs to earlier committed frames and is left intact. + package func cancelGraphicsPreviewPresentationOnOwnerThread() { + connection.preconditionIsOwnerThread() + resetTransientPresentationState() + } + package var stateSnapshotOnOwnerThread: WindowStateSnapshot { get throws { connection.preconditionIsOwnerThread() diff --git a/Sources/WaylandClient/Public/Display/WaylandDisplay.swift b/Sources/WaylandClient/Public/Display/WaylandDisplay.swift index 63f4a434..33499197 100644 --- a/Sources/WaylandClient/Public/Display/WaylandDisplay.swift +++ b/Sources/WaylandClient/Public/Display/WaylandDisplay.swift @@ -195,6 +195,12 @@ public actor WaylandDisplay { try requireCore().requestRedraw(windowID) } + package func cancelGraphicsPreviewPresentation(for windowID: WindowID) { + guard case .active(let core, _) = lifecycle else { return } + + core.cancelGraphicsPreviewPresentation(for: windowID) + } + package func requestPresentationFeedback(_ windowID: WindowID) throws { try requireCore().requestPresentationFeedback(windowID) } diff --git a/Sources/WaylandClient/Public/Window/Window.swift b/Sources/WaylandClient/Public/Window/Window.swift index 8b568bcc..0aa8bde3 100644 --- a/Sources/WaylandClient/Public/Window/Window.swift +++ b/Sources/WaylandClient/Public/Window/Window.swift @@ -440,4 +440,8 @@ public struct Window: Sendable, Hashable { timeoutMilliseconds: timeoutMilliseconds ) } + + package func cancelGraphicsPreviewPresentation() async { + await display.cancelGraphicsPreviewPresentation(for: id) + } } diff --git a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsManagedWindowBacking.swift b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsManagedWindowBacking.swift index 0f875dfa..95927956 100644 --- a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsManagedWindowBacking.swift +++ b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsManagedWindowBacking.swift @@ -11,6 +11,9 @@ package protocol WaylandGraphicsManagedWindow: Sendable { timeoutMilliseconds: Int32 ) async throws -> SurfaceGeometry + /// Cancels presentation state reserved for a frame that produced no work. + func cancelGraphicsPreviewPresentation() async + func requestGraphicsPreviewSurfaceFeedback( timeoutMilliseconds: Int32 ) async throws -> SurfaceCapabilitySnapshot @@ -65,6 +68,10 @@ extension WaylandGraphicsManagedWindow { try await geometry } + package func cancelGraphicsPreviewPresentation() async { + // Alternate managed windows may not retain presentation state between calls. + } + package func requestGraphicsPreviewSurfaceFeedback( timeoutMilliseconds _: Int32 ) async throws -> SurfaceCapabilitySnapshot { diff --git a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift index 95378d2b..2ed381e8 100644 --- a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift +++ b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift @@ -2047,9 +2047,13 @@ package actor WaylandGraphicsWindowBackingStorage { ) } - func cancel(leaseID: WaylandGraphicsFrameLeaseID) { - if leaseState.cancel(leaseID: leaseID) { - releaseExternalBufferReservations(leaseID: leaseID) + func cancel(leaseID: WaylandGraphicsFrameLeaseID) async { + let shouldCancelPresentation = leaseState.hasSubmittedFrame + guard leaseState.cancel(leaseID: leaseID) else { return } + + releaseExternalBufferReservations(leaseID: leaseID) + if shouldCancelPresentation { + await window.cancelGraphicsPreviewPresentation() } } diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift index b94cd34f..46c12c70 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -1018,6 +1018,58 @@ struct ExternalBufferPresentationFeedbackTests { // swiftlint:disable type_body_length @Suite struct WaylandGraphicsExternalBufferLifecycleTests { + @Test + func firstUnusedLeaseCancelDoesNotResetPresentation() async throws { + let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) + let storage = externalBufferStorage(window: window) + let lease = try await storage.nextFrame() + + await lease.cancel() + + #expect(await window.cancelPresentationRequests == 0) + await storage.closeForTesting() + } + + @Test + func unusedLeaseCancelAfterSubmissionResetsPresentation() async throws { + let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) + let storage = externalBufferStorage(window: window) + let firstLease = try await storage.nextFrame() + let submitted = try await registerAndSubmitTestExternalBuffer( + storage: storage, + lease: firstLease, + descriptor: try testExternalDescriptor(), + schedule: presentationFeedbackSchedule() + ) + let feedbackIdentity = try #require( + submitted.receipt.presentationFeedbackIdentity + ) + let unusedLease = try await storage.nextFrame() + + await unusedLease.cancel() + + #expect(await window.cancelPresentationRequests == 1) + #expect(await storage.externalBufferSubmittedSlotRawValuesForTesting() == [0]) + #expect(await window.pendingPresentationFeedbackHandlerCount() == 1) + + async let feedback = submitted.receipt.waitForPresentationFeedback() + await window.emitDiscardedFeedback(feedbackIdentity.surfacePresentationID) + #expect( + await feedback + == .discarded( + submissionID: submitted.receipt.id, + bufferID: submitted.buffer.id, + identity: feedbackIdentity.surfacePresentationID + ) + ) + + async let release = submitted.receipt.waitForRelease() + await window.emitImportedBufferRelease(at: 0) + #expect(await release == .released) + + await storage.closeForTesting() + } + @Test(.timeLimit(.minutes(1))) func backingCloseDoesNotJoinItsReentrantWindowCloseObserver() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) @@ -2277,6 +2329,7 @@ private actor ExternalBufferFakeManagedWindow: WaylandGraphicsManagedWindow { [SurfacePresentationIdentity: @Sendable (SurfacePresentationFeedback) -> Void] = [:] private var nextPresentationFeedbackIdentityRawValue: UInt64 = 1 private(set) var preparePresentationRequests = 0 + private(set) var cancelPresentationRequests = 0 private var importedSyncTimelineIdentities: [SurfaceSyncTimelineIdentity] = [] private var removedSyncTimelineIdentities: [SurfaceSyncTimelineIdentity] = [] private var surfaceFeedbackSynchronization: SurfaceSynchronizationCapability? @@ -2312,10 +2365,6 @@ private actor ExternalBufferFakeManagedWindow: WaylandGraphicsManagedWindow { get async throws { geometryValue } } - func setGeometry(_ geometry: SurfaceGeometry) { - geometryValue = geometry - } - func setSurfaceFeedbackSynchronization( _ synchronization: SurfaceSynchronizationCapability? ) { @@ -2542,6 +2591,16 @@ private actor ExternalBufferFakeManagedWindow: WaylandGraphicsManagedWindow { } } +extension ExternalBufferFakeManagedWindow { + func setGeometry(_ geometry: SurfaceGeometry) { + geometryValue = geometry + } + + func cancelGraphicsPreviewPresentation() { + cancelPresentationRequests += 1 + } +} + private func externalBufferStorage( window: ExternalBufferFakeManagedWindow, configuration: WaylandGraphicsConfiguration = WaylandGraphicsConfiguration( From 2c60ba2a547799830cc8b89a23cd9f13873a4d10 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:36:54 -0400 Subject: [PATCH 3/4] Preserve redraws when frame leases are canceled --- .../Internal/Popup/PopupModel.swift | 3 ++ .../Popup/PopupRoleSurface+Lifecycle.swift | 2 + .../Internal/Windowing/TopLevelWindow.swift | 18 ++++++++- .../Internal/Windowing/WindowModel.swift | 25 ++++++++++++ .../Internal/Windowing/WindowModelTypes.swift | 3 ++ .../Windowing/WindowRedrawState.swift | 14 +++++++ .../WaylandGraphicsSubmissionStorage.swift | 11 +----- .../WindowModelPresentationTests.swift | 36 +++++++++++++++++ .../Windowing/WindowRedrawStateTests.swift | 39 +++++++++++++++++++ ...raphicsExternalBufferSubmissionTests.swift | 31 +++++++++++++++ 10 files changed, 171 insertions(+), 11 deletions(-) diff --git a/Sources/WaylandClient/Internal/Popup/PopupModel.swift b/Sources/WaylandClient/Internal/Popup/PopupModel.swift index dee0ffcf..5e081591 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupModel.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupModel.swift @@ -5,6 +5,7 @@ package enum PopupEvent: Equatable, Sendable { case frameBecameReady(bufferAvailability: RedrawBufferAvailability) case bufferBecameAvailable(bufferAvailability: RedrawBufferAvailability) case redrawRequestConsumed(bufferAvailability: RedrawBufferAvailability) + case redrawRequestCanceled(bufferAvailability: RedrawBufferAvailability) case presentationStarted(PopupPresentationRequest) case presentationBlockedByBuffer case presentationSucceeded(generation: UInt64, bufferAvailability: RedrawBufferAvailability) @@ -122,6 +123,8 @@ package struct PopupModel: Equatable, Sendable { return reduceRedraw(.bufferBecameAvailable, bufferAvailability: bufferAvailability) case .redrawRequestConsumed(let bufferAvailability): return try reduceRedrawRequestConsumed(bufferAvailability: bufferAvailability) + case .redrawRequestCanceled(let bufferAvailability): + return reduceRedraw(.redrawRequestCanceled, bufferAvailability: bufferAvailability) case .presentationStarted(let request): return try reducePresentationStarted(request) case .presentationBlockedByBuffer: diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift index 46209f3d..8c00a4d6 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift @@ -315,6 +315,8 @@ extension PopupRoleSurface { .bufferBecameAvailable(bufferAvailability: bufferAvailability) case .redrawRequestConsumed: .redrawRequestConsumed(bufferAvailability: bufferAvailability) + case .redrawRequestCanceled: + .redrawRequestCanceled(bufferAvailability: bufferAvailability) case .drawBlockedByBuffer: .presentationBlockedByBuffer case .presented(let generation): diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index ea8f87c6..051aa1a8 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -1326,10 +1326,24 @@ extension TopLevelWindow { /// Cancels the redraw request held by a graphics frame that wasn't submitted. /// - /// Surface transaction state belongs to earlier committed frames and is left intact. + /// A newer invalidation is republished. Surface transaction state and software + /// presentations are left intact because they may belong to other frames. package func cancelGraphicsPreviewPresentationOnOwnerThread() { connection.preconditionIsOwnerThread() - resetTransientPresentationState() + + do { + try interpretWindowEffects( + model.reduce( + .graphicsPreviewPresentationCanceled( + bufferAvailability: try redrawBufferAvailability() + ) + ) + ) + } catch let error as ClientError { + reportCallbackFailure(operation: .markNeedsRedraw, error: error) + } catch { + reportCallbackFailure(operation: .markNeedsRedraw, error: error) + } } package var stateSnapshotOnOwnerThread: WindowStateSnapshot { diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift index fe1ba89f..956340f3 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift @@ -87,6 +87,10 @@ package struct WindowModel: Equatable, Sendable { return reduceRedraw(.bufferBecameAvailable, bufferAvailability: bufferAvailability) case .redrawRequestConsumed(let bufferAvailability): return try reduceRedrawRequestConsumed(bufferAvailability: bufferAvailability) + case .graphicsPreviewPresentationCanceled(let bufferAvailability): + return reduceGraphicsPreviewPresentationCanceled( + bufferAvailability: bufferAvailability + ) case .presentationStarted(let request): return try reducePresentationStarted(request) case .presentationBlockedByBuffer: return try reducePresentationBlockedByBuffer() @@ -376,6 +380,27 @@ extension WindowModel { } } + private mutating func reduceGraphicsPreviewPresentationCanceled( + bufferAvailability: RedrawBufferAvailability + ) -> [WindowEffect] { + guard !isClosed else { return [] } + let windowID = id + return updateActiveWindowStateIfPresent { activeState in + guard activeState.presentation.isIdle else { + return [] + } + + return Self.mapRedrawEffects( + activeState.redraw.reduce( + .redrawRequestCanceled, + bufferAvailability: bufferAvailability + ), + in: activeState, + windowID: windowID + ) + } + } + private func mapRedrawEffects(_ effects: [WindowRedrawEffect]) -> [WindowEffect] { Self.mapRedrawEffects(effects, windowID: id) } diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift index e812cb16..08eb4279 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift @@ -12,6 +12,9 @@ package enum WindowEvent: Equatable, Sendable { case frameBecameReady(bufferAvailability: RedrawBufferAvailability) case bufferBecameAvailable(bufferAvailability: RedrawBufferAvailability) case redrawRequestConsumed(bufferAvailability: RedrawBufferAvailability) + case graphicsPreviewPresentationCanceled( + bufferAvailability: RedrawBufferAvailability + ) case presentationStarted(PresentationRequest) case presentationBlockedByBuffer case presentationSucceeded(generation: UInt64, bufferAvailability: RedrawBufferAvailability) diff --git a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift index 00cb5e06..f41cfe0d 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift @@ -10,6 +10,7 @@ enum WindowRedrawEvent: Equatable, Sendable { case frameBecameReady case bufferBecameAvailable case redrawRequestConsumed + case redrawRequestCanceled case drawBlockedByBuffer case presented(generation: UInt64) case transientStateReset @@ -94,6 +95,8 @@ struct WindowRedrawState: Equatable, Sendable { case .redrawRequestConsumed: markRedrawRequestConsumed() effects = [] + case .redrawRequestCanceled: + effects = cancelRedrawRequest(bufferAvailability: bufferAvailability) case .drawBlockedByBuffer: markDrawBlockedByBuffer() effects = [] @@ -134,6 +137,17 @@ extension WindowRedrawState { } } + private mutating func cancelRedrawRequest( + bufferAvailability: RedrawBufferAvailability + ) -> [WindowRedrawEffect] { + guard case .frameReady(.outstanding) = pacing else { + return [] + } + + pacing = .frameReady(.none) + return publishIfNeeded(bufferAvailability: bufferAvailability) + } + private mutating func markDrawBlockedByBuffer() { guard isDirty else { return } pacing = .waitingForBuffer diff --git a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift index 2ed381e8..80763954 100644 --- a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift +++ b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift @@ -1638,7 +1638,7 @@ package actor WaylandGraphicsWindowBackingStorage { geometry: geometry ) } catch { - releaseExternalBufferReservationsAndCancelLease(leaseID: leaseID) + await cancel(leaseID: leaseID) throw error } let operation = try leaseState.prepareSubmission(leaseID: leaseID) @@ -1709,7 +1709,7 @@ package actor WaylandGraphicsWindowBackingStorage { geometry: geometry ) } catch { - releaseExternalBufferReservationsAndCancelLease(leaseID: leaseID) + await cancel(leaseID: leaseID) throw error } let operation = try leaseState.prepareSubmission(leaseID: leaseID) @@ -1858,13 +1858,6 @@ package actor WaylandGraphicsWindowBackingStorage { externalBufferRegistry.releaseReservations(leaseID: leaseID) } - private func releaseExternalBufferReservationsAndCancelLease( - leaseID: WaylandGraphicsFrameLeaseID - ) { - releaseExternalBufferReservations(leaseID: leaseID) - _ = leaseState.cancel(leaseID: leaseID) - } - private func markExternalBufferSubmitted( _ externalBuffer: WaylandGraphicsExternalBuffer, leaseID: WaylandGraphicsFrameLeaseID, diff --git a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift index f6663e68..6b07137d 100644 --- a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift @@ -205,6 +205,42 @@ struct WindowModelPresentationTests { // swiftlint:disable:this type_body_lengt ) } + @Test + func graphicsPreviewCancellationRepublishesNewerConfigure() throws { + var model = try activePublishedModel() + + #expect( + try model.reduce(.configureReceived(configure(width: 1_024, height: 768, serial: 2))) + == [.ackConfigure(2)] + ) + #expect( + try model.reduce( + .graphicsPreviewPresentationCanceled(bufferAvailability: .available) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.redraw.isDirty) + #expect(model.redraw.hasOutstandingRedrawRequest) + } + + @Test + func graphicsPreviewCancellationLeavesSoftwarePresentationActive() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect( + try model.reduce(.configureReceived(configure(width: 1_024, height: 768, serial: 2))) + == [.ackConfigure(2)] + ) + #expect(model.redraw.hasOutstandingRedrawRequest) + + #expect( + try model.reduce( + .graphicsPreviewPresentationCanceled(bufferAvailability: .available) + ).isEmpty + ) + #expect(model.presentation == .drawing(request: request)) + #expect(model.redraw.hasOutstandingRedrawRequest) + } + @Test func presentationStartRequiresIssuedRequest() throws { var model = try activePublishedModel() diff --git a/Tests/WaylandClientTests/Windowing/WindowRedrawStateTests.swift b/Tests/WaylandClientTests/Windowing/WindowRedrawStateTests.swift index b9f25c43..c97e0b6e 100644 --- a/Tests/WaylandClientTests/Windowing/WindowRedrawStateTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowRedrawStateTests.swift @@ -32,6 +32,34 @@ struct WindowRedrawStateTests { #expect(state.isDirty) } + @Test + func canceledRedrawRepublishesLatestDirtyGeneration() { + var state = WindowRedrawState() + + _ = state.reduce(.contentInvalidated, bufferAvailability: .available) + let leasedGeneration = state.generationForCurrentDraw + _ = state.reduce(.contentInvalidated, bufferAvailability: .available) + + #expect(state.generationForCurrentDraw == leasedGeneration + 1) + #expect( + state.reduce(.redrawRequestCanceled, bufferAvailability: .available) + == [.publishRedrawRequested] + ) + #expect(state.hasOutstandingRedrawRequest) + } + + @Test + func canceledRedrawDoesNotRepublishAfterRequestWasConsumed() { + var state = WindowRedrawState() + + _ = state.reduce(.contentInvalidated, bufferAvailability: .available) + _ = state.reduce(.redrawRequestConsumed, bufferAvailability: .available) + + #expect(state.reduce(.redrawRequestCanceled, bufferAvailability: .available).isEmpty) + #expect(state.isDirty) + #expect(!state.hasOutstandingRedrawRequest) + } + @Test func staticContentDoesNotPublishAgainWhenFrameBecomesReady() { var state = WindowRedrawState() @@ -311,6 +339,8 @@ private struct RedrawTraceModel { case .redrawRequestConsumed: consumeRedrawRequest() return [] + case .redrawRequestCanceled: + return cancelRedrawRequest(bufferAvailability: bufferAvailability) case .drawBlockedByBuffer: markDrawBlockedByBuffer() return [] @@ -346,6 +376,15 @@ private struct RedrawTraceModel { } } + private mutating func cancelRedrawRequest( + bufferAvailability: RedrawBufferAvailability + ) -> [WindowRedrawEffect] { + guard case .frameReady(hasOutstandingRequest: true) = pacing else { return [] } + + pacing = .frameReady(hasOutstandingRequest: false) + return publishIfNeeded(bufferAvailability: bufferAvailability) + } + private mutating func markDrawBlockedByBuffer() { if isDirty { pacing = .waitingForBuffer diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift index 46c12c70..62c0b4cc 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -1070,6 +1070,37 @@ struct WaylandGraphicsExternalBufferLifecycleTests { await storage.closeForTesting() } + @Test + func preflightFailureAfterSubmissionResetsPresentationOnce() async throws { + let window = try ExternalBufferFakeManagedWindow() + let storage = externalBufferStorage( + window: window, + configuration: WaylandGraphicsConfiguration(presentationPolicy: .software) + ) + let firstLease = try await storage.nextFrame() + _ = try await firstLease.submit(.clearColor(.black)) + let failedLease = try await storage.nextFrame() + let invalidDamage = WaylandGraphicsDamageRegion( + rects: [try LogicalRect(x: 101, y: 0, width: 20, height: 10)] + ) + let frame = WaylandGraphicsSubmittedFrame.clearColor( + WaylandGraphicsClearFrame( + color: .black, + metadata: WaylandGraphicsFrameMetadata(damage: invalidDamage) + ) + ) + + await #expect(throws: WaylandGraphicsError.invalidDamageRegion) { + _ = try await failedLease.submit(frame) + } + #expect(await window.cancelPresentationRequests == 1) + + await failedLease.cancel() + #expect(await window.cancelPresentationRequests == 1) + + await storage.closeForTesting() + } + @Test(.timeLimit(.minutes(1))) func backingCloseDoesNotJoinItsReentrantWindowCloseObserver() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) From 9d96890c9cdc155b429316ec3cd13503b9bdebab Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:38:36 -0400 Subject: [PATCH 4/4] Allow popup redraws to consume pending configures --- .../Internal/Popup/PopupConfigureState.swift | 13 +++++++++ .../Popup/PopupRoleSurface+Lifecycle.swift | 7 +++-- .../Internal/Windowing/TopLevelWindow.swift | 19 ++----------- .../Windowing/WindowRedrawState.swift | 15 ++++++++++ .../Popup/PopupDomainTypesTests.swift | 28 +++++++++++++++++++ .../Windowing/TopLevelWindowRedrawTests.swift | 8 +++--- 6 files changed, 67 insertions(+), 23 deletions(-) diff --git a/Sources/WaylandClient/Internal/Popup/PopupConfigureState.swift b/Sources/WaylandClient/Internal/Popup/PopupConfigureState.swift index 24274635..ef79e265 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupConfigureState.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupConfigureState.swift @@ -58,6 +58,19 @@ package final class PopupConfigureState { recoverablePhase.hasReceivedInitialConfigure } + /// Whether a complete surface configure is waiting to be consumed. + package var hasPendingSurfaceConfigure: Bool { + switch recoverablePhase { + case .ready: + true + case .waitingForInitialPopupConfigure, + .pendingInitialRolePayload, + .waitingForPopupConfigure, + .pendingRolePayload: + false + } + } + package init() { // Starts with no popup configure payload. } diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift index 8c00a4d6..46d25dbc 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Lifecycle.swift @@ -251,8 +251,11 @@ extension PopupRoleSurface { } package func redrawBufferAvailability() throws -> RedrawBufferAvailability { - surfaceRuntime.redrawBufferAvailability( - matching: try currentSurfaceGeometry().bufferSize.rawSize + try RedrawBufferAvailability.resolvingPendingConfigure( + configureState.hasPendingSurfaceConfigure, + currentBufferAvailability: surfaceRuntime.redrawBufferAvailability( + matching: try currentSurfaceGeometry().bufferSize.rawSize + ) ) } diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index 051aa1a8..4722806c 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -1158,29 +1158,14 @@ extension TopLevelWindow { } private func redrawBufferAvailability() throws -> RedrawBufferAvailability { - try Self.resolveRedrawBufferAvailability( - hasPendingSurfaceConfigure: configureState.hasPendingSurfaceConfigure, + try RedrawBufferAvailability.resolvingPendingConfigure( + configureState.hasPendingSurfaceConfigure, currentBufferAvailability: surfaceRuntime.redrawBufferAvailability( matching: try currentSurfaceGeometry().bufferSize.rawSize ) ) } - /// Resolves whether a redraw can start before it consumes a pending configure. - /// - /// A complete configure supersedes the current geometry, so the old buffer pool - /// cannot decide whether the next frame can be drawn. - package static func resolveRedrawBufferAvailability( - hasPendingSurfaceConfigure: Bool, - currentBufferAvailability: @autoclosure () throws -> RedrawBufferAvailability - ) rethrows -> RedrawBufferAvailability { - guard !hasPendingSurfaceConfigure else { - return .available - } - - return try currentBufferAvailability() - } - // swiftlint:disable:next cyclomatic_complexity private func interpretWindowEffects(_ effects: [WindowEffect]) throws { for effect in effects { diff --git a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift index f41cfe0d..d5176183 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift @@ -31,6 +31,21 @@ package enum RedrawBufferAvailability: Equatable, Sendable { var isAvailable: Bool { self == .available } + + /// Resolves whether a redraw can start before it consumes a pending configure. + /// + /// A complete configure supersedes the current geometry, so the old buffer pool + /// cannot decide whether the next frame can be drawn. + package static func resolvingPendingConfigure( + _ hasPendingSurfaceConfigure: Bool, + currentBufferAvailability: @autoclosure () throws -> Self + ) rethrows -> Self { + guard !hasPendingSurfaceConfigure else { + return .available + } + + return try currentBufferAvailability() + } } struct WindowRedrawState: Equatable, Sendable { diff --git a/Tests/WaylandClientTests/Popup/PopupDomainTypesTests.swift b/Tests/WaylandClientTests/Popup/PopupDomainTypesTests.swift index 7ee8f02f..f2bf594c 100644 --- a/Tests/WaylandClientTests/Popup/PopupDomainTypesTests.swift +++ b/Tests/WaylandClientTests/Popup/PopupDomainTypesTests.swift @@ -114,22 +114,50 @@ struct PopupDomainTypesTests { func popupConfigureStateLatchesPlacementAndAcksOnlyAfterSurfaceConfigure() throws { let state = PopupConfigureState() + #expect(!state.hasPendingSurfaceConfigure) state.handlePopupConfigure( RawXDGPopupConfigure(x: 4, y: 8, width: 120, height: 64) ) #expect(!state.hasReceivedInitialConfigure) + #expect(!state.hasPendingSurfaceConfigure) #expect(state.consumeLatestConfigure() == nil) let sequence = try #require(state.handleSurfaceConfigure(serial: 99).configure) #expect(state.hasReceivedInitialConfigure) + #expect(state.hasPendingSurfaceConfigure) #expect(sequence.serial == 99) #expect(sequence.placement.origin == LogicalOffset(x: 4, y: 8)) #expect(sequence.placement.size == (try PositiveLogicalSize(width: 120, height: 64))) #expect(state.consumeLatestConfigure() == sequence) + #expect(!state.hasPendingSurfaceConfigure) #expect(state.consumeLatestConfigure() == nil) } + @Test + func pendingPopupConfigurePublishesRedrawWithoutOldBuffer() throws { + let configureState = PopupConfigureState() + configureState.handlePopupConfigure( + RawXDGPopupConfigure(x: 4, y: 8, width: 120, height: 64) + ) + _ = try #require(configureState.handleSurfaceConfigure(serial: 99).configure) + var redraw = WindowRedrawState() + let availability = RedrawBufferAvailability.resolvingPendingConfigure( + configureState.hasPendingSurfaceConfigure, + currentBufferAvailability: { + Issue.record("The old popup buffer pool should not be checked.") + return .unavailable + }() + ) + + let effects = redraw.reduce( + .contentInvalidated, + bufferAvailability: availability + ) + + #expect(effects == [.publishRedrawRequested]) + } + @Test func popupConfigureStateRejectsSurfaceConfigureWithoutPopupPayload() { let state = PopupConfigureState() diff --git a/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift b/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift index 45323b75..6b200b33 100644 --- a/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift +++ b/Tests/WaylandClientTests/Windowing/TopLevelWindowRedrawTests.swift @@ -7,8 +7,8 @@ struct TopLevelWindowRedrawTests { @Test func pendingConfigurePublishesRedrawWithoutOldBuffer() { var redraw = WindowRedrawState() - let availability = TopLevelWindow.resolveRedrawBufferAvailability( - hasPendingSurfaceConfigure: true, + let availability = RedrawBufferAvailability.resolvingPendingConfigure( + true, currentBufferAvailability: { Issue.record("The old buffer pool should not be checked.") return .unavailable @@ -26,8 +26,8 @@ struct TopLevelWindowRedrawTests { @Test func unavailableBufferWithoutPendingConfigureWaits() { var redraw = WindowRedrawState() - let availability = TopLevelWindow.resolveRedrawBufferAvailability( - hasPendingSurfaceConfigure: false, + let availability = RedrawBufferAvailability.resolvingPendingConfigure( + false, currentBufferAvailability: .unavailable )