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/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/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..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 + ) ) } @@ -315,6 +318,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 c11258bd..4722806c 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) @@ -1154,8 +1158,11 @@ extension TopLevelWindow { } private func redrawBufferAvailability() throws -> RedrawBufferAvailability { - surfaceRuntime.redrawBufferAvailability( - matching: try currentSurfaceGeometry().bufferSize.rawSize + try RedrawBufferAvailability.resolvingPendingConfigure( + configureState.hasPendingSurfaceConfigure, + currentBufferAvailability: surfaceRuntime.redrawBufferAvailability( + matching: try currentSurfaceGeometry().bufferSize.rawSize + ) ) } @@ -1302,6 +1309,28 @@ extension TopLevelWindow { return try currentSurfaceGeometry() } + /// Cancels the redraw request held by a graphics frame that wasn't submitted. + /// + /// 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() + + 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 { get throws { connection.preconditionIsOwnerThread() 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..d5176183 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 @@ -30,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 { @@ -94,6 +110,8 @@ struct WindowRedrawState: Equatable, Sendable { case .redrawRequestConsumed: markRedrawRequestConsumed() effects = [] + case .redrawRequestCanceled: + effects = cancelRedrawRequest(bufferAvailability: bufferAvailability) case .drawBlockedByBuffer: markDrawBlockedByBuffer() effects = [] @@ -134,6 +152,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/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..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, @@ -2047,9 +2040,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/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/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 new file mode 100644 index 00000000..6b200b33 --- /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 = RedrawBufferAvailability.resolvingPendingConfigure( + 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 = RedrawBufferAvailability.resolvingPendingConfigure( + false, + currentBufferAvailability: .unavailable + ) + + let effects = redraw.reduce( + .contentInvalidated, + bufferAvailability: availability + ) + + #expect(effects.isEmpty) + #expect(redraw.isWaitingForBuffer) + } +} 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 b94cd34f..62c0b4cc 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -1018,6 +1018,89 @@ 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 + 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) @@ -2277,6 +2360,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 +2396,6 @@ private actor ExternalBufferFakeManagedWindow: WaylandGraphicsManagedWindow { get async throws { geometryValue } } - func setGeometry(_ geometry: SurfaceGeometry) { - geometryValue = geometry - } - func setSurfaceFeedbackSynchronization( _ synchronization: SurfaceSynchronizationCapability? ) { @@ -2542,6 +2622,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( 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()