From 802f7b3b54bdab4181a8c153fe13ffc29ca45613 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:46:16 -0400 Subject: [PATCH 01/10] Reject stale prepared software frames Validate software frame reservations again at commit time and stage all fallible submit work before making buffers busy. Preserve one replacement redraw and expose typed presentation outcomes for async preparation. --- ...dDisplayPresentationIntegrationTests.swift | 277 ++++++++++++++- .../WaylandDisplayPublicAPISurfaceTests.swift | 42 +++ .../generated/shims/request-bridges.c | 6 - .../include/generated/shims/request-bridges.h | 4 - .../include/wayland-client-kit-shims.h | 4 + .../CWaylandProtocols/shims/requests-core.c | 30 ++ .../shims/requests-presentation.c | 23 ++ .../Internal/Display/DisplayCore.swift | 45 ++- .../Popup/PopupRoleSurface+Presentation.swift | 3 +- .../Internal/Popup/PopupRoleSurface.swift | 13 +- .../Surfaces/SurfaceFrameCommitter.swift | 50 ++- .../Surfaces/SurfaceRuntime+Metadata.swift | 10 +- .../Surfaces/SurfaceRuntime+Submit.swift | 7 + .../Internal/Surfaces/SurfaceRuntime.swift | 19 ++ .../SurfaceSubmitConstraintObjects.swift | 1 + .../Surfaces/SurfaceTransactionState.swift | 36 +- .../Internal/Windowing/TopLevelWindow.swift | 116 +++++-- .../WindowModel+SoftwarePresentation.swift | 31 ++ .../Internal/Windowing/WindowModel.swift | 14 +- .../Internal/Windowing/WindowModelTypes.swift | 4 + .../Windowing/WindowOwnedResourceLedger.swift | 20 +- .../Windowing/WindowRedrawState.swift | 13 + .../Windowing/WindowSoftwarePresenter.swift | 22 +- .../WaylandDisplay+SoftwarePresentation.swift | 18 +- .../Public/Display/WaylandDisplay.swift | 5 + .../Window+SoftwareFramePreparation.swift | 81 +++-- .../PresentationFeedbackAndFrameCallbacks.md | 9 + .../WaylandClient.docc/WaylandClient.md | 2 + .../WaylandClient.docc/WindowDrawing.md | 37 +- Sources/WaylandClientKitTool/main.swift | 2 + .../RequestBridgeGenerationTests.swift | 5 +- .../Rendering/SurfaceRuntimeSubmitTests.swift | 17 +- .../WindowSoftwarePresenterTests.swift | 125 ++++++- ...DisplayCoreSoftwarePresentationTests.swift | 48 +++ .../WindowModelPresentationTests.swift | 12 +- ...WindowModelSoftwarePresentationTests.swift | 181 ++++++++++ .../WindowOwnedResourceLedgerTests.swift | 13 + ...ftwarePresentationPublicRequestTests.swift | 319 ++++++++++++++++++ docs/documentation-symbol-coverage.json | 4 +- docs/public-api-audit.md | 9 + docs/public-api-baseline.md | 29 +- docs/release.md | 5 + protocols/request-bridge-policy.json | 1 - 43 files changed, 1541 insertions(+), 171 deletions(-) create mode 100644 Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift create mode 100644 Tests/WaylandClientTests/Windowing/DisplayCoreSoftwarePresentationTests.swift create mode 100644 Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift create mode 100644 Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift diff --git a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift index c4d9aed4..77fa8404 100644 --- a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift +++ b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift @@ -2,6 +2,226 @@ import Testing import WaylandClient extension WaylandDisplayPublicIntegrationTests { + @Test + func asyncSoftwarePresentationSupersedesStalePreparationAndCommitsReplacement() async throws { + try await withPublicConnection { display in + try await exerciseStaleSoftwarePresentation(on: display) + } + } + + @Test + func canceledAsyncSoftwarePreparationIsSupersededAndRetryable() async throws { + try await withPublicConnection { display in + try await exerciseCanceledSoftwarePresentation(on: display) + } + } + + @Test + func closingWindowDuringAsyncSoftwarePreparationReturnsClosedWithoutDrawing() async throws { + try await withPublicConnection { display in + try await exerciseWindowCloseDuringSoftwarePreparation(on: display) + } + } + + @Test + func closingDisplayDuringAsyncSoftwarePreparationReturnsClosedWithoutDrawing() async throws { + try await withPublicConnection { display in + try await exerciseDisplayCloseDuringSoftwarePreparation(on: display) + } + } + + private func exerciseStaleSoftwarePresentation(on display: WaylandDisplay) async throws { + let displayEvents = display.events + let window = try await display.createTopLevelWindow( + configuration: testWindowConfiguration() + ) + + let initialOutcome = try await window.show( + timeoutMilliseconds: publicIntegrationTimeoutMilliseconds, + preparing: { $0.id }, + { preparedID, frame in + #expect(preparedID == frame.id) + fill(frame, color: 0x0014_2434) + } + ) + #expect(initialOutcome == .presented) + #expect( + try await window.redraw( + preparing: { $0.id }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) == .deferred + ) + try await waitForRedrawRequest(for: window, in: displayEvents) + + let gate = AsyncSoftwarePreparationGate() + async let staleOutcome = window.redraw( + requestPresentationFeedback: true, + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) + await gate.waitUntilSuspended() + + async let replacementEvent = displayEvent( + in: displayEvents, + matching: { $0 == .redrawRequested(window.id) }, + after: { + try await window.requestRedraw() + await gate.resumePreparation() + } + ) + + #expect(try await staleOutcome == .superseded) + #expect(try await replacementEvent == .redrawRequested(window.id)) + #expect(try await window.needsRedraw) + let replacementOutcome = try await window.redraw( + preparing: { $0.id }, + { preparedID, frame in + #expect(preparedID == frame.id) + fill(frame, color: 0x0044_2414) + } + ) + #expect(replacementOutcome == .presented) + await window.close() + } + + private func exerciseCanceledSoftwarePresentation(on display: WaylandDisplay) async throws { + let displayEvents = display.events + let window = try await display.createTopLevelWindow( + configuration: testWindowConfiguration() + ) + try await show(window, color: 0x0024_1424) + try await waitForRedrawRequest(for: window, in: displayEvents) + + let gate = AsyncSoftwarePreparationGate() + let outcome = try await withThrowingTaskGroup( + of: SoftwarePresentationOutcome.self + ) { group in + group.addTask { + try await window.redraw( + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) + } + await gate.waitUntilSuspended() + group.cancelAll() + + async let replacementEvent = displayEvent( + in: displayEvents, + matching: { $0 == .redrawRequested(window.id) }, + after: { await gate.resumePreparation() } + ) + let nextOutcome = try await group.next() + #expect(try await replacementEvent == .redrawRequested(window.id)) + return try #require(nextOutcome) + } + #expect(outcome == .superseded) + #expect( + try await window.redraw( + preparing: { $0.id }, + { _, frame in fill(frame, color: 0x0034_1434) } + ) == .presented + ) + await window.close() + } + + private func exerciseWindowCloseDuringSoftwarePreparation( + on display: WaylandDisplay + ) async throws { + let displayEvents = display.events + let window = try await display.createTopLevelWindow( + configuration: testWindowConfiguration() + ) + try await show(window, color: 0x0011_2233) + try await waitForRedrawRequest(for: window, in: displayEvents) + + let gate = AsyncSoftwarePreparationGate() + async let outcome = window.redraw( + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) + await gate.waitUntilSuspended() + await window.close() + await gate.resumePreparation() + + #expect(try await outcome == .closed) + #expect( + try await window.redraw( + preparing: { $0.id }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) == .closed + ) + } + + private func exerciseDisplayCloseDuringSoftwarePreparation( + on display: WaylandDisplay + ) async throws { + let displayEvents = display.events + let window = try await display.createTopLevelWindow( + configuration: testWindowConfiguration() + ) + try await show(window, color: 0x0011_3344) + try await waitForRedrawRequest(for: window, in: displayEvents) + + let gate = AsyncSoftwarePreparationGate() + async let outcome = window.redraw( + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) + await gate.waitUntilSuspended() + await display.close() + await gate.resumePreparation() + + #expect(try await outcome == .closed) + } + + @Test + func throwingAsyncSoftwarePreparationReleasesReservationAndRepublishesRedraw() async throws { + try await withPublicConnection { display in + let displayEvents = display.events + let window = try await display.createTopLevelWindow( + configuration: testWindowConfiguration() + ) + try await show(window, color: 0x0033_2211) + try await waitForRedrawRequest(for: window, in: displayEvents) + + let replacementEvent = try await displayEvent( + in: displayEvents, + matching: { $0 == .redrawRequested(window.id) }, + after: { + do { + _ = try await window.redraw( + preparing: { _ in throw InjectedSoftwarePreparationFailure() }, + { _, _ in throw UnexpectedStaleSoftwareDraw() } + ) + Issue.record("expected software preparation failure") + } catch is InjectedSoftwarePreparationFailure { + // The original preparation failure remains observable. + } + } + ) + #expect(replacementEvent == .redrawRequested(window.id)) + #expect( + try await window.redraw( + preparing: { $0.id }, + { _, frame in fill(frame, color: 0x0022_3344) } + ) == .presented + ) + await window.close() + } + } + @Test func presentationFeedbackReportsUnavailableOrPublishesResult() async throws { try await withPublicConnection { display in @@ -53,10 +273,14 @@ private func expectPresentationFeedback( try await window.requestRedraw() } ) - try await window.requestPresentationFeedback() - try await window.redraw { frame in - fill(frame, color: 0x0044_2414) - } + let outcome = try await window.redraw( + requestPresentationFeedback: true, + preparing: { _ in () }, + { _, frame in + fill(frame, color: 0x0044_2414) + } + ) + #expect(outcome == .presented) let feedback: SurfacePresentationFeedback? do { @@ -98,3 +322,48 @@ private func noteOptionalProtocolRuntimeSkip(test: String, interfaceName: String "Compositor advertised \(interfaceName) for \(test) but did not deliver a terminal event." ) } + +private func waitForRedrawRequest( + for window: Window, + in displayEvents: DisplayEvents +) async throws { + _ = try await displayEvent( + in: displayEvents, + matching: { $0 == .redrawRequested(window.id) }, + after: { try await window.requestRedraw() } + ) +} + +private actor AsyncSoftwarePreparationGate { + private var isSuspended = false + private var suspensionWaiters: [CheckedContinuation] = [] + private var resumeContinuation: CheckedContinuation? + + func suspendPreparation() async { + isSuspended = true + let waiters = suspensionWaiters + suspensionWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + + await withCheckedContinuation { continuation in + resumeContinuation = continuation + } + } + + func waitUntilSuspended() async { + guard !isSuspended else { return } + await withCheckedContinuation { continuation in + suspensionWaiters.append(continuation) + } + } + + func resumePreparation() { + resumeContinuation?.resume() + resumeContinuation = nil + } +} + +private struct UnexpectedStaleSoftwareDraw: Error {} +private struct InjectedSoftwarePreparationFailure: Error {} diff --git a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift index 59026363..2f3c09e3 100644 --- a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift +++ b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift @@ -257,6 +257,48 @@ struct WaylandPresentationAPISurfaceTests { _ = usePresentationFeedbackAPI } + + @Test + func softwarePresentationOutcomesCompileForExternalClients() { + let outcomes: [SoftwarePresentationOutcome] = [ + .presented, + .superseded, + .deferred, + .closed, + ] + + #expect(Set(outcomes).count == 4) + _ = useAtomicSoftwarePresentationAPI + } + + private func useAtomicSoftwarePresentationAPI( + _ window: Window + ) async throws -> [SoftwarePresentationOutcome] { + let shown = try await window.show( + requestPresentationFeedback: true, + preparing: { reservation in reservation.id }, + { _, _ in () } + ) + let redrawn = try await window.redraw( + requestPresentationFeedback: true, + preparing: { reservation in reservation.id }, + { _, _ in () } + ) + let damagedShow = try await window.show( + damage: nil, + timeoutMilliseconds: 1_000, + requestPresentationFeedback: true, + preparing: { reservation in reservation.id }, + { _, _ in () } + ) + let damagedRedraw = try await window.redraw( + damage: nil, + requestPresentationFeedback: true, + preparing: { reservation in reservation.id }, + { _, _ in () } + ) + return [shown, redrawn, damagedShow, damagedRedraw] + } } @Suite("WaylandDisplay data transfer public API surface") diff --git a/Sources/CWaylandProtocols/generated/shims/request-bridges.c b/Sources/CWaylandProtocols/generated/shims/request-bridges.c index 29e63dd4..ffd78b65 100644 --- a/Sources/CWaylandProtocols/generated/shims/request-bridges.c +++ b/Sources/CWaylandProtocols/generated/shims/request-bridges.c @@ -92,12 +92,6 @@ void swl_seat_release( wl_seat_release(object); } -struct wl_callback *swl_surface_frame( - struct wl_surface *object) -{ - return wl_surface_frame(object); -} - void swl_surface_set_buffer_scale( struct wl_surface *object, int32_t request_scale) diff --git a/Sources/CWaylandProtocols/include/generated/shims/request-bridges.h b/Sources/CWaylandProtocols/include/generated/shims/request-bridges.h index 7612faf5..e9f12664 100644 --- a/Sources/CWaylandProtocols/include/generated/shims/request-bridges.h +++ b/Sources/CWaylandProtocols/include/generated/shims/request-bridges.h @@ -8,7 +8,6 @@ #include #include -struct wl_callback; struct wl_compositor; struct wl_data_device; struct wl_data_device_manager; @@ -97,9 +96,6 @@ struct wl_touch *swl_seat_get_touch( void swl_seat_release( struct wl_seat *object); -struct wl_callback *swl_surface_frame( - struct wl_surface *object); - void swl_surface_set_buffer_scale( struct wl_surface *object, int32_t request_scale); diff --git a/Sources/CWaylandProtocols/include/wayland-client-kit-shims.h b/Sources/CWaylandProtocols/include/wayland-client-kit-shims.h index bd7a0ed6..94ce2a78 100644 --- a/Sources/CWaylandProtocols/include/wayland-client-kit-shims.h +++ b/Sources/CWaylandProtocols/include/wayland-client-kit-shims.h @@ -129,6 +129,7 @@ struct wl_buffer *swl_shm_pool_create_buffer( void swl_surface_attach( struct wl_surface *surface, struct wl_buffer *buffer, int32_t x, int32_t y); +struct wl_callback *swl_surface_frame(struct wl_surface *surface); void swl_surface_commit(struct wl_surface *surface); void swl_surface_damage_buffer( struct wl_surface *surface, int32_t x, int32_t y, @@ -837,6 +838,7 @@ enum swl_test_core_request_kind { SWL_TEST_CORE_SUBSURFACE_SET_SYNC = 18, SWL_TEST_CORE_SUBSURFACE_SET_DESYNC = 19, SWL_TEST_CORE_SUBSURFACE_DESTROY = 20, + SWL_TEST_CORE_SURFACE_FRAME = 21, }; enum swl_test_metadata_request_kind { @@ -946,6 +948,7 @@ struct swl_test_core_request_record { int32_t y; uint32_t latest_sequence; uint32_t attach_sequence; + uint32_t frame_sequence; uint32_t damage_sequence; uint32_t commit_sequence; uint32_t buffer_destroy_sequence; @@ -1970,6 +1973,7 @@ struct swl_test_commit_timing_destroy_record swl_test_commit_timing_destroy_record(void); void swl_test_presentation_request_recording_begin(void); +void swl_test_presentation_request_recording_begin_forwarding(void); void swl_test_presentation_request_recording_end(void); struct swl_test_presentation_request_record swl_test_presentation_request_record(void); diff --git a/Sources/CWaylandProtocols/shims/requests-core.c b/Sources/CWaylandProtocols/shims/requests-core.c index c08f7d73..ea0a626b 100644 --- a/Sources/CWaylandProtocols/shims/requests-core.c +++ b/Sources/CWaylandProtocols/shims/requests-core.c @@ -58,6 +58,12 @@ static void swl_surface_attach_default( wl_surface_attach(surface, buffer, x, y); } +static struct wl_callback *swl_surface_frame_default( + struct wl_surface *surface) +{ + return wl_surface_frame(surface); +} + static void swl_surface_commit_default(struct wl_surface *surface) { wl_surface_commit(surface); @@ -199,6 +205,8 @@ static void (*swl_surface_attach_impl)( struct wl_buffer *buffer, int32_t x, int32_t y) = swl_surface_attach_default; +static struct wl_callback *(*swl_surface_frame_impl)( + struct wl_surface *surface) = swl_surface_frame_default; static void (*swl_surface_commit_impl)(struct wl_surface *surface) = swl_surface_commit_default; static void (*swl_surface_damage_impl)( @@ -322,6 +330,17 @@ static void swl_test_surface_attach_record( swl_surface_attach_default(surface, buffer, x, y); } +static struct wl_callback *swl_test_surface_frame_record( + struct wl_surface *surface) +{ + swl_test_record_core_request(SWL_TEST_CORE_SURFACE_FRAME, surface); + swl_test_core_request_latest.frame_sequence = + swl_test_core_request_latest.latest_sequence; + if (swl_test_core_request_forwards_requests) + return swl_surface_frame_default(surface); + return (struct wl_callback *)0x5404; +} + static void swl_test_surface_commit_record(struct wl_surface *surface) { swl_test_record_core_request(SWL_TEST_CORE_SURFACE_COMMIT, surface); @@ -565,6 +584,7 @@ static uint32_t swl_test_proxy_get_id(void *proxy) #define swl_shm_create_pool_impl wl_shm_create_pool #define swl_shm_pool_create_buffer_impl wl_shm_pool_create_buffer #define swl_surface_attach_impl wl_surface_attach +#define swl_surface_frame_impl wl_surface_frame #define swl_surface_commit_impl wl_surface_commit #define swl_surface_damage_impl wl_surface_damage #define swl_surface_damage_buffer_impl wl_surface_damage_buffer @@ -611,6 +631,14 @@ void swl_surface_attach( SWL_CORE_REQUEST_UNLOCK(); } +struct wl_callback *swl_surface_frame(struct wl_surface *surface) +{ + SWL_CORE_REQUEST_LOCK(); + struct wl_callback *callback = swl_surface_frame_impl(surface); + SWL_CORE_REQUEST_UNLOCK(); + return callback; +} + void swl_surface_commit(struct wl_surface *surface) { SWL_CORE_REQUEST_LOCK(); @@ -826,6 +854,7 @@ static void swl_test_core_request_recording_start(int forwards_requests) swl_shm_create_pool_impl = swl_test_shm_create_pool_record; swl_shm_pool_create_buffer_impl = swl_test_shm_pool_create_buffer_record; swl_surface_attach_impl = swl_test_surface_attach_record; + swl_surface_frame_impl = swl_test_surface_frame_record; swl_surface_commit_impl = swl_test_surface_commit_record; swl_surface_damage_impl = swl_test_surface_damage_legacy_record; swl_surface_damage_buffer_impl = swl_test_surface_damage_buffer_record; @@ -869,6 +898,7 @@ void swl_test_core_request_recording_end(void) swl_shm_create_pool_impl = swl_shm_create_pool_default; swl_shm_pool_create_buffer_impl = swl_shm_pool_create_buffer_default; swl_surface_attach_impl = swl_surface_attach_default; + swl_surface_frame_impl = swl_surface_frame_default; swl_surface_commit_impl = swl_surface_commit_default; swl_surface_damage_impl = swl_surface_damage_default; swl_surface_damage_buffer_impl = swl_surface_damage_buffer_default; diff --git a/Sources/CWaylandProtocols/shims/requests-presentation.c b/Sources/CWaylandProtocols/shims/requests-presentation.c index 91b8ea41..4abe9869 100644 --- a/Sources/CWaylandProtocols/shims/requests-presentation.c +++ b/Sources/CWaylandProtocols/shims/requests-presentation.c @@ -59,6 +59,18 @@ static struct wp_presentation_feedback *swl_test_presentation_feedback_record( return feedback; } +static struct wp_presentation_feedback * +swl_test_presentation_feedback_record_forwarding( + struct wp_presentation *presentation, + struct wl_surface *surface) +{ + struct wp_presentation_feedback *feedback = + swl_presentation_feedback_default(presentation, surface); + swl_test_record_presentation_request( + SWL_TEST_PRESENTATION_FEEDBACK, presentation, surface, feedback); + return feedback; +} + static void swl_test_presentation_destroy_record( struct wp_presentation *presentation) { @@ -107,6 +119,17 @@ void swl_test_presentation_request_recording_begin(void) swl_test_presentation_feedback_destroy_record; } +void swl_test_presentation_request_recording_begin_forwarding(void) +{ + swl_test_presentation_request_latest = + (struct swl_test_presentation_request_record){0}; + swl_presentation_feedback_impl = + swl_test_presentation_feedback_record_forwarding; + swl_presentation_destroy_impl = swl_presentation_destroy_default; + swl_presentation_feedback_destroy_impl = + swl_presentation_feedback_destroy_default; +} + void swl_test_presentation_request_recording_end(void) { swl_presentation_feedback_impl = swl_presentation_feedback_default; diff --git a/Sources/WaylandClient/Internal/Display/DisplayCore.swift b/Sources/WaylandClient/Internal/Display/DisplayCore.swift index d72ab1ff..27b7fd9e 100644 --- a/Sources/WaylandClient/Internal/Display/DisplayCore.swift +++ b/Sources/WaylandClient/Internal/Display/DisplayCore.swift @@ -124,9 +124,12 @@ final class DisplayCore: RawInvariantFailureReporter, WindowFailureSink { func reserveSoftwareFrameForShow( _ windowID: WindowID, timeoutMilliseconds: Int32 - ) throws -> SoftwareFrameReservation? { + ) throws -> WindowSoftwareFrameReservationOutcome { try withFatalFailureFinalization { - try requireOpenWindow(windowID).reserveShowSoftwareFrameOnOwnerThread( + guard !isClosed, let window = surfaces.window(windowID) else { + return .closed + } + return try window.reserveShowSoftwareFrameOnOwnerThread( timeoutMilliseconds: timeoutMilliseconds ) } @@ -163,9 +166,12 @@ final class DisplayCore: RawInvariantFailureReporter, WindowFailureSink { func reserveSoftwareFrameForRedraw( _ windowID: WindowID - ) throws -> SoftwareFrameReservation? { + ) throws -> WindowSoftwareFrameReservationOutcome { try withFatalFailureFinalization { - try requireOpenWindow(windowID).reserveRedrawSoftwareFrameOnOwnerThread() + guard !isClosed, let window = surfaces.window(windowID) else { + return .closed + } + return try window.reserveRedrawSoftwareFrameOnOwnerThread() } } @@ -178,24 +184,28 @@ final class DisplayCore: RawInvariantFailureReporter, WindowFailureSink { requestPresentationFeedback: Bool, damage: SurfaceDamageRegion?, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void - ) throws { + ) throws -> SoftwarePresentationOutcome { try withFatalFailureFinalization { - let window = try requireOpenWindow(windowID) - let presentationFeedback = try presentationFeedbackCommitRequest( - for: window, - windowID: windowID, - isRequested: requestPresentationFeedback - ) - try window.submitReservedSoftwareFrameOnOwnerThread( + guard !isClosed, let window = surfaces.window(windowID) else { + return .closed + } + let outcome = try window.submitReservedSoftwareFrameOnOwnerThread( reservation, submitConstraints: submitConstraints, metadata: metadata, damage: damage, - presentationFeedback: presentationFeedback, + makePresentationFeedback: { [self] in + try presentationFeedbackCommitRequest( + for: window, + windowID: windowID, + isRequested: requestPresentationFeedback + ) + }, draw ) - guard !isClosed, let activeSession else { return } + guard !isClosed, let activeSession else { return .closed } publishSessionEvents(activeSession) + return outcome } } @@ -203,10 +213,9 @@ final class DisplayCore: RawInvariantFailureReporter, WindowFailureSink { _ windowID: WindowID, reservation: SoftwareFrameReservation ) throws { - try withFatalFailureFinalization { - try requireOpenWindow(windowID).cancelSoftwareFrameReservationOnOwnerThread( - reservation - ) + withFatalFailureFinalization { + guard !isClosed, let window = surfaces.window(windowID) else { return } + window.cancelSoftwareFrameReservationOnOwnerThread(reservation) } } diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift index 61e235f0..4975cdca 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift @@ -85,8 +85,9 @@ extension PopupRoleSurface { } do { + let stagedCommit = try stageSurfaceFrameCommit(preparedCommit) _ = drawingBuffer.markBusy(commitGeneration: request.generation) - try commitSurfaceFrame(preparedCommit) + commitSurfaceFrame(stagedCommit) } catch { pendingFrameRegistration = nil cancelSurfaceFrameCallback() diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift index 8f49b3b5..57227518 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift @@ -376,15 +376,22 @@ extension PopupRoleSurface { ) } - package func commitSurfaceFrame( + package func stageSurfaceFrameCommit( _ preparedCommit: PreparedSurfaceFrameCommit - ) throws { - try SurfaceFrameCommitter.commit( + ) throws -> StagedSurfaceFrameCommit { + try SurfaceFrameCommitter.stage( preparedCommit, runtime: &surfaceRuntime ) } + package func commitSurfaceFrame(_ stagedCommit: StagedSurfaceFrameCommit) { + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &surfaceRuntime + ) + } + package func resetTransientSurfaceTransactionState() { surfaceRuntime.resetTransientTransactionState() } diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift index b911f609..5829a141 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift @@ -59,11 +59,16 @@ package struct PreparedSurfaceFrameCommit { let scaleInstallation: SurfaceScaleInstallation let generation: UInt64 let plan: SurfaceCommitPlan - let submitConstraints: SurfaceSubmitConstraints - let metadata: SurfaceCommitMetadata + let submitConstraints: ResolvedSurfaceSubmitConstraints? + let metadata: ResolvedSurfaceCommitMetadata? let payload: SurfaceCommitPayload } +package struct StagedSurfaceFrameCommit { + let preparedCommit: PreparedSurfaceFrameCommit + let committedFrame: SurfaceCommittedFrame +} + enum SurfaceFrameCommitter { static func requestFrameCallback( on surface: RawSurface, @@ -107,24 +112,51 @@ enum SurfaceFrameCommitter { payload: request.payload ) try request.metadata.validate(capabilities: runtime.capabilitySnapshot()) + let submitConstraints = try runtime.resolvedSubmitConstraints(request.submitConstraints) + let metadata = try runtime.resolvedCommitMetadata(request.metadata) return PreparedSurfaceFrameCommit( surface: request.surface, scaleInstallation: request.scaleInstallation, generation: request.generation, plan: plan, - submitConstraints: request.submitConstraints, - metadata: request.metadata, + submitConstraints: submitConstraints, + metadata: metadata, payload: request.payload ) } + static func stage( + _ preparedCommit: PreparedSurfaceFrameCommit, + runtime: inout SurfaceRuntime + ) throws -> StagedSurfaceFrameCommit { + let committedFrame = try runtime.committedFrameCandidate( + generation: preparedCommit.generation, + plan: preparedCommit.plan, + payload: preparedCommit.payload.committedPayload + ) + try preparedCommit.submitConstraints?.apply() + preparedCommit.metadata?.apply() + return StagedSurfaceFrameCommit( + preparedCommit: preparedCommit, + committedFrame: committedFrame + ) + } + @discardableResult static func commit( _ preparedCommit: PreparedSurfaceFrameCommit, runtime: inout SurfaceRuntime ) throws -> SurfaceCommitPlan { - try runtime.applySubmitConstraints(preparedCommit.submitConstraints) - try runtime.applyCommitMetadata(preparedCommit.metadata) + let stagedCommit = try stage(preparedCommit, runtime: &runtime) + return commit(stagedCommit, runtime: &runtime) + } + + @discardableResult + static func commit( + _ stagedCommit: StagedSurfaceFrameCommit, + runtime: inout SurfaceRuntime + ) -> SurfaceCommitPlan { + let preparedCommit = stagedCommit.preparedCommit preparedCommit.surface.setBufferScale(preparedCommit.plan.bufferScale) preparedCommit.scaleInstallation.applyViewportDestinationIfNeeded( preparedCommit.plan.viewportDestination @@ -137,11 +169,7 @@ enum SurfaceFrameCommitter { break } preparedCommit.surface.commit() - try runtime.prepareCommittedFrame( - generation: preparedCommit.generation, - plan: preparedCommit.plan, - payload: preparedCommit.payload.committedPayload - ) + runtime.recordValidatedCommittedFrame(stagedCommit.committedFrame) runtime.markSubmitConstraintsCommitted() return preparedCommit.plan } diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Metadata.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Metadata.swift index e7706d91..35d889af 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Metadata.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Metadata.swift @@ -127,7 +127,13 @@ extension SurfaceRuntime { func preflightCommitMetadata( _ metadata: SurfaceCommitMetadata ) throws(SurfaceCommitMetadataError) { - guard let objects = surfaceObjects else { return } - _ = try objects.metadataObjects.preflight(metadata) + _ = try resolvedCommitMetadata(metadata) + } + + func resolvedCommitMetadata( + _ metadata: SurfaceCommitMetadata + ) throws(SurfaceCommitMetadataError) -> ResolvedSurfaceCommitMetadata? { + guard let objects = surfaceObjects else { return nil } + return try objects.metadataObjects.preflight(metadata) } } diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Submit.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Submit.swift index b3250b38..7672d094 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Submit.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime+Submit.swift @@ -73,6 +73,13 @@ extension SurfaceRuntime { } } + func resolvedSubmitConstraints( + _ constraints: SurfaceSubmitConstraints + ) throws(SurfaceSubmitConstraintError) -> ResolvedSurfaceSubmitConstraints? { + guard let objects = surfaceObjects else { return nil } + return try objects.submitConstraintObjects.preflight(constraints) + } + mutating func markSubmitConstraintsCommitted() { updateSurfaceObjects { objects in objects.submitConstraintObjects.markCommitted() diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime.swift index 2d683fbe..027a7d0a 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceRuntime.swift @@ -448,6 +448,25 @@ extension SurfaceRuntime { try transactionState.validateCommittedFrameCandidate(generation: generation) } + func committedFrameCandidate( + generation: UInt64, + plan: SurfaceCommitPlan, + payload: SurfaceCommittedPayload = .buffer + ) throws -> SurfaceCommittedFrame { + let transactionState = surfaceObjects?.transactionState ?? SurfaceTransactionState() + return try transactionState.committedFrameCandidate( + generation: generation, + plan: plan, + payload: payload + ) + } + + mutating func recordValidatedCommittedFrame(_ committedFrame: SurfaceCommittedFrame) { + updateSurfaceObjects { objects in + objects.transactionState.recordValidatedCommittedFrame(committedFrame) + } + } + mutating func prepareCommittedFrame( generation: UInt64, plan: SurfaceCommitPlan, diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceSubmitConstraintObjects.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceSubmitConstraintObjects.swift index a5081ce9..4f531579 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceSubmitConstraintObjects.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceSubmitConstraintObjects.swift @@ -89,6 +89,7 @@ struct SurfaceSubmitConstraintObjects { case .implicit: return nil case .explicit(let acquire, let release): + guard acquire != nil || release != nil else { return nil } guard let synchronization else { throw .explicitSyncUnavailable } diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceTransactionState.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceTransactionState.swift index d1474b7b..f8bb9b06 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceTransactionState.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceTransactionState.swift @@ -135,6 +135,20 @@ package struct SurfaceTransactionState: Equatable, Sendable { plan: SurfaceCommitPlan, payload: SurfaceCommittedPayload = .buffer ) throws { + recordValidatedCommittedFrame( + try committedFrameCandidate( + generation: generation, + plan: plan, + payload: payload + ) + ) + } + + package func committedFrameCandidate( + generation: UInt64, + plan: SurfaceCommitPlan, + payload: SurfaceCommittedPayload = .buffer + ) throws -> SurfaceCommittedFrame { let acknowledgedConfigureSerial = try validateCommittedFrameCandidate( generation: generation ) @@ -147,20 +161,26 @@ package struct SurfaceTransactionState: Equatable, Sendable { actual: generation ) } - if let lastGeneration = lastCommittedFrame?.generation, generation <= lastGeneration { - throw SurfaceTransactionError.commitGenerationDidNotAdvance( - previous: lastGeneration, - actual: generation - ) - } - lastCommittedFrame = SurfaceCommittedFrame( + return SurfaceCommittedFrame( generation: generation, configureSerial: acknowledgedConfigureSerial, plan: plan, payload: payload ) - if payload == .buffer { + } + + package mutating func recordValidatedCommittedFrame( + _ committedFrame: SurfaceCommittedFrame + ) { + precondition(acknowledgedConfigureSerial == committedFrame.configureSerial) + precondition(pendingFrameCallbackGeneration == committedFrame.generation) + precondition( + lastCommittedFrame.map { committedFrame.generation > $0.generation } ?? true + ) + + lastCommittedFrame = committedFrame + if committedFrame.payload == .buffer { hasCommittedBufferContent = true } } diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index 4722806c..61735beb 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -430,9 +430,11 @@ package final class TopLevelWindow { return presentationRequest } - private func reserveSoftwareFrameForCurrentRedraw() throws -> SoftwareFrameReservation? { - guard !model.isClosed else { return nil } - guard let request = try consumeSoftwarePresentationRequest() else { return nil } + private func reserveSoftwareFrameForCurrentRedraw() + throws -> WindowSoftwareFrameReservationOutcome + { + guard !model.isClosed else { return .closed } + guard let request = try consumeSoftwarePresentationRequest() else { return .deferred } try interpretWindowEffects( model.reduce(.presentationStarted(request)) @@ -454,7 +456,9 @@ package final class TopLevelWindow { hasPendingFrameRegistration: pendingFrameRegistration != nil ) try interpretSoftwarePresentationFollowUp(result.followUp) - guard let reservedFrame = result.reservedFrame else { return nil } + guard let reservedFrame = result.reservedFrame else { + return model.isClosed ? .closed : .deferred + } softwarePresentationCoordinator.register( PendingSoftwareFrameReservation( @@ -464,7 +468,7 @@ package final class TopLevelWindow { ), for: reservedFrame.reservation.reservationID ) - return reservedFrame.reservation + return .reserved(reservedFrame.reservation) } catch let failure as WindowSoftwarePresentationFailure { failActivePresentation( generation: request.generation, @@ -646,32 +650,29 @@ package final class TopLevelWindow { submitConstraints: SurfaceSubmitConstraints, metadata: SurfaceCommitMetadata, damage: SurfaceDamageRegion?, - presentationFeedback: WindowPresentationFeedbackCommitRequest?, + makePresentationFeedback: () throws -> WindowPresentationFeedbackCommitRequest?, _ draw: (borrowing SoftwareFrame) throws -> Void - ) throws -> RedrawOutcome { + ) throws -> SoftwarePresentationOutcome { guard !model.isClosed else { cancelSoftwareFrameReservation(reservation) - return .skippedClosed + return .closed } - guard - let pendingReservation = softwarePresentationCoordinator.take( - reservation.reservationID - ) - else { - throw ClientError.invalidWindowState( - .message("software frame reservation is not active") - ) + guard let pendingReservation = softwarePresentationCoordinator.take(reservation) else { + return model.isClosed ? .closed : .superseded } do { _ = try consumeLatestConfigureIfAvailable() - guard try currentSurfaceGeometry() == pendingReservation.geometry else { - pendingReservation.reservedFrame.drawingBuffer.discard() - resetTransientState() - try markNeedsRedraw(bufferAvailability: try redrawBufferAvailability()) - return .skippedPendingFrame + let currentGeometry = try currentSurfaceGeometry() + guard !model.isClosed, + model.isCurrentSoftwarePresentation(pendingReservation.request), + currentGeometry == pendingReservation.geometry, + !Task.isCancelled + else { + return try supersedeSoftwarePresentation(pendingReservation) } + let presentationFeedback = try makePresentationFeedback() let result = try softwarePresenter().presentReserved( pendingReservation.reservedFrame, context: WindowSoftwarePresentationContext( @@ -687,7 +688,14 @@ package final class TopLevelWindow { pendingFrameRegistration: &pendingFrameRegistration ) try interpretSoftwarePresentationFollowUp(result.followUp) - return result.outcome + switch result.outcome { + case .presented: + return .presented + case .skippedClosed: + return .closed + case .skippedPendingFrame, .waitingForBuffer: + return .deferred + } } catch let failure as WindowSoftwarePresentationFailure { pendingReservation.reservedFrame.drawingBuffer.discard() failActivePresentation( @@ -708,10 +716,52 @@ package final class TopLevelWindow { } } + private func supersedeSoftwarePresentation( + _ pendingReservation: PendingSoftwareFrameReservation + ) throws -> SoftwarePresentationOutcome { + pendingReservation.reservedFrame.drawingBuffer.discard() + guard !model.isClosed, + case .drawing(let activeRequest) = model.presentation, + activeRequest == pendingReservation.request + else { + return model.isClosed ? .closed : .superseded + } + + try interpretWindowEffects( + model.reduce( + .softwarePresentationSuperseded( + generation: pendingReservation.request.generation, + bufferAvailability: .available + ) + ) + ) + return .superseded + } + private func cancelSoftwareFrameReservation(_ reservation: SoftwareFrameReservation) { - guard softwarePresentationCoordinator.cancel(reservation.reservationID) else { return } - resetTransientState() - publishDeferredRedrawAfterReservationCancellation() + guard let pendingReservation = softwarePresentationCoordinator.cancel(reservation) else { + return + } + pendingReservation.reservedFrame.drawingBuffer.discard() + guard !model.isClosed, + case .drawing(let activeRequest) = model.presentation, + activeRequest == pendingReservation.request + else { + return + } + + do { + try interpretWindowEffects( + model.reduce( + .softwarePresentationSuperseded( + generation: pendingReservation.request.generation, + bufferAvailability: .available + ) + ) + ) + } catch { + preconditionFailure("Unexpected software-presentation cancellation error: \(error)") + } } private func cancelAllSoftwareFrameReservations() { @@ -1920,7 +1970,7 @@ extension TopLevelWindow { package func reserveShowSoftwareFrameOnOwnerThread( timeoutMilliseconds: Int32 = defaultConfigureTimeoutMS - ) throws -> SoftwareFrameReservation? { + ) throws -> WindowSoftwareFrameReservationOutcome { connection.preconditionIsOwnerThread() if model.currentConfiguration == nil { @@ -1974,10 +2024,12 @@ extension TopLevelWindow { ) } - package func reserveRedrawSoftwareFrameOnOwnerThread() throws -> SoftwareFrameReservation? { + package func reserveRedrawSoftwareFrameOnOwnerThread() + throws -> WindowSoftwareFrameReservationOutcome + { connection.preconditionIsOwnerThread() - guard !model.isClosed else { return nil } + guard !model.isClosed else { return .closed } _ = try consumeLatestConfigureIfAvailable() return try reserveSoftwareFrameForCurrentRedraw() @@ -1988,17 +2040,17 @@ extension TopLevelWindow { submitConstraints: SurfaceSubmitConstraints = .default, metadata: SurfaceCommitMetadata = .default, damage: SurfaceDamageRegion? = nil, - presentationFeedback: WindowPresentationFeedbackCommitRequest? = nil, + makePresentationFeedback: () throws -> WindowPresentationFeedbackCommitRequest? = { nil }, _ draw: (borrowing SoftwareFrame) throws -> Void - ) throws { + ) throws -> SoftwarePresentationOutcome { connection.preconditionIsOwnerThread() - _ = try submitReservedSoftwareFrame( + return try submitReservedSoftwareFrame( reservation, submitConstraints: submitConstraints, metadata: metadata, damage: damage, - presentationFeedback: presentationFeedback, + makePresentationFeedback: makePresentationFeedback, draw ) } diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift new file mode 100644 index 00000000..01b9ee9a --- /dev/null +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift @@ -0,0 +1,31 @@ +extension WindowModel { + func isCurrentSoftwarePresentation(_ request: PresentationRequest) -> Bool { + guard case .active(let activeState) = lifecycle else { return false } + + return activeState.presentation == .drawing(request: request) + && activeState.configure == request.configuration + && activeState.redraw.generationForCurrentDraw == request.generation + } + + mutating func reduceSoftwarePresentationSuperseded( + _ generation: UInt64, + _ bufferAvailability: RedrawBufferAvailability + ) throws -> [WindowEffect] { + let windowID = id + return try transitionActiveWindowState { activeState in + try Self.requireActivePresentation( + generation: generation, + in: activeState, + windowID: windowID + ) + activeState.presentation = .idle + return Self.mapRedrawEffects( + activeState.redraw.supersedeSoftwarePresentation( + bufferAvailability: bufferAvailability + ), + in: activeState, + windowID: windowID + ) + } + } +} diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift index 956340f3..a80abaef 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift @@ -87,13 +87,13 @@ 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 .graphicsPreviewPresentationCanceled(let availability): + return reduceGraphicsPreviewPresentationCanceled(bufferAvailability: availability) case .presentationStarted(let request): return try reducePresentationStarted(request) case .presentationBlockedByBuffer: return try reducePresentationBlockedByBuffer() + case .softwarePresentationSuperseded(let generation, let availability): + return try reduceSoftwarePresentationSuperseded(generation, availability) case .presentationSucceeded(let generation, let bufferAvailability): return try reducePresentationSucceeded( generation: generation, @@ -135,7 +135,7 @@ extension WindowModel { return state } - private mutating func transitionActiveWindowState( + mutating func transitionActiveWindowState( _ update: (inout ActiveWindowState) throws -> [WindowEffect] ) throws -> [WindowEffect] { var activeState = try requireActiveWindowState() @@ -405,7 +405,7 @@ extension WindowModel { Self.mapRedrawEffects(effects, windowID: id) } - private static func mapRedrawEffects( + static func mapRedrawEffects( _ effects: [WindowRedrawEffect], in activeState: ActiveWindowState, windowID: WindowID @@ -439,7 +439,7 @@ extension WindowModel { return generation } - private static func requireActivePresentation( + static func requireActivePresentation( generation actualGeneration: UInt64, in activeState: ActiveWindowState, windowID: WindowID diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift index 08eb4279..72378bca 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift @@ -17,6 +17,10 @@ package enum WindowEvent: Equatable, Sendable { ) case presentationStarted(PresentationRequest) case presentationBlockedByBuffer + case softwarePresentationSuperseded( + generation: UInt64, + bufferAvailability: RedrawBufferAvailability + ) case presentationSucceeded(generation: UInt64, bufferAvailability: RedrawBufferAvailability) case externalPresentationSucceeded( generation: UInt64, diff --git a/Sources/WaylandClient/Internal/Windowing/WindowOwnedResourceLedger.swift b/Sources/WaylandClient/Internal/Windowing/WindowOwnedResourceLedger.swift index df05fe99..826e5f1a 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowOwnedResourceLedger.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowOwnedResourceLedger.swift @@ -26,6 +26,14 @@ final class WindowOwnedResourceLedger { resources.removeValue(forKey: identity) } + func take( + _ identity: Identity, + matching predicate: (Resource) -> Bool + ) -> Resource? { + guard let resource = resources[identity], predicate(resource) else { return nil } + return resources.removeValue(forKey: identity) + } + func retire(_ identity: Identity) { guard let resource = resources.removeValue(forKey: identity) else { return } retireResource(resource) @@ -93,14 +101,14 @@ final class WindowSoftwareReservationCoordinator { _ = resources.insert(reservation, for: identity) } - func take(_ identity: SoftwareFrameReservationToken) -> PendingSoftwareFrameReservation? { - resources.take(identity) + func take(_ reservation: SoftwareFrameReservation) -> PendingSoftwareFrameReservation? { + resources.take(reservation.reservationID) { pendingReservation in + pendingReservation.reservedFrame.reservation == reservation + } } - func cancel(_ identity: SoftwareFrameReservationToken) -> Bool { - guard let reservation = resources.take(identity) else { return false } - reservation.reservedFrame.drawingBuffer.discard() - return true + func cancel(_ reservation: SoftwareFrameReservation) -> PendingSoftwareFrameReservation? { + take(reservation) } func close() { diff --git a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift index d5176183..24b6b65e 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift @@ -163,6 +163,19 @@ extension WindowRedrawState { return publishIfNeeded(bufferAvailability: bufferAvailability) } + mutating func supersedeSoftwarePresentation( + bufferAvailability: RedrawBufferAvailability + ) -> [WindowRedrawEffect] { + switch pacing { + case .frameReady(.outstanding), .waitingForBuffer: + pacing = .frameReady(.none) + case .frameReady(.none), .waitingForFrame: + break + } + + return publishIfNeeded(bufferAvailability: bufferAvailability) + } + private mutating func markDrawBlockedByBuffer() { guard isDirty else { return } pacing = .waitingForBuffer diff --git a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift index 07b5906f..34f785f3 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift @@ -17,6 +17,12 @@ struct WindowSoftwareFrameReservationResult { let followUp: WindowSoftwarePresentationFollowUp? } +package enum WindowSoftwareFrameReservationOutcome: Equatable, Sendable { + case reserved(SoftwareFrameReservation) + case deferred + case closed +} + enum WindowSoftwarePresentationFollowUp { case fail(generation: UInt64, PresentationError) case blockedByBuffer @@ -461,11 +467,15 @@ struct WindowSoftwarePresenter { drawingBuffer: inout RawBuffer.DrawingBuffer ) throws { do { - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - try SurfaceFrameCommitter.commit( + let stagedCommit = try SurfaceFrameCommitter.stage( context.preparedCommit, runtime: &runtime ) + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &runtime + ) } catch { throw WindowSoftwarePresentationFailure( presentationError: .surfaceCommit(String(describing: error)), @@ -517,11 +527,15 @@ struct WindowSoftwarePresenter { drawingBuffer: RawBuffer.ReservedDrawingBuffer ) throws { do { - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - try SurfaceFrameCommitter.commit( + let stagedCommit = try SurfaceFrameCommitter.stage( context.preparedCommit, runtime: &runtime ) + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &runtime + ) } catch { throw WindowSoftwarePresentationFailure( presentationError: .surfaceCommit(String(describing: error)), diff --git a/Sources/WaylandClient/Public/Display/WaylandDisplay+SoftwarePresentation.swift b/Sources/WaylandClient/Public/Display/WaylandDisplay+SoftwarePresentation.swift index 9bca12bc..37d49037 100644 --- a/Sources/WaylandClient/Public/Display/WaylandDisplay+SoftwarePresentation.swift +++ b/Sources/WaylandClient/Public/Display/WaylandDisplay+SoftwarePresentation.swift @@ -39,8 +39,9 @@ extension WaylandDisplay { package func reserveSoftwareFrameForShow( _ windowID: WindowID, timeoutMilliseconds: Int32 - ) throws -> SoftwareFrameReservation? { - try requireCore().reserveSoftwareFrameForShow( + ) throws -> WindowSoftwareFrameReservationOutcome { + guard let core = coreIfActive() else { return .closed } + return try core.reserveSoftwareFrameForShow( windowID, timeoutMilliseconds: timeoutMilliseconds ) @@ -80,8 +81,9 @@ extension WaylandDisplay { package func reserveSoftwareFrameForRedraw( _ windowID: WindowID - ) throws -> SoftwareFrameReservation? { - try requireCore().reserveSoftwareFrameForRedraw(windowID) + ) throws -> WindowSoftwareFrameReservationOutcome { + guard let core = coreIfActive() else { return .closed } + return try core.reserveSoftwareFrameForRedraw(windowID) } // swiftlint:disable:next function_parameter_count @@ -93,8 +95,9 @@ extension WaylandDisplay { requestPresentationFeedback: Bool, damage: SurfaceDamageRegion? = nil, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void - ) throws { - try requireCore().submitReservedSoftwareFrame( + ) throws -> SoftwarePresentationOutcome { + guard let core = coreIfActive() else { return .closed } + return try core.submitReservedSoftwareFrame( windowID, reservation: reservation, submitConstraints: submitConstraints, @@ -109,6 +112,7 @@ extension WaylandDisplay { _ windowID: WindowID, reservation: SoftwareFrameReservation ) throws { - try requireCore().cancelSoftwareFrameReservation(windowID, reservation: reservation) + guard let core = coreIfActive() else { return } + try core.cancelSoftwareFrameReservation(windowID, reservation: reservation) } } diff --git a/Sources/WaylandClient/Public/Display/WaylandDisplay.swift b/Sources/WaylandClient/Public/Display/WaylandDisplay.swift index 33499197..737affdd 100644 --- a/Sources/WaylandClient/Public/Display/WaylandDisplay.swift +++ b/Sources/WaylandClient/Public/Display/WaylandDisplay.swift @@ -314,6 +314,11 @@ public actor WaylandDisplay { return core } + + func coreIfActive() -> DisplayCore? { + guard case .active(let core, _) = lifecycle else { return nil } + return core + } } extension WaylandDisplay { diff --git a/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift b/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift index 999c4b3b..884f13a9 100644 --- a/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift +++ b/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift @@ -1,95 +1,130 @@ +/// The terminal disposition of an asynchronously prepared software frame. +public enum SoftwarePresentationOutcome: Equatable, Sendable { + /// The frame was committed to the window's Wayland surface. + case presented + /// Preparation completed after the transaction became stale or was canceled + /// during final validation. + case superseded + /// No presentation started because redraw pacing or buffers deferred it. + case deferred + /// The window or its display closed before the frame could be committed. + case closed +} + extension Window { + @discardableResult public func show( timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, + requestPresentationFeedback: Bool = false, preparing prepare: sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void - ) async throws { + ) async throws -> SoftwarePresentationOutcome { try await show( damage: nil, timeoutMilliseconds: timeoutMilliseconds, + requestPresentationFeedback: requestPresentationFeedback, preparing: prepare, draw ) } + @discardableResult public func show( damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, + requestPresentationFeedback: Bool = false, preparing prepare: sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void - ) async throws { - guard - let reservation = try await display.reserveSoftwareFrameForShow( - id, - timeoutMilliseconds: timeoutMilliseconds - ) - else { - return + ) async throws -> SoftwarePresentationOutcome { + let reservationOutcome = try await display.reserveSoftwareFrameForShow( + id, + timeoutMilliseconds: timeoutMilliseconds + ) + let reservation: SoftwareFrameReservation + switch reservationOutcome { + case .reserved(let reservedFrame): + reservation = reservedFrame + case .deferred: + return .deferred + case .closed: + return .closed } do { let prepared = try await prepare(reservation) - try await display.submitReservedSoftwareFrame( + return try await display.submitReservedSoftwareFrame( id, reservation: reservation, submitConstraints: .default, metadata: .default, - requestPresentationFeedback: false, + requestPresentationFeedback: requestPresentationFeedback, damage: damage ) { frame in try draw(prepared, frame) } } catch { - let submitError = error + let submissionError = error do { try await display.cancelSoftwareFrameReservation(id, reservation: reservation) } catch { - throw submitError + throw submissionError } - throw submitError + throw submissionError } } + @discardableResult public func redraw( + requestPresentationFeedback: Bool = false, preparing prepare: sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void - ) async throws { + ) async throws -> SoftwarePresentationOutcome { try await redraw( damage: nil, + requestPresentationFeedback: requestPresentationFeedback, preparing: prepare, draw ) } + @discardableResult public func redraw( damage: SurfaceDamageRegion?, + requestPresentationFeedback: Bool = false, preparing prepare: sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void - ) async throws { - guard let reservation = try await display.reserveSoftwareFrameForRedraw(id) else { - return + ) async throws -> SoftwarePresentationOutcome { + let reservationOutcome = try await display.reserveSoftwareFrameForRedraw(id) + let reservation: SoftwareFrameReservation + switch reservationOutcome { + case .reserved(let reservedFrame): + reservation = reservedFrame + case .deferred: + return .deferred + case .closed: + return .closed } do { let prepared = try await prepare(reservation) - try await display.submitReservedSoftwareFrame( + return try await display.submitReservedSoftwareFrame( id, reservation: reservation, submitConstraints: .default, metadata: .default, - requestPresentationFeedback: false, + requestPresentationFeedback: requestPresentationFeedback, damage: damage ) { frame in try draw(prepared, frame) } } catch { - let submitError = error + let submissionError = error do { try await display.cancelSoftwareFrameReservation(id, reservation: reservation) } catch { - throw submitError + throw submissionError } - throw submitError + throw submissionError } } } diff --git a/Sources/WaylandClient/WaylandClient.docc/PresentationFeedbackAndFrameCallbacks.md b/Sources/WaylandClient/WaylandClient.docc/PresentationFeedbackAndFrameCallbacks.md index b6bc021e..9e1641e2 100644 --- a/Sources/WaylandClient/WaylandClient.docc/PresentationFeedbackAndFrameCallbacks.md +++ b/Sources/WaylandClient/WaylandClient.docc/PresentationFeedbackAndFrameCallbacks.md @@ -24,6 +24,15 @@ presentation feedback. WaylandClientKit owns requests, event correlation, and stream termination. Frameworks own animation, frame budgeting, and fallback scheduling. +For asynchronously prepared software frames, pass +`requestPresentationFeedback: true` to +``Window/show(damage:timeoutMilliseconds:requestPresentationFeedback:preparing:_:)`` +or ``Window/redraw(damage:requestPresentationFeedback:preparing:_:)``. The +feedback object is requested inside the same callback-feedback-commit sequence +as the accepted frame. Superseded, deferred, and closed attempts request neither +feedback nor a frame callback and do not commit. Requesting feedback throws when +the compositor does not provide presentation-time support. + ## Example See `PresentationFeedbackAnimation` in `Examples/PresentationFeedbackAnimation`. diff --git a/Sources/WaylandClient/WaylandClient.docc/WaylandClient.md b/Sources/WaylandClient/WaylandClient.docc/WaylandClient.md index 773553a8..b45dbc37 100644 --- a/Sources/WaylandClient/WaylandClient.docc/WaylandClient.md +++ b/Sources/WaylandClient/WaylandClient.docc/WaylandClient.md @@ -48,6 +48,8 @@ targets. ### Rendering - ``SoftwareFrame`` +- ``SoftwareFrameReservation`` +- ``SoftwarePresentationOutcome`` - ``SurfaceGeometry`` - ``SurfaceScale`` - ``OutputSnapshot`` diff --git a/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md b/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md index f363cef8..9c13160b 100644 --- a/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md +++ b/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md @@ -9,14 +9,45 @@ Application code draws into the frame payload and asks the window to present it. Each software frame reports an opaque ``SoftwareFrameBufferID`` for the borrowed SHM buffer. ``SoftwareFrame/withBuffer(_:)`` provides scoped access to XRGB8888 bytes, stride, and geometry. The byte span is valid only inside the closure. -Use ``Window/show(damage:timeoutMilliseconds:preparing:_:)`` and -``Window/redraw(damage:preparing:_:)`` when expensive scene preparation should +Use +``Window/show(damage:timeoutMilliseconds:requestPresentationFeedback:preparing:_:)`` +and ``Window/redraw(damage:requestPresentationFeedback:preparing:_:)`` when +expensive scene preparation should begin after WaylandClientKit has selected the authoritative software frame geometry and reusable buffer identity. The preparation closure receives a ``SoftwareFrameReservation`` with buffer dimensions, stride, geometry, and -opaque identity. Mutable bytes remain scoped to the final draw closure. +opaque identity. Mutable bytes remain scoped to the final draw closure. These +operations are latest-wins transactions: after preparation resumes, +WaylandClientKit revalidates the exact reservation, window, configure, +authoritative geometry, redraw generation, and task cancellation before it +borrows mutable bytes. + +If the prepared generation is still current, WaylandClientKit draws, requests +the frame callback and optional presentation feedback, commits the surface, and +records the generation without another suspension point. If it is no longer +current, the library discards the reservation without invoking the draw closure +or issuing frame-callback, presentation-feedback, or surface-commit requests. +The newest dirty generation remains pending and produces one replacement redraw +request when pacing and buffer availability allow it. GPU allocation experiments remain package-internal preview code. +## Asynchronous Preparation Outcomes + +Asynchronous software presentation returns ``SoftwarePresentationOutcome``: + +- `.presented` means the prepared frame was committed. +- `.superseded` means preparation finished after the reserved generation became + stale, or the task was canceled after preparation without throwing. The + prepared value is discarded and the newest redraw remains eligible. +- `.deferred` means no transaction began because redraw pacing or software-buffer + availability did not permit one. The preparation closure is not invoked. +- `.closed` means the window or its display closed before the frame could be + committed. The draw closure is not invoked. + +Errors thrown by preparation or drawing remain errors. The reservation is +released and eligible redraw work is republished before the original operation +error is re-thrown. + ``PopupSurface`` follows the same ownership rule as windows: it is a managed surface, but popup placement and dismissal are governed by xdg-shell. diff --git a/Sources/WaylandClientKitTool/main.swift b/Sources/WaylandClientKitTool/main.swift index d900d10c..7980fbc0 100644 --- a/Sources/WaylandClientKitTool/main.swift +++ b/Sources/WaylandClientKitTool/main.swift @@ -1183,6 +1183,7 @@ private func runRequestPathTests(context: ToolContext, sanitizer: RequestPathSan let filters = [ "WindowControlPublicRequestTests", + "WindowSoftwarePresentationPublicRequestTests", "WindowDragSourcePublicRequestTests", "SubsurfacePublicRequestTests", "DesktopIntegrationPublicRequestTests", @@ -1436,6 +1437,7 @@ private func runFoundationCheck(context: ToolContext) throws { private func requestTestEnvironment() -> [String: String] { [ "WAYLAND_CLIENT_KIT_ENABLE_WINDOW_CONTROL_REQUEST_TESTS": "1", + "WAYLAND_CLIENT_KIT_ENABLE_SOFTWARE_PRESENTATION_REQUEST_TESTS": "1", "WAYLAND_CLIENT_KIT_ENABLE_DND_SOURCE_REQUEST_TESTS": "1", "WAYLAND_CLIENT_KIT_ENABLE_SUBSURFACE_REQUEST_TESTS": "1", "WAYLAND_CLIENT_KIT_ENABLE_DESKTOP_REQUEST_TESTS": "1", diff --git a/Tests/WaylandClientKitToolTests/RequestBridgeGenerationTests.swift b/Tests/WaylandClientKitToolTests/RequestBridgeGenerationTests.swift index bafe1e46..0854d15c 100644 --- a/Tests/WaylandClientKitToolTests/RequestBridgeGenerationTests.swift +++ b/Tests/WaylandClientKitToolTests/RequestBridgeGenerationTests.swift @@ -40,14 +40,15 @@ struct RequestBridgeGenerationTests { let names = Set(bridges.map(\.functionName)) #expect(policy.interfaces.count == 38) - #expect(bridges.count == 61) + #expect(bridges.count == 60) #expect(names.contains("swl_compositor_create_surface")) - #expect(names.contains("swl_surface_frame")) + #expect(!names.contains("swl_surface_frame")) #expect(names.contains("swl_seat_get_pointer")) #expect(names.contains("swl_data_device_manager_create_data_source")) #expect(names.contains("swl_primary_selection_device_manager_get_device")) #expect(!names.contains("swl_wl_compositor_create_surface")) #expect(!names.contains("swl_wl_data_device_manager_create_data_source")) + #expect(policy.interfaces["wl_surface"]?.handwrittenByDefault == .testRecording) #expect( policy.interfaces["wp_linux_drm_syncobj_manager_v1"]? .handwrittenRequests["import_timeline"] == .failureInjection diff --git a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift index a56f0269..0ded1314 100644 --- a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift +++ b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift @@ -196,7 +196,7 @@ } @Test - func frameCommitterDoesNotRecordFrameWhenSubmitConstraintsFail() async throws { + func frameCommitterRejectsUnavailableSubmitConstraintsDuringPreparation() async throws { try await CoreRequestRecordingGate.withExclusiveRecording { swl_test_core_request_recording_begin() defer { swl_test_core_request_recording_end() } @@ -205,17 +205,12 @@ defer { surface.destroy() } var runtime = try configuredRuntime() runtime.setExplicitSynchronizationActive() - let preparedCommit = try preparedCommit( - surface: surface, - runtime: &runtime, - constraints: explicitConstraints(timeline: 77, acquire: 2, release: 3), - payload: .buffer(try testSurfaceBuffer(pointer: 0x5702)) - ) - #expect(throws: SurfaceSubmitConstraintError.explicitSyncUnavailable) { - try SurfaceFrameCommitter.commit( - preparedCommit, - runtime: &runtime + try preparedCommit( + surface: surface, + runtime: &runtime, + constraints: explicitConstraints(timeline: 77, acquire: 2, release: 3), + payload: .buffer(try testSurfaceBuffer(pointer: 0x5702)) ) } #expect(runtime.transactionSnapshot.lastCommittedFrame == nil) diff --git a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift index b75adbab..9d4b63bc 100644 --- a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift +++ b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift @@ -41,6 +41,35 @@ ) } + @Test + func frameCallbackRequestFailureSkipsFeedbackAndCommit() { + var events: [CommitSequenceEvent] = [] + + do { + try WindowSoftwarePresentationCommitSequence.perform { + events.append(.frameCallback) + throw InjectedFrameCallbackFailure() + } requestPresentationFeedback: { + events.append(.presentationFeedback(.show)) + return SurfacePresentationIdentity(rawValue: 3) + } commit: { + events.append(.commit(.show)) + } cancelFrameCallback: { + events.append(.cancelFrameCallback) + } cleanupAfterFailure: { identity in + if let identity { + events.append(.cancelPresentationFeedback(identity)) + } + events.append(.discardDrawingBuffer) + } + Issue.record("expected frame callback request failure") + } catch is InjectedFrameCallbackFailure { + #expect(events == [.frameCallback, .discardDrawingBuffer]) + } catch { + Issue.record("unexpected error: \(error)") + } + } + @Test func presentationFeedbackRequestFailureDoesNotCommitFrame() { var events: [CommitSequenceEvent] = [] @@ -147,6 +176,86 @@ } } + @Test + func reservedBufferCanBeDiscardedAfterPoolRetirement() async throws { + try await withSoftwarePresentationRecording { + let sharedMemory = try testSharedMemory(pointer: 0x6A22) + let pool = try sharedMemory.createPool(width: 64, height: 48, bufferCount: 1) + let reservedBuffer = try #require(pool.acquireReservedDrawingBuffer()) + + pool.retire(reason: .resized) + #expect(unsafe swl_test_core_request_record().buffer_destroy_sequence == 0) + + reservedBuffer.discard() + #expect(unsafe swl_test_core_request_record().buffer_destroy_sequence == 0) + #expect(!pool.hasBusyBuffers) + #expect(!pool.hasFreeBuffers) + + pool.destroy() + #expect(unsafe swl_test_core_request_record().buffer_destroy_sequence > 0) + #expect(unsafe swl_test_core_request_record().shm_pool_destroy_sequence > 0) + } + } + + @Test + func submitConstraintFailureLeavesDrawingBufferReusable() async throws { + try await withSoftwarePresentationRecording { + try exerciseSubmitConstraintFailureLeavesDrawingBufferReusable() + } + } + + private func exerciseSubmitConstraintFailureLeavesDrawingBufferReusable() throws { + let surface = try testSurface(pointer: 0x6A31) + let sharedMemory = try testSharedMemory(pointer: 0x6A32) + let pool = try sharedMemory.createPool(width: 64, height: 48, bufferCount: 1) + let acquirePoint = SurfaceSyncPoint( + timeline: SurfaceSyncTimelineIdentity(77), + point: RawSyncobjTimelinePoint(2) + ) + let releasePoint = SurfaceSyncPoint( + timeline: SurfaceSyncTimelineIdentity(77), + point: RawSyncobjTimelinePoint(3) + ) + var runtime = SurfaceRuntime(role: .toplevelWindow) + runtime.setExplicitSynchronizationActive() + runtime.recordConfigureReceived(serial: 1) + try runtime.acknowledgeConfigure(serial: 1) + var pendingFrameRegistration: FrameCallbackRegistration? + let presenter = softwarePresenter(surface: surface, pool: pool) + + do { + _ = try presenter.present( + context: try softwarePresentationContext( + submitConstraints: SurfaceSubmitConstraints( + synchronization: .explicit( + acquire: acquirePoint, + release: releasePoint + ), + pacing: .none + ) + ), + draw: { _ in () }, + runtime: &runtime, + pendingFrameRegistration: &pendingFrameRegistration + ) + Issue.record("expected submit constraint failure") + } catch let failure as WindowSoftwarePresentationFailure { + #expect( + failure.underlying as? SurfaceSubmitConstraintError + == .explicitSyncUnavailable + ) + } catch { + Issue.record("unexpected error: \(error)") + } + + let hasPendingRegistration = hasPendingFrameRegistration(pendingFrameRegistration) + #expect(!hasPendingRegistration) + #expect(!pool.hasBusyBuffers) + #expect(pool.hasFreeBuffers) + #expect(unsafe swl_test_core_request_record().attach_sequence == 0) + #expect(unsafe swl_test_core_request_record().commit_sequence == 0) + } + private func exerciseDrawFailureBeforePresentationRequests() throws { let surface = try testSurface(pointer: 0x6A01) let sharedMemory = try testSharedMemory(pointer: 0x6A02) @@ -226,7 +335,18 @@ } } - private func softwarePresentationContext() throws + private func hasPendingFrameRegistration( + _ registration: borrowing FrameCallbackRegistration? + ) -> Bool { + switch registration { + case .some: true + case .none: false + } + } + + private func softwarePresentationContext( + submitConstraints: SurfaceSubmitConstraints = .default + ) throws -> WindowSoftwarePresentationContext { let geometry = try SurfaceGeometry( @@ -249,7 +369,7 @@ configuration: configure.configuration ), geometry: geometry, - submitConstraints: .default, + submitConstraints: submitConstraints, metadata: .default, damage: nil, presentationFeedback: nil @@ -314,6 +434,7 @@ } private struct InjectedPresentationFeedbackFailure: Error {} + private struct InjectedFrameCallbackFailure: Error {} private struct InjectedCommitFailure: Error {} private struct InjectedDrawFailure: Error {} #endif diff --git a/Tests/WaylandClientTests/Windowing/DisplayCoreSoftwarePresentationTests.swift b/Tests/WaylandClientTests/Windowing/DisplayCoreSoftwarePresentationTests.swift new file mode 100644 index 00000000..3398f059 --- /dev/null +++ b/Tests/WaylandClientTests/Windowing/DisplayCoreSoftwarePresentationTests.swift @@ -0,0 +1,48 @@ +import Synchronization +import Testing + +@testable import WaylandClient + +@Suite +struct DisplayCoreSoftwarePresentationTests { + @Test + func reservationForClosedWindowReturnsClosed() throws { + let core = DisplayCore(eventHub: DisplayEventHub()) + + #expect( + try core.reserveSoftwareFrameForRedraw(WindowID(rawValue: 42)) == .closed + ) + } + + @Test + func submissionForClosedWindowReturnsClosedWithoutDrawing() throws { + let core = DisplayCore(eventHub: DisplayEventHub()) + let surfaceGeometry = try SurfaceGeometry( + logicalSize: PositiveLogicalSize(width: 1, height: 1), + scale: .one + ) + let reservation = SoftwareFrameReservation( + reservationID: SoftwareFrameReservationToken(rawValue: 1), + id: SoftwareFrameBufferID(rawValue: 1), + width: 1, + height: 1, + stride: 4, + geometry: SoftwareFrameGeometry(surface: surfaceGeometry) + ) + let didDraw = Mutex(false) + + let outcome = try core.submitReservedSoftwareFrame( + WindowID(rawValue: 42), + reservation: reservation, + submitConstraints: .default, + metadata: .default, + requestPresentationFeedback: true, + damage: nil + ) { _ in + didDraw.withLock { $0 = true } + } + + #expect(outcome == .closed) + #expect(!didDraw.withLock { $0 }) + } +} diff --git a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift index 6b07137d..fb589bd4 100644 --- a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift @@ -5,7 +5,7 @@ import WaylandRaw @Suite struct WindowModelPresentationTests { // swiftlint:disable:this type_body_length - private let windowID = WindowID(rawValue: 42) + let windowID = WindowID(rawValue: 42) @Test func redrawRequestConsumedProducesPresentationEffect() throws { @@ -363,21 +363,21 @@ struct WindowModelPresentationTests { // swiftlint:disable:this type_body_lengt } extension WindowModelPresentationTests { - private func configuredModelReadyForConfigure() throws -> WindowModel { + func configuredModelReadyForConfigure() throws -> WindowModel { var model = WindowModel(id: windowID, fallbackSize: .default) _ = try model.reduce(.roleObjectsCreated) _ = try model.reduce(.initialCommitSent) return model } - private func activePublishedModel() throws -> WindowModel { + func activePublishedModel() throws -> WindowModel { var model = try configuredModelReadyForConfigure() _ = try model.reduce(.published) _ = try model.reduce(.configureReceived(configure(width: 800, height: 600, serial: 1))) return model } - private func activeModelWithStartedPresentation() throws -> ( + func activeModelWithStartedPresentation() throws -> ( model: WindowModel, request: PresentationRequest ) { @@ -388,7 +388,7 @@ extension WindowModelPresentationTests { return (model, request) } - private func presentationRequest(from effects: [WindowEffect]) throws -> PresentationRequest { + func presentationRequest(from effects: [WindowEffect]) throws -> PresentationRequest { guard case .performSoftwarePresent(let request) = try #require(effects.first) else { Issue.record("expected presentation effect") throw ClientError.window( @@ -400,7 +400,7 @@ extension WindowModelPresentationTests { return request } - private func configure( + func configure( width: Int32, height: Int32, serial: UInt32 = 1 diff --git a/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift new file mode 100644 index 00000000..66174e42 --- /dev/null +++ b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift @@ -0,0 +1,181 @@ +import Testing +import WaylandRaw + +@testable import WaylandClient + +extension WindowModelPresentationTests { + @Test + func configureDuringSoftwarePreparationSupersedesAndPublishesReplacementImmediately() throws { + var (model, request) = try activeModelWithStartedPresentation() + let replacementConfigure = try configure(width: 1_024, height: 768, serial: 2) + + #expect( + try model.reduce(.configureReceived(replacementConfigure)) + == [.ackConfigure(2)] + ) + #expect(!model.isCurrentSoftwarePresentation(request)) + + #expect( + try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .available + ) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.presentation == .idle) + #expect(model.currentConfiguration == replacementConfigure.configuration) + #expect(model.redraw.isDirty) + #expect(model.redraw.hasOutstandingRedrawRequest) + } + + @Test + func supersededSoftwarePresentationRepublishesNewestGenerationExactlyOnce() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect( + try model.reduce(.contentInvalidated(bufferAvailability: .available)).isEmpty + ) + #expect(model.redraw.generationForCurrentDraw == request.generation + 1) + #expect(model.redraw.hasOutstandingRedrawRequest) + + #expect( + try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .available + ) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.presentation == .idle) + #expect(model.redraw.isDirty) + #expect(model.redraw.generationForCurrentDraw == request.generation + 1) + #expect(model.redraw.hasOutstandingRedrawRequest) + + #expect( + try model.reduce(.contentInvalidated(bufferAvailability: .available)).isEmpty + ) + } + + @Test + func canceledSoftwarePreparationRepublishesItsUncommittedGeneration() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect(!model.redraw.hasOutstandingRedrawRequest) + #expect( + try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .available + ) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.presentation == .idle) + #expect(model.redraw.isDirty) + #expect(model.redraw.generationForCurrentDraw == request.generation) + #expect(model.redraw.hasOutstandingRedrawRequest) + } + + @Test + func supersededSoftwarePresentationWaitsWhenNoBufferIsAvailable() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect( + try model.reduce(.contentInvalidated(bufferAvailability: .unavailable)).isEmpty + ) + #expect(model.redraw.isWaitingForBuffer) + + #expect( + try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .unavailable + ) + ).isEmpty + ) + #expect(model.presentation == .idle) + #expect(model.redraw.isDirty) + #expect(model.redraw.isWaitingForBuffer) + #expect( + try model.reduce(.bufferBecameAvailable(bufferAvailability: .available)) + == [.publishRedrawRequested(windowID)] + ) + } + + @Test + func supersededSoftwarePresentationRejectsMismatchedGeneration() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect( + throws: ClientError.window( + windowID, + .invalidLifecycleTransition( + .presentationGenerationMismatch( + expected: request.generation, + actual: request.generation + 1 + ) + ) + ) + ) { + _ = try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation + 1, + bufferAvailability: .available + ) + ) + } + #expect(model.presentation == .drawing(request: request)) + } + + @Test + func softwarePresentationCurrencyRequiresActiveRequestConfigurationAndGeneration() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect(model.isCurrentSoftwarePresentation(request)) + + _ = try model.reduce(.contentInvalidated(bufferAvailability: .available)) + #expect(!model.isCurrentSoftwarePresentation(request)) + + _ = try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .available + ) + ) + #expect(!model.isCurrentSoftwarePresentation(request)) + } + + @Test + func manyConsecutiveSoftwareSupersessionsDoNotLoseOrDuplicateRedraws() throws { + var (model, request) = try activeModelWithStartedPresentation() + + for expectedGeneration in 2...101 { + #expect( + try model.reduce( + .contentInvalidated(bufferAvailability: .available) + ).isEmpty + ) + #expect(model.redraw.generationForCurrentDraw == UInt64(expectedGeneration)) + #expect( + try model.reduce( + .softwarePresentationSuperseded( + generation: request.generation, + bufferAvailability: .available + ) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.presentation == .idle) + + let effects = try model.reduce( + .redrawRequestConsumed(bufferAvailability: .available) + ) + guard case .performSoftwarePresent(let nextRequest) = try #require(effects.first) else { + Issue.record("expected replacement software presentation") + return + } + #expect(effects.count == 1) + _ = try model.reduce(.presentationStarted(nextRequest)) + request = nextRequest + } + } +} diff --git a/Tests/WaylandClientTests/Windowing/WindowOwnedResourceLedgerTests.swift b/Tests/WaylandClientTests/Windowing/WindowOwnedResourceLedgerTests.swift index d9ed00ee..88b15887 100644 --- a/Tests/WaylandClientTests/Windowing/WindowOwnedResourceLedgerTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowOwnedResourceLedgerTests.swift @@ -56,6 +56,19 @@ struct WindowOwnedResourceLedgerTests { #expect(ledger.count == 1) } + @Test + func mismatchedResourceDoesNotConsumeMatchingIdentity() throws { + let log = RetirementLog() + let ledger = makeLedger(log) + ledger.insert(Resource(id: 1), for: 7) + + #expect(ledger.take(7) { $0.id == 2 } == nil) + #expect(ledger.count == 1) + #expect(try #require(ledger.take(7) { $0.id == 1 }).id == 1) + #expect(ledger.isEmpty) + #expect(log.ids.isEmpty) + } + @Test func connectionFailureCleanupRetiresAllResourcesOnce() { let log = RetirementLog() diff --git a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift new file mode 100644 index 00000000..ec327601 --- /dev/null +++ b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift @@ -0,0 +1,319 @@ +#if SWL_ENABLE_TESTING + import CWaylandProtocols + import Foundation + import Testing + import WaylandTestSupport + + @testable import WaylandClient + + @Suite( + .enabled( + if: SoftwarePresentationRequestTestEnvironment.isEnabled, + """ + Set WAYLAND_DISPLAY and + WAYLAND_CLIENT_KIT_ENABLE_SOFTWARE_PRESENTATION_REQUEST_TESTS=1 + """ + ), + .timeLimit(.minutes(1)), + .tags(.linux, .integration, .liveWayland, .publicAPI), + .serialized + ) + struct WindowSoftwarePresentationPublicRequestTests { + @Test + func rejectedPreparationsIssueNoSurfaceTransactionRequests() async throws { + try await withRecordedSoftwarePresentationConnection { window, displayEvents in + try await exerciseRepeatedSupersessions(window, displayEvents) + try await exerciseCancellation(window, displayEvents) + try await exercisePreparationFailure(window, displayEvents) + #expect(try await window.needsRedraw) + try await exerciseClose(window) + } + } + } + + private func withRecordedSoftwarePresentationConnection( + _ operation: @Sendable (Window, DisplayEvents) async throws -> Void + ) async throws { + try await CoreRequestRecordingGate.withExclusiveRecording { + try await PresentationRequestRecordingGate.withExclusiveRecording { + try await withSoftwarePresentationConnection { display, window in + let displayEvents = display.events + try await window.show { frame in + fillSoftwareFrame(frame, color: 0x0014_2434) + } + try await waitForSoftwareRedrawRequest( + for: window, + in: displayEvents, + phase: "initial redraw request" + ) + + swl_test_core_request_recording_begin_forwarding() + swl_test_presentation_request_recording_begin_forwarding() + defer { swl_test_presentation_request_recording_end() } + defer { swl_test_core_request_recording_end() } + + try await operation(window, displayEvents) + expectNoSurfaceTransactionRequests() + } + } + } + } + + private func exerciseRepeatedSupersessions( + _ window: Window, + _ displayEvents: DisplayEvents + ) async throws { + for _ in 0..<16 { + let gate = SoftwarePreparationRequestGate() + async let staleOutcome = window.redraw( + requestPresentationFeedback: true, + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { preparedID, frame in + try rejectSoftwareDraw(preparedID, frame) + } + ) + await gate.waitUntilSuspended() + + async let replacementEvent = softwareRedrawEvent( + for: window, + in: displayEvents, + phase: "repeated supersession replacement" + ) { + do { + try await window.requestRedraw() + } catch { + await gate.resumePreparation() + throw error + } + await gate.resumePreparation() + } + #expect(try await staleOutcome == .superseded) + _ = try await replacementEvent + } + } + + private func exerciseCancellation( + _ window: Window, + _ displayEvents: DisplayEvents + ) async throws { + let gate = SoftwarePreparationRequestGate() + let outcome = try await withThrowingTaskGroup( + of: SoftwarePresentationOutcome.self + ) { group in + group.addTask { + try await window.redraw( + requestPresentationFeedback: true, + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { preparedID, frame in + try rejectSoftwareDraw(preparedID, frame) + } + ) + } + await gate.waitUntilSuspended() + group.cancelAll() + + async let replacementEvent = softwareRedrawEvent( + for: window, + in: displayEvents, + phase: "cancellation replacement" + ) { + await gate.resumePreparation() + } + let nextOutcome = try await group.next() + _ = try await replacementEvent + return try #require(nextOutcome) + } + #expect(outcome == .superseded) + } + + private func exercisePreparationFailure( + _ window: Window, + _ displayEvents: DisplayEvents + ) async throws { + _ = try await softwareRedrawEvent( + for: window, + in: displayEvents, + phase: "preparation-failure replacement" + ) { + do { + _ = try await window.redraw( + requestPresentationFeedback: true, + preparing: { _ in + throw InjectedSoftwarePreparationFailure() + }, + { preparedID, frame in + try rejectSoftwareDraw(preparedID, frame) + } + ) + Issue.record("expected preparation failure") + } catch is InjectedSoftwarePreparationFailure { + // The original preparation error must remain observable. + } + } + } + + private func exerciseClose(_ window: Window) async throws { + let gate = SoftwarePreparationRequestGate() + async let outcome = window.redraw( + requestPresentationFeedback: true, + preparing: { reservation in + await gate.suspendPreparation() + return reservation.id + }, + { preparedID, frame in + try rejectSoftwareDraw(preparedID, frame) + } + ) + await gate.waitUntilSuspended() + await window.close() + await gate.resumePreparation() + #expect(try await outcome == .closed) + } + + private func expectNoSurfaceTransactionRequests() { + let coreRecord = unsafe swl_test_core_request_record() + let presentationRecord = unsafe swl_test_presentation_request_record() + + #expect(unsafe coreRecord.attach_sequence == 0) + #expect(unsafe coreRecord.frame_sequence == 0) + #expect(unsafe coreRecord.damage_sequence == 0) + #expect(unsafe coreRecord.commit_sequence == 0) + #expect(unsafe presentationRecord.call_count == 0) + } + + private func withSoftwarePresentationConnection( + _ body: @Sendable (WaylandDisplay, Window) async throws -> Void + ) async throws { + try await WaylandDisplay.withConnection( + applicationID: "org.waylandclientkit.SoftwarePresentationRequestTests", + cursorConfiguration: CursorConfiguration(fallbackCursor: .hidden), + discoveryTimeoutMilliseconds: 5_000 + ) { display in + let window = try await display.createTopLevelWindow( + configuration: try WindowConfiguration( + title: "WaylandClientKit Software Presentation Request Test", + appID: "wayland-client-kit-software-presentation-request-test", + initialWidth: 160, + initialHeight: 120, + bufferCount: 3, + closeRequestPolicy: .requestOnly, + decorationPreference: .preferServerSide + ) + ) + try await body(display, window) + } + } + + private func waitForSoftwareRedrawRequest( + for window: Window, + in displayEvents: DisplayEvents, + phase: String + ) async throws { + _ = try await softwareRedrawEvent( + for: window, + in: displayEvents, + phase: phase + ) { + try await window.requestRedraw() + } + } + + private func softwareRedrawEvent( + for window: Window, + in displayEvents: DisplayEvents, + phase: String, + after trigger: @escaping @Sendable () async throws -> Void + ) async throws -> DisplayEvent { + try await withThrowingTaskGroup(of: DisplayEvent.self) { group in + group.addTask { + var iterator = displayEvents.makeAsyncIterator() + try await trigger() + while let event = try await iterator.next() { + if event == .redrawRequested(window.id) { + return event + } + } + throw SoftwarePresentationRequestTestError.streamEnded + } + group.addTask { + try await Task.sleep(for: .seconds(5)) + throw SoftwarePresentationRequestTestError.timeout(phase) + } + + let event = try await group.next() + group.cancelAll() + return try #require(event) + } + } + + private func fillSoftwareFrame(_ frame: borrowing SoftwareFrame, color: UInt32) { + frame.withXRGB8888Rows { _, pixels in + for index in 0..] = [] + private var resumeContinuation: CheckedContinuation? + + func suspendPreparation() async { + isSuspended = true + let waiters = suspensionWaiters + suspensionWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + await withCheckedContinuation { continuation in + resumeContinuation = continuation + } + } + + func waitUntilSuspended() async { + guard !isSuspended else { return } + await withCheckedContinuation { continuation in + suspensionWaiters.append(continuation) + } + } + + func resumePreparation() { + resumeContinuation?.resume() + resumeContinuation = nil + } + } + + private enum SoftwarePresentationRequestTestError: Error { + case streamEnded + case timeout(String) + } + + private enum SoftwarePresentationRequestTestEnvironment { + static var isEnabled: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["WAYLAND_DISPLAY"]?.isEmpty == false + && environment[ + "WAYLAND_CLIENT_KIT_ENABLE_SOFTWARE_PRESENTATION_REQUEST_TESTS" + ] == "1" + } + } + + private struct UnexpectedSoftwarePresentationDraw: Error {} + private struct InjectedSoftwarePreparationFailure: Error {} +#endif diff --git a/docs/documentation-symbol-coverage.json b/docs/documentation-symbol-coverage.json index c6f15606..bfc93315 100644 --- a/docs/documentation-symbol-coverage.json +++ b/docs/documentation-symbol-coverage.json @@ -1,8 +1,8 @@ { "products" : { "WaylandClient" : { - "documented" : 146, - "eligible" : 1521 + "documented" : 151, + "eligible" : 1526 }, "WaylandGraphicsPreview" : { "documented" : 32, diff --git a/docs/public-api-audit.md b/docs/public-api-audit.md index 1921c0fb..676c68d0 100644 --- a/docs/public-api-audit.md +++ b/docs/public-api-audit.md @@ -40,6 +40,7 @@ Intentionally public: - `SoftwareFrameBufferID` - `SoftwareFrameBuffer` - `SoftwareFrameReservation` +- `SoftwarePresentationOutcome` - `SoftwareFrameGeometry` - `PositivePixelSize` - `SoftwareFrame` @@ -210,6 +211,14 @@ Current user-facing contract: spans, stride, and geometry without exposing raw Wayland or SHM handles. `SoftwareFrameReservation` lets async preparation observe the selected software buffer identity and geometry before the final scoped draw borrow. + Async `Window.show(preparing:_:)` and `Window.redraw(preparing:_:)` calls are + latest-wins transactions and return `SoftwarePresentationOutcome`. After + preparation resumes, the library revalidates the exact reservation, window, + configure, authoritative geometry, redraw generation, and task cancellation. + A stale attempt is discarded without drawing, requesting a frame callback or + presentation feedback, or committing the surface. Its replacement redraw is + published once through normal pacing. Presentation feedback requested by the + async API belongs to the same eventual surface commit. - Window sizes are logical surface sizes. `SurfaceGeometry` records the logical size, buffer-pixel size, and exact `SurfaceScale` used by the current SHM frame. diff --git a/docs/public-api-baseline.md b/docs/public-api-baseline.md index dc5a85ad..e7a14538 100644 --- a/docs/public-api-baseline.md +++ b/docs/public-api-baseline.md @@ -1492,6 +1492,11 @@ s:13WaylandClient24SoftwareFrameReservationV6heights5Int32Vvp swift.property Sof s:13WaylandClient24SoftwareFrameReservationV2idAA0cD8BufferIDVvp swift.property SoftwareFrameReservation.id let id: SoftwareFrameBufferID - s:13WaylandClient24SoftwareFrameReservationV6strides5Int32Vvp swift.property SoftwareFrameReservation.stride let stride: Int32 - s:13WaylandClient24SoftwareFrameReservationV5widths5Int32Vvp swift.property SoftwareFrameReservation.width let width: Int32 - +s:13WaylandClient27SoftwarePresentationOutcomeO swift.enum SoftwarePresentationOutcome enum SoftwarePresentationOutcome - +s:13WaylandClient27SoftwarePresentationOutcomeO6closedyA2CmF swift.enum.case SoftwarePresentationOutcome.closed case closed - +s:13WaylandClient27SoftwarePresentationOutcomeO8deferredyA2CmF swift.enum.case SoftwarePresentationOutcome.deferred case deferred - +s:13WaylandClient27SoftwarePresentationOutcomeO9presentedyA2CmF swift.enum.case SoftwarePresentationOutcome.presented case presented - +s:13WaylandClient27SoftwarePresentationOutcomeO10supersededyA2CmF swift.enum.case SoftwarePresentationOutcome.superseded case superseded - s:13WaylandClient19StartedToplevelDragV swift.struct StartedToplevelDrag struct StartedToplevelDrag - s:13WaylandClient19StartedToplevelDragV4dragAA0dE0Vvp swift.property StartedToplevelDrag.drag let drag: ToplevelDrag - s:13WaylandClient19StartedToplevelDragV6sourceAA0E6SourceVvp swift.property StartedToplevelDrag.source let source: DragSource - @@ -2104,8 +2109,8 @@ s:13WaylandClient6WindowV11needsRedrawSbvp swift.property Window.needsRedraw var s:13WaylandClient6WindowV18presentationEventsAA0c12PresentationE0Vvp swift.property Window.presentationEvents var presentationEvents: WindowPresentationEvents { get } - s:13WaylandClient6WindowV6redrawyyyAA13SoftwareFrameVYbKXEYaKF swift.method Window.redraw(_:) func redraw(_ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - s:13WaylandClient6WindowV6redraw6damage_yAA19SurfaceDamageRegionVSg_yAA13SoftwareFrameVYbKXEtYaKF swift.method Window.redraw(damage:_:) func redraw(damage: SurfaceDamageRegion?, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - -s:13WaylandClient6WindowV6redraw6damage9preparing_yAA19SurfaceDamageRegionVSg_xAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0jK0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(damage:preparing:_:) func redraw(damage: SurfaceDamageRegion?, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} -s:13WaylandClient6WindowV6redraw9preparing_yxAA24SoftwareFrameReservationVYaYbKYCXE_yx_AA0fG0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(preparing:_:) func redraw(preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback9preparing_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbxAA0J16FrameReservationVYaYbKYCXEyx_AA0jO0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(damage:requestPresentationFeedback:preparing:_:) @discardableResult func redraw(damage: SurfaceDamageRegion?, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV6redraw27requestPresentationFeedback9preparing_AA08SoftwareF7OutcomeOSb_xAA0I16FrameReservationVYaYbKYCXEyx_AA0iK0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(requestPresentationFeedback:preparing:_:) @discardableResult func redraw(requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} s:13WaylandClient6WindowV15relativePointer6seatIDAA08RelativeE12SubscriptionVAA04SeatG0V_tYaKF swift.method Window.relativePointer(seatID:) func relativePointer(seatID: SeatID) async throws -> RelativePointerSubscription - s:13WaylandClient6WindowV22requestActivationToken5appID13serialContext19timeoutMillisecondsAA0eF0VSSSg_AA0e6SerialJ0VSgs5Int32VtYaKF swift.method Window.requestActivationToken(appID:serialContext:timeoutMilliseconds:) func requestActivationToken(appID: String? = nil, serialContext: ActivationSerialContext? = nil, timeoutMilliseconds: Int32 = WaylandDisplay.defaultActivationTokenTimeoutMilliseconds) async throws -> ActivationToken - s:13WaylandClient6WindowV21requestExitFullscreenyyYaKF swift.method Window.requestExitFullscreen() func requestExitFullscreen() async throws - @@ -2131,9 +2136,9 @@ s:13WaylandClient6WindowV15setOpaqueRegionyyAA07SurfaceF0VSgYaKF swift.method Wi s:13WaylandClient6WindowV8setTitleyyAA0A6StringVYaKF swift.method Window.setTitle(_:) func setTitle(_ title: WaylandString) async throws - s:13WaylandClient6WindowV8setTitleyySSYaKF swift.method Window.setTitle(_:) func setTitle(_ title: String) async throws - s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds_yAA19SurfaceDamageRegionVSg_s5Int32VyAA13SoftwareFrameVYbKXEtYaKF swift.method Window.show(damage:timeoutMilliseconds:_:) func show(damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - -s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds9preparing_yAA19SurfaceDamageRegionVSg_s5Int32VxAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0mN0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(damage:timeoutMilliseconds:preparing:_:) func show(damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareI7OutcomeOAA19SurfaceDamageRegionVSg_s5Int32VSbxAA0L16FrameReservationVYaYbKYCXEyx_AA0lR0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(damage:timeoutMilliseconds:requestPresentationFeedback:preparing:_:) @discardableResult func show(damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} s:13WaylandClient6WindowV4show19timeoutMilliseconds_ys5Int32V_yAA13SoftwareFrameVYbKXEtYaKF swift.method Window.show(timeoutMilliseconds:_:) func show(timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - -s:13WaylandClient6WindowV4show19timeoutMilliseconds9preparing_ys5Int32V_xAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0iJ0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(timeoutMilliseconds:preparing:_:) func show(timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV4show19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareH7OutcomeOs5Int32V_SbxAA0K16FrameReservationVYaYbKYCXEyx_AA0kN0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(timeoutMilliseconds:requestPresentationFeedback:preparing:_:) @discardableResult func show(timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} s:13WaylandClient6WindowV9startDrag6source6seatID6serial4iconAA0E6SourceVAA0eK13ConfigurationV_AA04SeatH0VAA11InputSerialVAA0E4IconOtYaKF swift.method Window.startDrag(source:seatID:serial:icon:) func startDrag(source configuration: DragSourceConfiguration, seatID: SeatID, serial: InputSerial, icon: DragIcon = .none) async throws -> DragSource - s:13WaylandClient6WindowV17startToplevelDrag6source6seatID6serial4icon6offsetAA07StartedeF0VAA0F19SourceConfigurationV_AA04SeatI0VAA11InputSerialVAA0F4IconOAA13LogicalOffsetVtYaKF swift.method Window.startToplevelDrag(source:seatID:serial:icon:offset:) func startToplevelDrag(source configuration: DragSourceConfiguration, seatID: SeatID, serial: InputSerial, icon: DragIcon = .none, offset: LogicalOffset = .zero) async throws -> StartedToplevelDrag - s:13WaylandClient6WindowV13stateSnapshotAA0c5StateE0Vvp swift.property Window.stateSnapshot var stateSnapshot: WindowStateSnapshot { get async throws } - @@ -5291,6 +5296,14 @@ s:13WaylandClient27RelativePointerSubscriptionV2eeoiySbAC_ACtFZ memberOf s:13Way s:13WaylandClient27RelativePointerSubscriptionV2idAA0cdE2IDVvp memberOf s:13WaylandClient27RelativePointerSubscriptionV - s:13WaylandClient27RelativePointerSubscriptionV4hash4intoys6HasherVz_tF memberOf s:13WaylandClient27RelativePointerSubscriptionV - s:13WaylandClient27RelativePointerSubscriptionV7destroyyyYaKF memberOf s:13WaylandClient27RelativePointerSubscriptionV - +s:13WaylandClient27SoftwarePresentationOutcomeO conformsTo s:SH Swift.Hashable +s:13WaylandClient27SoftwarePresentationOutcomeO conformsTo s:SQ Swift.Equatable +s:13WaylandClient27SoftwarePresentationOutcomeO conformsTo s:s16SendableMetatypeP Swift.SendableMetatype +s:13WaylandClient27SoftwarePresentationOutcomeO conformsTo s:s8SendableP Swift.Sendable +s:13WaylandClient27SoftwarePresentationOutcomeO10supersededyA2CmF memberOf s:13WaylandClient27SoftwarePresentationOutcomeO - +s:13WaylandClient27SoftwarePresentationOutcomeO6closedyA2CmF memberOf s:13WaylandClient27SoftwarePresentationOutcomeO - +s:13WaylandClient27SoftwarePresentationOutcomeO8deferredyA2CmF memberOf s:13WaylandClient27SoftwarePresentationOutcomeO - +s:13WaylandClient27SoftwarePresentationOutcomeO9presentedyA2CmF memberOf s:13WaylandClient27SoftwarePresentationOutcomeO - s:13WaylandClient27SurfacePresentationFeedbackO conformsTo s:SQ Swift.Equatable s:13WaylandClient27SurfacePresentationFeedbackO conformsTo s:s16SendableMetatypeP Swift.SendableMetatype s:13WaylandClient27SurfacePresentationFeedbackO conformsTo s:s8SendableP Swift.Sendable @@ -5690,14 +5703,14 @@ s:13WaylandClient6WindowV27requestPresentationFeedbackyyYaKF memberOf s:13Waylan s:13WaylandClient6WindowV2eeoiySbAC_ACtFZ memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV2idAA0C2IDVvp memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4hash4intoys6HasherVz_tF memberOf s:13WaylandClient6WindowV - -s:13WaylandClient6WindowV4show19timeoutMilliseconds9preparing_ys5Int32V_xAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0iJ0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV4show19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareH7OutcomeOs5Int32V_SbxAA0K16FrameReservationVYaYbKYCXEyx_AA0kN0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show19timeoutMilliseconds_ys5Int32V_yAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - -s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds9preparing_yAA19SurfaceDamageRegionVSg_s5Int32VxAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0mN0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareI7OutcomeOAA19SurfaceDamageRegionVSg_s5Int32VSbxAA0L16FrameReservationVYaYbKYCXEyx_AA0lR0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds_yAA19SurfaceDamageRegionVSg_s5Int32VyAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV5closeyyYaF memberOf s:13WaylandClient6WindowV - -s:13WaylandClient6WindowV6redraw6damage9preparing_yAA19SurfaceDamageRegionVSg_xAA24SoftwareFrameReservationVYaYbKYCXEyx_AA0jK0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV6redraw27requestPresentationFeedback9preparing_AA08SoftwareF7OutcomeOSb_xAA0I16FrameReservationVYaYbKYCXEyx_AA0iK0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback9preparing_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbxAA0J16FrameReservationVYaYbKYCXEyx_AA0jO0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redraw6damage_yAA19SurfaceDamageRegionVSg_yAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - -s:13WaylandClient6WindowV6redraw9preparing_yxAA24SoftwareFrameReservationVYaYbKYCXE_yx_AA0fG0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redrawyyyAA13SoftwareFrameVYbKXEYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV7setIconyyAA0cE0OYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV8activate5usingyAA15ActivationTokenV_tYaKF memberOf s:13WaylandClient6WindowV - diff --git a/docs/release.md b/docs/release.md index 6c5f2cd9..de8d5f27 100644 --- a/docs/release.md +++ b/docs/release.md @@ -30,6 +30,11 @@ This checkpoint contains source-breaking `WaylandClient` changes: `dataTransfer`, and `presentation` cases. `WaylandDisplay.events` is now the complete cross-family ordered feed; specialized streams remain family-ordered convenience views. +- Update async software drawing calls for the new `SoftwarePresentationOutcome` + result. `Window.show(preparing:_:)` and `Window.redraw(preparing:_:)` now + distinguish presented, superseded, deferred, and closed attempts, revalidate + prepared generations before drawing, and accept an atomic + `requestPresentationFeedback` option. ## Required Gates diff --git a/protocols/request-bridge-policy.json b/protocols/request-bridge-policy.json index 5b6990cd..1db4766c 100644 --- a/protocols/request-bridge-policy.json +++ b/protocols/request-bridge-policy.json @@ -52,7 +52,6 @@ "wl_surface": { "wrapperPrefix": "surface", "generatedRequests": [ - "frame", "set_buffer_scale" ], "handwrittenByDefault": "test_recording" From 5fece720801e96882763c463aace3793add4470f Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:11:29 -0400 Subject: [PATCH 02/10] Republish redraw after software draw failures Transition failed software presentations through the uncommitted-frame redraw repair before rethrowing. Cover the live request path so failures preserve one redraw without sending a surface transaction. --- .../Internal/Windowing/TopLevelWindow.swift | 48 +++++++++++-------- .../WindowModel+SoftwarePresentation.swift | 14 ++++++ .../Internal/Windowing/WindowModel.swift | 5 +- .../Internal/Windowing/WindowModelTypes.swift | 4 ++ ...WindowModelSoftwarePresentationTests.swift | 18 +++++++ ...ftwarePresentationPublicRequestTests.swift | 26 ++++++++++ 6 files changed, 94 insertions(+), 21 deletions(-) diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index 61735beb..70c17c76 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -470,10 +470,7 @@ package final class TopLevelWindow { ) return .reserved(reservedFrame.reservation) } catch let failure as WindowSoftwarePresentationFailure { - failActivePresentation( - generation: request.generation, - error: failure.presentationError - ) + failSoftwarePresentationIfStillActive(generation: request.generation) throw failure.underlying } catch { failPresentationIfStillActive( @@ -603,10 +600,7 @@ package final class TopLevelWindow { try interpretSoftwarePresentationFollowUp(result.followUp) return result.outcome } catch let failure as WindowSoftwarePresentationFailure { - failActivePresentation( - generation: request.generation, - error: failure.presentationError - ) + failSoftwarePresentationIfStillActive(generation: request.generation) if case .userDraw = failure.presentationError { throw WindowSoftwareDrawFailure(underlying: failure.underlying) } @@ -698,9 +692,8 @@ package final class TopLevelWindow { } } catch let failure as WindowSoftwarePresentationFailure { pendingReservation.reservedFrame.drawingBuffer.discard() - failActivePresentation( - generation: pendingReservation.request.generation, - error: failure.presentationError + failSoftwarePresentationIfStillActive( + generation: pendingReservation.request.generation ) if case .userDraw = failure.presentationError { throw WindowSoftwareDrawFailure(underlying: failure.underlying) @@ -708,9 +701,8 @@ package final class TopLevelWindow { throw failure.underlying } catch { pendingReservation.reservedFrame.drawingBuffer.discard() - failPresentationIfStillActive( - generation: pendingReservation.request.generation, - error: .surfaceCommit(String(describing: error)) + failSoftwarePresentationIfStillActive( + generation: pendingReservation.request.generation ) throw error } @@ -774,11 +766,8 @@ package final class TopLevelWindow { guard let followUp else { return } switch followUp { - case .fail(let generation, let error): - failActivePresentation( - generation: generation, - error: error - ) + case .fail(let generation, _): + failSoftwarePresentationIfStillActive(generation: generation) case .blockedByBuffer: try interpretWindowEffects(model.reduce(.presentationBlockedByBuffer)) case .resetTransientState: @@ -811,6 +800,27 @@ package final class TopLevelWindow { ) } + private func failSoftwarePresentationIfStillActive(generation: UInt64) { + guard case .drawing(let request) = model.presentation, + request.generation == generation + else { + return + } + + do { + try interpretWindowEffects( + model.reduce( + .softwarePresentationFailed( + generation: generation, + bufferAvailability: .available + ) + ) + ) + } catch { + preconditionFailure("Unexpected software-presentation failure error: \(error)") + } + } + private func failActivePresentation( generation: UInt64, error: PresentationError diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift index 01b9ee9a..61c58f63 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift @@ -10,6 +10,20 @@ extension WindowModel { mutating func reduceSoftwarePresentationSuperseded( _ generation: UInt64, _ bufferAvailability: RedrawBufferAvailability + ) throws -> [WindowEffect] { + try reduceUncommittedSoftwarePresentation(generation, bufferAvailability) + } + + mutating func reduceSoftwarePresentationFailed( + _ generation: UInt64, + _ bufferAvailability: RedrawBufferAvailability + ) throws -> [WindowEffect] { + try reduceUncommittedSoftwarePresentation(generation, bufferAvailability) + } + + private mutating func reduceUncommittedSoftwarePresentation( + _ generation: UInt64, + _ bufferAvailability: RedrawBufferAvailability ) throws -> [WindowEffect] { let windowID = id return try transitionActiveWindowState { activeState in diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift index a80abaef..afa483bc 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift @@ -77,8 +77,7 @@ package struct WindowModel: Equatable, Sendable { return try reduceInitialCommitSent() case .published: return try reducePublished() - case .configureReceived(let sequence): - return try reduceConfigureReceived(sequence) + case .configureReceived(let sequence): return try reduceConfigureReceived(sequence) case .contentInvalidated(let bufferAvailability): return reduceRedraw(.contentInvalidated, bufferAvailability: bufferAvailability) case .frameBecameReady(let bufferAvailability): @@ -92,6 +91,8 @@ package struct WindowModel: Equatable, Sendable { case .presentationStarted(let request): return try reducePresentationStarted(request) case .presentationBlockedByBuffer: return try reducePresentationBlockedByBuffer() + case .softwarePresentationFailed(let generation, let availability): + return try reduceSoftwarePresentationFailed(generation, availability) case .softwarePresentationSuperseded(let generation, let availability): return try reduceSoftwarePresentationSuperseded(generation, availability) case .presentationSucceeded(let generation, let bufferAvailability): diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift index 72378bca..447d28cf 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift @@ -17,6 +17,10 @@ package enum WindowEvent: Equatable, Sendable { ) case presentationStarted(PresentationRequest) case presentationBlockedByBuffer + case softwarePresentationFailed( + generation: UInt64, + bufferAvailability: RedrawBufferAvailability + ) case softwarePresentationSuperseded( generation: UInt64, bufferAvailability: RedrawBufferAvailability diff --git a/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift index 66174e42..cf645d1c 100644 --- a/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift @@ -76,6 +76,24 @@ extension WindowModelPresentationTests { #expect(model.redraw.hasOutstandingRedrawRequest) } + @Test + func failedSoftwarePresentationRepublishesItsUncommittedGeneration() throws { + var (model, request) = try activeModelWithStartedPresentation() + + #expect( + try model.reduce( + .softwarePresentationFailed( + generation: request.generation, + bufferAvailability: .available + ) + ) == [.publishRedrawRequested(windowID)] + ) + #expect(model.presentation == .idle) + #expect(model.redraw.isDirty) + #expect(model.redraw.generationForCurrentDraw == request.generation) + #expect(model.redraw.hasOutstandingRedrawRequest) + } + @Test func supersededSoftwarePresentationWaitsWhenNoBufferIsAvailable() throws { var (model, request) = try activeModelWithStartedPresentation() diff --git a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift index ec327601..ce784a46 100644 --- a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift @@ -25,6 +25,7 @@ try await exerciseRepeatedSupersessions(window, displayEvents) try await exerciseCancellation(window, displayEvents) try await exercisePreparationFailure(window, displayEvents) + try await exerciseDrawFailure(window, displayEvents) #expect(try await window.needsRedraw) try await exerciseClose(window) } @@ -158,6 +159,30 @@ } } + private func exerciseDrawFailure( + _ window: Window, + _ displayEvents: DisplayEvents + ) async throws { + _ = try await softwareRedrawEvent( + for: window, + in: displayEvents, + phase: "draw-failure replacement" + ) { + do { + _ = try await window.redraw( + requestPresentationFeedback: true, + preparing: { _ in () }, + { _, _ in + throw InjectedSoftwareDrawFailure() + } + ) + Issue.record("expected draw failure") + } catch let failure as WindowSoftwareDrawFailure { + #expect(failure.underlying is InjectedSoftwareDrawFailure) + } + } + } + private func exerciseClose(_ window: Window) async throws { let gate = SoftwarePreparationRequestGate() async let outcome = window.redraw( @@ -316,4 +341,5 @@ private struct UnexpectedSoftwarePresentationDraw: Error {} private struct InjectedSoftwarePreparationFailure: Error {} + private struct InjectedSoftwareDrawFailure: Error {} #endif From 63ebbf6d6092941368ec01bc503c5c244a051357 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:19:21 -0400 Subject: [PATCH 03/10] Reject wrapped software presentation identities Pair wrapped redraw generations with an exhaustion-checked content identity and validate both before prepared software frames commit. Preserve existing numeric wrap semantics while preventing full-cycle ABA acceptance. --- .../WindowModel+SoftwarePresentation.swift | 5 +++- .../Internal/Windowing/WindowModel.swift | 3 ++- .../Internal/Windowing/WindowModelTypes.swift | 11 ++++++++ .../Windowing/WindowRedrawState.swift | 26 +++++++++++++++++++ .../WindowModelPresentationTests.swift | 3 ++- ...WindowModelSoftwarePresentationTests.swift | 24 +++++++++++++++++ 6 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift index 61c58f63..8acf7da3 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel+SoftwarePresentation.swift @@ -4,7 +4,10 @@ extension WindowModel { return activeState.presentation == .drawing(request: request) && activeState.configure == request.configuration - && activeState.redraw.generationForCurrentDraw == request.generation + && activeState.redraw.matchesCurrentContent( + generation: request.generation, + identity: request.redrawIdentity + ) } mutating func reduceSoftwarePresentationSuperseded( diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift index afa483bc..055a142c 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModel.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModel.swift @@ -252,7 +252,8 @@ extension WindowModel { let generation = activeState.redraw.generationForCurrentDraw let request = PresentationRequest( generation: generation, - configuration: activeState.configure + configuration: activeState.configure, + redrawIdentity: activeState.redraw.identityForCurrentDraw ) activeState.presentation = .requested(request: request) return [.performSoftwarePresent(request)] diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift index 447d28cf..df95fad0 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift @@ -53,6 +53,17 @@ package enum WindowEffect: Equatable, Sendable { package struct PresentationRequest: Equatable, Sendable { let generation: UInt64 let configuration: ResolvedWindowConfiguration + let redrawIdentity: WindowRedrawContentIdentity + + init( + generation: UInt64, + configuration: ResolvedWindowConfiguration, + redrawIdentity: WindowRedrawContentIdentity = .initial + ) { + self.generation = generation + self.configuration = configuration + self.redrawIdentity = redrawIdentity + } var summary: WindowPresentationRequestSummary { WindowPresentationRequestSummary( diff --git a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift index 24b6b65e..5975c630 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowRedrawState.swift @@ -20,6 +20,18 @@ enum WindowRedrawEffect: Equatable, Sendable { case publishRedrawRequested } +struct WindowRedrawContentIdentity: Equatable, Sendable { + static let initial = Self(rawValue: 0) + + let rawValue: UInt64 + + func incremented() -> Self { + let (nextValue, overflowed) = rawValue.addingReportingOverflow(1) + precondition(!overflowed, "Window redraw content identity exhausted") + return Self(rawValue: nextValue) + } +} + package enum RedrawBufferAvailability: Equatable, Sendable { case available case unavailable @@ -72,6 +84,7 @@ struct WindowRedrawState: Equatable, Sendable { } private var contentGeneration: UInt64 = 0 + private var contentIdentity = WindowRedrawContentIdentity.initial private var presentedGeneration: UInt64 = 0 private var presentationStatus = PresentationStatus.current private var pacing = Pacing.frameReady(.none) @@ -92,6 +105,17 @@ struct WindowRedrawState: Equatable, Sendable { contentGeneration } + var identityForCurrentDraw: WindowRedrawContentIdentity { + contentIdentity + } + + func matchesCurrentContent( + generation: UInt64, + identity: WindowRedrawContentIdentity + ) -> Bool { + generation == contentGeneration && identity == contentIdentity + } + mutating func reduce( _ event: WindowRedrawEvent, bufferAvailability: RedrawBufferAvailability @@ -130,7 +154,9 @@ struct WindowRedrawState: Equatable, Sendable { extension WindowRedrawState { private mutating func invalidateContent() { + let nextIdentity = contentIdentity.incremented() contentGeneration &+= 1 + contentIdentity = nextIdentity presentationStatus = .invalidated } diff --git a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift index fb589bd4..e48178ae 100644 --- a/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowModelPresentationTests.swift @@ -14,7 +14,8 @@ struct WindowModelPresentationTests { // swiftlint:disable:this type_body_lengt let effects = try model.reduce(.redrawRequestConsumed(bufferAvailability: .available)) let request = PresentationRequest( generation: 1, - configuration: try #require(model.currentConfiguration) + configuration: try #require(model.currentConfiguration), + redrawIdentity: model.redraw.identityForCurrentDraw ) #expect(effects == [.performSoftwarePresent(request)]) diff --git a/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift index cf645d1c..517a2454 100644 --- a/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowModelSoftwarePresentationTests.swift @@ -150,6 +150,13 @@ extension WindowModelPresentationTests { var (model, request) = try activeModelWithStartedPresentation() #expect(model.isCurrentSoftwarePresentation(request)) + let collidingRequest = PresentationRequest( + generation: request.generation, + configuration: request.configuration + ) + #expect(collidingRequest.generation == request.generation) + #expect(collidingRequest.configuration == request.configuration) + #expect(!model.isCurrentSoftwarePresentation(collidingRequest)) _ = try model.reduce(.contentInvalidated(bufferAvailability: .available)) #expect(!model.isCurrentSoftwarePresentation(request)) @@ -163,6 +170,23 @@ extension WindowModelPresentationTests { #expect(!model.isCurrentSoftwarePresentation(request)) } + @Test + func wrappedGenerationDoesNotReuseContentIdentity() { + var redraw = WindowRedrawState() + let previousIdentity = redraw.identityForCurrentDraw + + _ = redraw.reduce(.presented(generation: .max), bufferAvailability: .available) + _ = redraw.reduce(.contentInvalidated, bufferAvailability: .unavailable) + + #expect(redraw.generationForCurrentDraw == 0) + #expect( + !redraw.matchesCurrentContent( + generation: 0, + identity: previousIdentity + ) + ) + } + @Test func manyConsecutiveSoftwareSupersessionsDoNotLoseOrDuplicateRedraws() throws { var (model, request) = try activeModelWithStartedPresentation() From 3d0f7e4568b1c4f34d540dc4a31c7362d98c8a28 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:45:38 -0400 Subject: [PATCH 04/10] Stage software commits before marking buffers busy Reserve callback state and stage recoverable work before marking shared-memory buffers busy. Then request callback and feedback, apply preflighted state, attach damage, and commit without throwing while retaining recoverable rollback behavior for external buffers. --- .../Surfaces/SurfaceFrameCommitter.swift | 32 ++- .../WindowExternalBufferPresenter.swift | 32 ++- .../Windowing/WindowSoftwarePresenter.swift | 198 +++++++----------- .../Rendering/SurfaceRuntimeSubmitTests.swift | 72 +++++++ .../WindowSoftwarePresenterTests.swift | 129 +----------- 5 files changed, 200 insertions(+), 263 deletions(-) diff --git a/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift b/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift index 5829a141..baabee17 100644 --- a/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift +++ b/Sources/WaylandClient/Internal/Surfaces/SurfaceFrameCommitter.swift @@ -70,6 +70,24 @@ package struct StagedSurfaceFrameCommit { } enum SurfaceFrameCommitter { + static func reserveFrameCallback( + runtime: inout SurfaceRuntime, + generation: UInt64 + ) throws { + try runtime.requestFrameCallback(generation: generation) + } + + static func requestReservedFrameCallback( + on surface: RawSurface, + onFrame: @escaping () -> Void + ) -> FrameCallbackRegistration { + do { + return try surface.requestFrame(onDone: onFrame) + } catch { + preconditionFailure("Reserved frame callback request failed: \(error)") + } + } + static func requestFrameCallback( on surface: RawSurface, runtime: inout SurfaceRuntime, @@ -134,8 +152,6 @@ enum SurfaceFrameCommitter { plan: preparedCommit.plan, payload: preparedCommit.payload.committedPayload ) - try preparedCommit.submitConstraints?.apply() - preparedCommit.metadata?.apply() return StagedSurfaceFrameCommit( preparedCommit: preparedCommit, committedFrame: committedFrame @@ -157,6 +173,7 @@ enum SurfaceFrameCommitter { runtime: inout SurfaceRuntime ) -> SurfaceCommitPlan { let preparedCommit = stagedCommit.preparedCommit + applyPreflightedCommitState(preparedCommit) preparedCommit.surface.setBufferScale(preparedCommit.plan.bufferScale) preparedCommit.scaleInstallation.applyViewportDestinationIfNeeded( preparedCommit.plan.viewportDestination @@ -174,6 +191,17 @@ enum SurfaceFrameCommitter { return preparedCommit.plan } + private static func applyPreflightedCommitState( + _ preparedCommit: PreparedSurfaceFrameCommit + ) { + do { + try preparedCommit.submitConstraints?.apply() + } catch { + preconditionFailure("Preflighted surface submit constraints failed: \(error)") + } + preparedCommit.metadata?.apply() + } + private static func apply(_ damage: SurfaceDamageExtent, to surface: RawSurface) { switch damage { case .buffer(let rectangles): diff --git a/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift b/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift index 3f2295ea..f2705b2c 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift @@ -13,10 +13,6 @@ struct WindowExternalBufferPresentationRequest { } enum WindowExternalBufferPresenter { - private enum PresentationError: Error { - case missingCommitPlan - } - static func present( _ request: WindowExternalBufferPresentationRequest, runtime: inout SurfaceRuntime, @@ -76,24 +72,24 @@ enum WindowExternalBufferPresenter { commitPlan: SurfaceCommitPlan, presentationFeedbackIdentity: SurfacePresentationIdentity? ) { - var committedPlan: SurfaceCommitPlan? - let feedbackIdentity = try WindowSoftwarePresentationCommitSequence.perform { - try requestFrameCallback() - } requestPresentationFeedback: { - try requestPresentationFeedback() - } commit: { - committedPlan = try commit() - } cancelFrameCallback: { + try requestFrameCallback() + + let feedbackIdentity: SurfacePresentationIdentity? + do { + feedbackIdentity = try requestPresentationFeedback() + } catch { + cancelFrameCallback() + throw error + } + + do { + return (try commit(), feedbackIdentity) + } catch { cancelFrameCallback() - } cleanupAfterFailure: { feedbackIdentity in if let feedbackIdentity { cancelPresentationFeedback(feedbackIdentity) } + throw error } - - guard let committedPlan else { - throw PresentationError.missingCommitPlan - } - return (committedPlan, feedbackIdentity) } } diff --git a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift index 34f785f3..b92cd571 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift @@ -1,5 +1,3 @@ -// swiftlint:disable file_length - import WaylandRaw struct WindowSoftwarePresentationResult { @@ -59,36 +57,16 @@ package struct WindowPresentationFeedbackCommitRequest { package enum WindowSoftwarePresentationCommitSequence { @discardableResult package static func perform( - requestFrameCallback: () throws -> Void, - requestPresentationFeedback: () throws -> SurfacePresentationIdentity?, - commit: () throws -> Void, - cancelFrameCallback: () -> Void, - cleanupAfterFailure: (SurfacePresentationIdentity?) -> Void - ) throws -> SurfacePresentationIdentity? { - do { - try requestFrameCallback() - } catch { - cleanupAfterFailure(nil) - throw error - } - - let feedbackIdentity: SurfacePresentationIdentity? - do { - feedbackIdentity = try requestPresentationFeedback() - } catch { - cancelFrameCallback() - cleanupAfterFailure(nil) - throw error - } - - do { - try commit() - return feedbackIdentity - } catch { - cancelFrameCallback() - cleanupAfterFailure(feedbackIdentity) - throw error - } + markDrawingBufferBusy: () -> Void, + requestFrameCallback: () -> Void, + requestPresentationFeedback: () -> SurfacePresentationIdentity?, + commit: () -> Void + ) -> SurfacePresentationIdentity? { + markDrawingBufferBusy() + requestFrameCallback() + let feedbackIdentity = requestPresentationFeedback() + commit() + return feedbackIdentity } } @@ -277,33 +255,33 @@ struct WindowSoftwarePresenter { pendingFrameRegistration: inout FrameCallbackRegistration?, drawingBuffer: inout RawBuffer.DrawingBuffer ) throws { - _ = try WindowSoftwarePresentationCommitSequence.perform( + let stagedCommit: StagedSurfaceFrameCommit + do { + stagedCommit = try stageCommit(context: context, runtime: &runtime) + } catch { + drawingBuffer.discard() + throw error + } + + _ = WindowSoftwarePresentationCommitSequence.perform( + markDrawingBufferBusy: { + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + }, requestFrameCallback: { - try requestFrameCallback( - request: context.request, - runtime: &runtime, + requestReservedFrameCallback( pendingFrameRegistration: &pendingFrameRegistration ) }, requestPresentationFeedback: { - try requestPresentationFeedback(context.presentationFeedback) + requestPresentationFeedbackAtPointOfNoReturn( + context.presentationFeedback + ) }, commit: { - try recordAndCommit( - context: context, + SurfaceFrameCommitter.commit( + stagedCommit, runtime: &runtime, - drawingBuffer: &drawingBuffer ) - }, - cancelFrameCallback: { - pendingFrameRegistration = nil - runtime.cancelFrameCallback() - }, - cleanupAfterFailure: { identity in - if let identity { - context.presentationFeedback?.cancel(identity) - } - drawingBuffer.discard() } ) } @@ -426,57 +404,52 @@ struct WindowSoftwarePresenter { } } - private func requestFrameCallback( - request: PresentationRequest, - runtime: inout SurfaceRuntime, + private func requestReservedFrameCallback( pendingFrameRegistration: inout FrameCallbackRegistration? - ) throws { - do { - pendingFrameRegistration = try SurfaceFrameCommitter.requestFrameCallback( - on: surface, - runtime: &runtime, - generation: request.generation, - onFrame: onFrame - ) - } catch { - throw WindowSoftwarePresentationFailure( - presentationError: .frameCallbackRequest(String(describing: error)), - underlying: error - ) - } + ) { + pendingFrameRegistration = SurfaceFrameCommitter.requestReservedFrameCallback( + on: surface, + onFrame: onFrame + ) } - private func requestPresentationFeedback( + private func requestPresentationFeedbackAtPointOfNoReturn( _ presentationFeedback: WindowPresentationFeedbackCommitRequest? - ) throws -> SurfacePresentationIdentity? { + ) -> SurfacePresentationIdentity? { guard let presentationFeedback else { return nil } do { return try presentationFeedback.request() } catch { - throw WindowSoftwarePresentationFailure( - presentationError: .presentationFeedbackRequest(String(describing: error)), - underlying: error + preconditionFailure( + "Prepared presentation feedback request failed: \(error)" ) } } - private func recordAndCommit( + private func stageCommit( context: WindowSoftwareCommitContext, - runtime: inout SurfaceRuntime, - drawingBuffer: inout RawBuffer.DrawingBuffer - ) throws { + runtime: inout SurfaceRuntime + ) throws -> StagedSurfaceFrameCommit { do { - let stagedCommit = try SurfaceFrameCommitter.stage( - context.preparedCommit, - runtime: &runtime + try SurfaceFrameCommitter.reserveFrameCallback( + runtime: &runtime, + generation: context.request.generation ) - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - SurfaceFrameCommitter.commit( - stagedCommit, - runtime: &runtime + } catch { + throw WindowSoftwarePresentationFailure( + presentationError: .frameCallbackRequest(String(describing: error)), + underlying: error + ) + } + + do { + return try SurfaceFrameCommitter.stage( + context.preparedCommit, + runtime: &runtime, ) } catch { + runtime.cancelFrameCallback() throw WindowSoftwarePresentationFailure( presentationError: .surfaceCommit(String(describing: error)), underlying: error @@ -490,57 +463,34 @@ struct WindowSoftwarePresenter { pendingFrameRegistration: inout FrameCallbackRegistration?, drawingBuffer: RawBuffer.ReservedDrawingBuffer ) throws { - _ = try WindowSoftwarePresentationCommitSequence.perform( + let stagedCommit: StagedSurfaceFrameCommit + do { + stagedCommit = try stageCommit(context: context, runtime: &runtime) + } catch { + drawingBuffer.discard() + throw error + } + + _ = WindowSoftwarePresentationCommitSequence.perform( + markDrawingBufferBusy: { + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + }, requestFrameCallback: { - try requestFrameCallback( - request: context.request, - runtime: &runtime, + requestReservedFrameCallback( pendingFrameRegistration: &pendingFrameRegistration ) }, requestPresentationFeedback: { - try requestPresentationFeedback(context.presentationFeedback) + requestPresentationFeedbackAtPointOfNoReturn( + context.presentationFeedback + ) }, commit: { - try recordAndCommitReserved( - context: context, + SurfaceFrameCommitter.commit( + stagedCommit, runtime: &runtime, - drawingBuffer: drawingBuffer ) - }, - cancelFrameCallback: { - pendingFrameRegistration = nil - runtime.cancelFrameCallback() - }, - cleanupAfterFailure: { identity in - if let identity { - context.presentationFeedback?.cancel(identity) - } - drawingBuffer.discard() } ) } - - private func recordAndCommitReserved( - context: WindowSoftwareCommitContext, - runtime: inout SurfaceRuntime, - drawingBuffer: RawBuffer.ReservedDrawingBuffer - ) throws { - do { - let stagedCommit = try SurfaceFrameCommitter.stage( - context.preparedCommit, - runtime: &runtime - ) - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - SurfaceFrameCommitter.commit( - stagedCommit, - runtime: &runtime - ) - } catch { - throw WindowSoftwarePresentationFailure( - presentationError: .surfaceCommit(String(describing: error)), - underlying: error - ) - } - } } diff --git a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift index 0ded1314..2de50943 100644 --- a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift +++ b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift @@ -12,6 +12,20 @@ @Suite(.serialized) struct SurfaceRuntimeSubmitTests { // swiftlint:disable:this type_body_length + @Test + func reservingPendingFrameCallbackFailsBeforeRequestCreation() throws { + var runtime = SurfaceRuntime(role: .toplevelWindow) + try runtime.requestFrameCallback(generation: 17) + + #expect(throws: SurfaceTransactionError.frameCallbackAlreadyPending(generation: 17)) { + try SurfaceFrameCommitter.reserveFrameCallback( + runtime: &runtime, + generation: 18 + ) + } + #expect(runtime.transactionSnapshot.pendingFrameCallbackGeneration == 17) + } + @Test func submitCapabilitySnapshotPublishesSynchronizationAndPacingFacts() { var runtime = SurfaceRuntime(role: .toplevelWindow) @@ -195,6 +209,62 @@ ) } + @Test + func externalBufferFrameCallbackFailureSkipsLaterOperations() { + var events: [ExternalBufferPresentationEvent] = [] + + #expect(throws: InjectedExternalFrameFailure.self) { + _ = try WindowExternalBufferPresenter.performCommitSequence { + events.append(.frameCallback) + throw InjectedExternalFrameFailure() + } requestPresentationFeedback: { + events.append(.presentationFeedback) + return SurfacePresentationIdentity(rawValue: 32) + } commit: { + events.append(.commit) + return try testSurfaceCommitPlan() + } cancelFrameCallback: { + events.append(.cancelFrameCallback) + } cancelPresentationFeedback: { cancelledIdentity in + events.append(.cancelPresentationFeedback(cancelledIdentity)) + } + } + + #expect(events == [.frameCallback]) + } + + @Test + func externalBufferCommitFailureCancelsFrameAndFeedback() { + var events: [ExternalBufferPresentationEvent] = [] + let identity = SurfacePresentationIdentity(rawValue: 33) + + #expect(throws: InjectedExternalCommitFailure.self) { + _ = try WindowExternalBufferPresenter.performCommitSequence { + events.append(.frameCallback) + } requestPresentationFeedback: { + events.append(.presentationFeedback) + return identity + } commit: { + events.append(.commit) + throw InjectedExternalCommitFailure() + } cancelFrameCallback: { + events.append(.cancelFrameCallback) + } cancelPresentationFeedback: { cancelledIdentity in + events.append(.cancelPresentationFeedback(cancelledIdentity)) + } + } + + #expect( + events == [ + .frameCallback, + .presentationFeedback, + .commit, + .cancelFrameCallback, + .cancelPresentationFeedback(identity), + ] + ) + } + @Test func frameCommitterRejectsUnavailableSubmitConstraintsDuringPreparation() async throws { try await CoreRequestRecordingGate.withExclusiveRecording { @@ -633,6 +703,8 @@ } private struct InjectedExternalFeedbackFailure: Error {} + private struct InjectedExternalFrameFailure: Error {} + private struct InjectedExternalCommitFailure: Error {} // swiftlint:enable closure_body_length #endif diff --git a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift index 9d4b63bc..4e066791 100644 --- a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift +++ b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift @@ -9,140 +9,33 @@ @Suite struct WindowSoftwarePresentationCommitSequenceTests { @Test(arguments: ManagedPresentationOperation.allCases) - func managedSubmissionRequestsPresentationFeedbackBeforeCommit( + func pointOfNoReturnMarksBufferBusyBeforeProtocolRequests( operation: ManagedPresentationOperation - ) throws { + ) { var events: [CommitSequenceEvent] = [] let identity = SurfacePresentationIdentity(rawValue: 7) - let returnedIdentity = try WindowSoftwarePresentationCommitSequence.perform { + let returnedIdentity = WindowSoftwarePresentationCommitSequence.perform { + events.append(.markDrawingBufferBusy) + } requestFrameCallback: { events.append(.frameCallback) } requestPresentationFeedback: { events.append(.presentationFeedback(operation)) return identity } commit: { events.append(.commit(operation)) - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cleanupAfterFailure: { identity in - if let identity { - events.append(.cancelPresentationFeedback(identity)) - } - events.append(.discardDrawingBuffer) } #expect(returnedIdentity == identity) #expect( events == [ + .markDrawingBufferBusy, .frameCallback, .presentationFeedback(operation), .commit(operation), ] ) } - - @Test - func frameCallbackRequestFailureSkipsFeedbackAndCommit() { - var events: [CommitSequenceEvent] = [] - - do { - try WindowSoftwarePresentationCommitSequence.perform { - events.append(.frameCallback) - throw InjectedFrameCallbackFailure() - } requestPresentationFeedback: { - events.append(.presentationFeedback(.show)) - return SurfacePresentationIdentity(rawValue: 3) - } commit: { - events.append(.commit(.show)) - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cleanupAfterFailure: { identity in - if let identity { - events.append(.cancelPresentationFeedback(identity)) - } - events.append(.discardDrawingBuffer) - } - Issue.record("expected frame callback request failure") - } catch is InjectedFrameCallbackFailure { - #expect(events == [.frameCallback, .discardDrawingBuffer]) - } catch { - Issue.record("unexpected error: \(error)") - } - } - - @Test - func presentationFeedbackRequestFailureDoesNotCommitFrame() { - var events: [CommitSequenceEvent] = [] - - do { - try WindowSoftwarePresentationCommitSequence.perform { - events.append(.frameCallback) - } requestPresentationFeedback: { - events.append(.presentationFeedback(.show)) - throw InjectedPresentationFeedbackFailure() - } commit: { - events.append(.commit(.show)) - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cleanupAfterFailure: { identity in - if let identity { - events.append(.cancelPresentationFeedback(identity)) - } - events.append(.discardDrawingBuffer) - } - Issue.record("expected presentation feedback request failure") - } catch is InjectedPresentationFeedbackFailure { - #expect( - events == [ - .frameCallback, - .presentationFeedback(.show), - .cancelFrameCallback, - .discardDrawingBuffer, - ] - ) - } catch { - Issue.record("unexpected error: \(error)") - } - } - - @Test - func commitFailureCancelsPresentationFeedback() { - var events: [CommitSequenceEvent] = [] - let identity = SurfacePresentationIdentity(rawValue: 9) - - do { - try WindowSoftwarePresentationCommitSequence.perform { - events.append(.frameCallback) - } requestPresentationFeedback: { - events.append(.presentationFeedback(.redraw)) - return identity - } commit: { - events.append(.commit(.redraw)) - throw InjectedCommitFailure() - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cleanupAfterFailure: { cancelledIdentity in - if let cancelledIdentity { - events.append(.cancelPresentationFeedback(cancelledIdentity)) - } - events.append(.discardDrawingBuffer) - } - Issue.record("expected commit failure") - } catch is InjectedCommitFailure { - #expect( - events == [ - .frameCallback, - .presentationFeedback(.redraw), - .commit(.redraw), - .cancelFrameCallback, - .cancelPresentationFeedback(identity), - .discardDrawingBuffer, - ] - ) - } catch { - Issue.record("unexpected error: \(error)") - } - } } @Suite(.serialized) @@ -252,7 +145,10 @@ #expect(!hasPendingRegistration) #expect(!pool.hasBusyBuffers) #expect(pool.hasFreeBuffers) + #expect(unsafe swl_test_core_request_record().frame_sequence == 0) #expect(unsafe swl_test_core_request_record().attach_sequence == 0) + #expect(unsafe swl_test_core_request_record().damage_sequence == 0) + #expect(unsafe swl_test_presentation_request_record().call_count == 0) #expect(unsafe swl_test_core_request_record().commit_sequence == 0) } @@ -425,16 +321,11 @@ } private enum CommitSequenceEvent: Equatable { + case markDrawingBufferBusy case frameCallback case presentationFeedback(ManagedPresentationOperation) case commit(ManagedPresentationOperation) - case cancelFrameCallback - case cancelPresentationFeedback(SurfacePresentationIdentity) - case discardDrawingBuffer } - private struct InjectedPresentationFeedbackFailure: Error {} - private struct InjectedFrameCallbackFailure: Error {} - private struct InjectedCommitFailure: Error {} private struct InjectedDrawFailure: Error {} #endif From dd8d5ecc17498b656839328eb49aaf7ed719e5c1 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:11:19 -0400 Subject: [PATCH 05/10] Serialize presentation request recording Protect forwarding hooks and captured snapshots with one mutex. Prove that a forwarded feedback request reaches Wayland and that ending recording restores the default hook. --- .../shims/requests-presentation.c | 37 +++++++++++- ...ftwarePresentationPublicRequestTests.swift | 57 ++++++++++++++++--- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/Sources/CWaylandProtocols/shims/requests-presentation.c b/Sources/CWaylandProtocols/shims/requests-presentation.c index 4abe9869..55258c66 100644 --- a/Sources/CWaylandProtocols/shims/requests-presentation.c +++ b/Sources/CWaylandProtocols/shims/requests-presentation.c @@ -2,6 +2,19 @@ #include "generated/stable/presentation-time/presentation-time-client-protocol.h" #ifdef SWL_ENABLE_TESTING +#include + +// Live request tests switch these hooks from the test task while requests run +// on the display thread. This mutex keeps hook changes and captured records in +// one order that ThreadSanitizer can observe. +static pthread_mutex_t swl_test_presentation_request_mutex = + PTHREAD_MUTEX_INITIALIZER; + +#define SWL_PRESENTATION_REQUEST_LOCK() \ + ((void)pthread_mutex_lock(&swl_test_presentation_request_mutex)) +#define SWL_PRESENTATION_REQUEST_UNLOCK() \ + ((void)pthread_mutex_unlock(&swl_test_presentation_request_mutex)) + static struct swl_test_presentation_request_record swl_test_presentation_request_latest; @@ -85,6 +98,8 @@ static void swl_test_presentation_feedback_destroy_record( SWL_TEST_PRESENTATION_FEEDBACK_DESTROY, feedback, NULL, feedback); } #else +#define SWL_PRESENTATION_REQUEST_LOCK() ((void)0) +#define SWL_PRESENTATION_REQUEST_UNLOCK() ((void)0) #define swl_presentation_feedback_impl wp_presentation_feedback #define swl_presentation_destroy_impl wp_presentation_destroy #define swl_presentation_feedback_destroy_impl wp_presentation_feedback_destroy @@ -94,33 +109,44 @@ struct wp_presentation_feedback *swl_wp_presentation_feedback( struct wp_presentation *presentation, struct wl_surface *surface) { - return swl_presentation_feedback_impl(presentation, surface); + SWL_PRESENTATION_REQUEST_LOCK(); + struct wp_presentation_feedback *feedback = + swl_presentation_feedback_impl(presentation, surface); + SWL_PRESENTATION_REQUEST_UNLOCK(); + return feedback; } void swl_wp_presentation_destroy(struct wp_presentation *presentation) { + SWL_PRESENTATION_REQUEST_LOCK(); swl_presentation_destroy_impl(presentation); + SWL_PRESENTATION_REQUEST_UNLOCK(); } void swl_wp_presentation_feedback_destroy( struct wp_presentation_feedback *feedback) { + SWL_PRESENTATION_REQUEST_LOCK(); swl_presentation_feedback_destroy_impl(feedback); + SWL_PRESENTATION_REQUEST_UNLOCK(); } #ifdef SWL_ENABLE_TESTING void swl_test_presentation_request_recording_begin(void) { + SWL_PRESENTATION_REQUEST_LOCK(); swl_test_presentation_request_latest = (struct swl_test_presentation_request_record){0}; swl_presentation_feedback_impl = swl_test_presentation_feedback_record; swl_presentation_destroy_impl = swl_test_presentation_destroy_record; swl_presentation_feedback_destroy_impl = swl_test_presentation_feedback_destroy_record; + SWL_PRESENTATION_REQUEST_UNLOCK(); } void swl_test_presentation_request_recording_begin_forwarding(void) { + SWL_PRESENTATION_REQUEST_LOCK(); swl_test_presentation_request_latest = (struct swl_test_presentation_request_record){0}; swl_presentation_feedback_impl = @@ -128,19 +154,26 @@ void swl_test_presentation_request_recording_begin_forwarding(void) swl_presentation_destroy_impl = swl_presentation_destroy_default; swl_presentation_feedback_destroy_impl = swl_presentation_feedback_destroy_default; + SWL_PRESENTATION_REQUEST_UNLOCK(); } void swl_test_presentation_request_recording_end(void) { + SWL_PRESENTATION_REQUEST_LOCK(); swl_presentation_feedback_impl = swl_presentation_feedback_default; swl_presentation_destroy_impl = swl_presentation_destroy_default; swl_presentation_feedback_destroy_impl = swl_presentation_feedback_destroy_default; + SWL_PRESENTATION_REQUEST_UNLOCK(); } struct swl_test_presentation_request_record swl_test_presentation_request_record(void) { - return swl_test_presentation_request_latest; + SWL_PRESENTATION_REQUEST_LOCK(); + struct swl_test_presentation_request_record record = + swl_test_presentation_request_latest; + SWL_PRESENTATION_REQUEST_UNLOCK(); + return record; } #endif diff --git a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift index ce784a46..45d30f12 100644 --- a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift @@ -2,10 +2,9 @@ import CWaylandProtocols import Foundation import Testing + import WaylandClient import WaylandTestSupport - @testable import WaylandClient - @Suite( .enabled( if: SoftwarePresentationRequestTestEnvironment.isEnabled, @@ -19,6 +18,39 @@ .serialized ) struct WindowSoftwarePresentationPublicRequestTests { + @Test + func forwardingPresentationRecorderRecordsAndRestores() async throws { + try await withReadySoftwarePresentationConnection { window, _ in + swl_test_core_request_recording_begin_forwarding() + defer { swl_test_core_request_recording_end() } + + swl_test_presentation_request_recording_begin_forwarding() + let outcome: SoftwarePresentationOutcome + do { + defer { swl_test_presentation_request_recording_end() } + outcome = try await window.redraw( + requestPresentationFeedback: true, + preparing: { _ in () }, + { _, frame in + fillSoftwareFrame(frame, color: 0x0034_4424) + } + ) + let record = unsafe swl_test_presentation_request_record() + #expect(unsafe record.call_count == 1) + #expect(unsafe record.kind == SWL_TEST_PRESENTATION_FEEDBACK) + #expect(unsafe record.feedback != nil) + } + #expect(outcome == .presented) + + let recordedCallCount = unsafe swl_test_presentation_request_record().call_count + try await window.requestPresentationFeedback() + #expect( + unsafe swl_test_presentation_request_record().call_count + == recordedCallCount + ) + } + } + @Test func rejectedPreparationsIssueNoSurfaceTransactionRequests() async throws { try await withRecordedSoftwarePresentationConnection { window, displayEvents in @@ -34,6 +66,20 @@ private func withRecordedSoftwarePresentationConnection( _ operation: @Sendable (Window, DisplayEvents) async throws -> Void + ) async throws { + try await withReadySoftwarePresentationConnection { window, displayEvents in + swl_test_core_request_recording_begin_forwarding() + swl_test_presentation_request_recording_begin_forwarding() + defer { swl_test_presentation_request_recording_end() } + defer { swl_test_core_request_recording_end() } + + try await operation(window, displayEvents) + expectNoSurfaceTransactionRequests() + } + } + + private func withReadySoftwarePresentationConnection( + _ operation: @Sendable (Window, DisplayEvents) async throws -> Void ) async throws { try await CoreRequestRecordingGate.withExclusiveRecording { try await PresentationRequestRecordingGate.withExclusiveRecording { @@ -47,14 +93,7 @@ in: displayEvents, phase: "initial redraw request" ) - - swl_test_core_request_recording_begin_forwarding() - swl_test_presentation_request_recording_begin_forwarding() - defer { swl_test_presentation_request_recording_end() } - defer { swl_test_core_request_recording_end() } - try await operation(window, displayEvents) - expectNoSurfaceTransactionRequests() } } } From 6a1f53268b162d3d2f895532f368eb63547a2fc7 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:13:26 -0400 Subject: [PATCH 06/10] Expose atomic feedback on simple software draws Provide show and redraw overloads that return presentation outcomes without requiring a no-op preparation closure. Record the symbols, compatibility behavior, and external usage in the public API baselines and documentation. --- ...dDisplayPresentationIntegrationTests.swift | 10 +--- .../WaylandDisplayPublicAPISurfaceTests.swift | 22 ++++++- .../Window+SoftwareFramePreparation.swift | 60 +++++++++++++++++++ .../WaylandClient.docc/WindowDrawing.md | 6 ++ docs/documentation-symbol-coverage.json | 2 +- docs/public-api-audit.md | 16 ++--- docs/public-api-baseline.md | 8 +++ docs/release.md | 12 ++-- 8 files changed, 115 insertions(+), 21 deletions(-) diff --git a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift index 77fa8404..d30bb36e 100644 --- a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift +++ b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPresentationIntegrationTests.swift @@ -273,13 +273,9 @@ private func expectPresentationFeedback( try await window.requestRedraw() } ) - let outcome = try await window.redraw( - requestPresentationFeedback: true, - preparing: { _ in () }, - { _, frame in - fill(frame, color: 0x0044_2414) - } - ) + let outcome = try await window.redraw(requestPresentationFeedback: true) { frame in + fill(frame, color: 0x0044_2414) + } #expect(outcome == .presented) let feedback: SurfacePresentationFeedback? diff --git a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift index 2f3c09e3..b4eadda6 100644 --- a/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift +++ b/IntegrationTests/PublicAPIClient/Tests/WaylandPublicIntegrationClientTests/WaylandDisplayPublicAPISurfaceTests.swift @@ -274,6 +274,17 @@ struct WaylandPresentationAPISurfaceTests { private func useAtomicSoftwarePresentationAPI( _ window: Window ) async throws -> [SoftwarePresentationOutcome] { + let simpleShow = try await window.show(requestPresentationFeedback: true) { _ in () } + let simpleRedraw = try await window.redraw(requestPresentationFeedback: true) { _ in () } + let simpleDamagedShow = try await window.show( + damage: nil, + requestPresentationFeedback: true, + timeoutMilliseconds: 1_000 + ) { _ in () } + let simpleDamagedRedraw = try await window.redraw( + damage: nil, + requestPresentationFeedback: true + ) { _ in () } let shown = try await window.show( requestPresentationFeedback: true, preparing: { reservation in reservation.id }, @@ -297,7 +308,16 @@ struct WaylandPresentationAPISurfaceTests { preparing: { reservation in reservation.id }, { _, _ in () } ) - return [shown, redrawn, damagedShow, damagedRedraw] + return [ + simpleShow, + simpleRedraw, + simpleDamagedShow, + simpleDamagedRedraw, + shown, + redrawn, + damagedShow, + damagedRedraw, + ] } } diff --git a/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift b/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift index 884f13a9..aa39c468 100644 --- a/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift +++ b/Sources/WaylandClient/Public/Window/Window+SoftwareFramePreparation.swift @@ -12,6 +12,38 @@ public enum SoftwarePresentationOutcome: Equatable, Sendable { } extension Window { + @discardableResult + public func show( + requestPresentationFeedback: Bool, + timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, + _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void + ) async throws -> SoftwarePresentationOutcome { + try await show( + damage: nil, + requestPresentationFeedback: requestPresentationFeedback, + timeoutMilliseconds: timeoutMilliseconds, + draw + ) + } + + @discardableResult + public func show( + damage: SurfaceDamageRegion?, + requestPresentationFeedback: Bool, + timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, + _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void + ) async throws -> SoftwarePresentationOutcome { + try await show( + damage: damage, + timeoutMilliseconds: timeoutMilliseconds, + requestPresentationFeedback: requestPresentationFeedback, + preparing: { _ in () }, + { _, frame in + try draw(frame) + } + ) + } + @discardableResult public func show( timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, @@ -73,6 +105,34 @@ extension Window { } } + @discardableResult + public func redraw( + requestPresentationFeedback: Bool, + _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void + ) async throws -> SoftwarePresentationOutcome { + try await redraw( + damage: nil, + requestPresentationFeedback: requestPresentationFeedback, + draw + ) + } + + @discardableResult + public func redraw( + damage: SurfaceDamageRegion?, + requestPresentationFeedback: Bool, + _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void + ) async throws -> SoftwarePresentationOutcome { + try await redraw( + damage: damage, + requestPresentationFeedback: requestPresentationFeedback, + preparing: { _ in () }, + { _, frame in + try draw(frame) + } + ) + } + @discardableResult public func redraw( requestPresentationFeedback: Bool = false, diff --git a/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md b/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md index 9c13160b..1698dbc8 100644 --- a/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md +++ b/Sources/WaylandClient/WaylandClient.docc/WindowDrawing.md @@ -22,6 +22,12 @@ WaylandClientKit revalidates the exact reservation, window, configure, authoritative geometry, redraw generation, and task cancellation before it borrows mutable bytes. +When asynchronous preparation is unnecessary, use +``Window/show(damage:requestPresentationFeedback:timeoutMilliseconds:_:)`` or +``Window/redraw(damage:requestPresentationFeedback:_:)`` to request feedback +atomically without a no-op preparation closure. These overloads return the same +``SoftwarePresentationOutcome``. + If the prepared generation is still current, WaylandClientKit draws, requests the frame callback and optional presentation feedback, commits the surface, and records the generation without another suspension point. If it is no longer diff --git a/docs/documentation-symbol-coverage.json b/docs/documentation-symbol-coverage.json index bfc93315..f3459089 100644 --- a/docs/documentation-symbol-coverage.json +++ b/docs/documentation-symbol-coverage.json @@ -2,7 +2,7 @@ "products" : { "WaylandClient" : { "documented" : 151, - "eligible" : 1526 + "eligible" : 1530 }, "WaylandGraphicsPreview" : { "documented" : 32, diff --git a/docs/public-api-audit.md b/docs/public-api-audit.md index 676c68d0..b91c5f21 100644 --- a/docs/public-api-audit.md +++ b/docs/public-api-audit.md @@ -212,13 +212,15 @@ Current user-facing contract: `SoftwareFrameReservation` lets async preparation observe the selected software buffer identity and geometry before the final scoped draw borrow. Async `Window.show(preparing:_:)` and `Window.redraw(preparing:_:)` calls are - latest-wins transactions and return `SoftwarePresentationOutcome`. After - preparation resumes, the library revalidates the exact reservation, window, - configure, authoritative geometry, redraw generation, and task cancellation. - A stale attempt is discarded without drawing, requesting a frame callback or - presentation feedback, or committing the surface. Its replacement redraw is - published once through normal pacing. Presentation feedback requested by the - async API belongs to the same eventual surface commit. + latest-wins transactions and return `SoftwarePresentationOutcome`. Matching + overloads without a preparation closure provide the same outcome and atomic + presentation-feedback option. After preparation resumes, the library + revalidates the exact reservation, window, configure, authoritative geometry, + redraw generation, and task cancellation. A stale attempt is discarded + without drawing, requesting a frame callback or presentation feedback, or + committing the surface. Its replacement redraw is published once through + normal pacing. Presentation feedback requested by these APIs belongs to the + same eventual surface commit. - Window sizes are logical surface sizes. `SurfaceGeometry` records the logical size, buffer-pixel size, and exact `SurfaceScale` used by the current SHM frame. diff --git a/docs/public-api-baseline.md b/docs/public-api-baseline.md index e7a14538..2e688d50 100644 --- a/docs/public-api-baseline.md +++ b/docs/public-api-baseline.md @@ -2109,7 +2109,9 @@ s:13WaylandClient6WindowV11needsRedrawSbvp swift.property Window.needsRedraw var s:13WaylandClient6WindowV18presentationEventsAA0c12PresentationE0Vvp swift.property Window.presentationEvents var presentationEvents: WindowPresentationEvents { get } - s:13WaylandClient6WindowV6redrawyyyAA13SoftwareFrameVYbKXEYaKF swift.method Window.redraw(_:) func redraw(_ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - s:13WaylandClient6WindowV6redraw6damage_yAA19SurfaceDamageRegionVSg_yAA13SoftwareFrameVYbKXEtYaKF swift.method Window.redraw(damage:_:) func redraw(damage: SurfaceDamageRegion?, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - +s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbyAA0I5FrameVYbKXEtYaKF swift.method Window.redraw(damage:requestPresentationFeedback:_:) @discardableResult func redraw(damage: SurfaceDamageRegion?, requestPresentationFeedback: Bool, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"}} s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback9preparing_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbxAA0J16FrameReservationVYaYbKYCXEyx_AA0jO0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(damage:requestPresentationFeedback:preparing:_:) @discardableResult func redraw(damage: SurfaceDamageRegion?, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV6redraw27requestPresentationFeedback_AA08SoftwareF7OutcomeOSb_yAA0H5FrameVYbKXEtYaKF swift.method Window.redraw(requestPresentationFeedback:_:) @discardableResult func redraw(requestPresentationFeedback: Bool, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"}} s:13WaylandClient6WindowV6redraw27requestPresentationFeedback9preparing_AA08SoftwareF7OutcomeOSb_xAA0I16FrameReservationVYaYbKYCXEyx_AA0iK0VtYbKXEtYaKs8SendableRzlF swift.method Window.redraw(requestPresentationFeedback:preparing:_:) @discardableResult func redraw(requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} s:13WaylandClient6WindowV15relativePointer6seatIDAA08RelativeE12SubscriptionVAA04SeatG0V_tYaKF swift.method Window.relativePointer(seatID:) func relativePointer(seatID: SeatID) async throws -> RelativePointerSubscription - s:13WaylandClient6WindowV22requestActivationToken5appID13serialContext19timeoutMillisecondsAA0eF0VSSSg_AA0e6SerialJ0VSgs5Int32VtYaKF swift.method Window.requestActivationToken(appID:serialContext:timeoutMilliseconds:) func requestActivationToken(appID: String? = nil, serialContext: ActivationSerialContext? = nil, timeoutMilliseconds: Int32 = WaylandDisplay.defaultActivationTokenTimeoutMilliseconds) async throws -> ActivationToken - @@ -2135,8 +2137,10 @@ s:13WaylandClient6WindowV14setMinimumSizeyyAA015PositiveLogicalF0VSgYaKF swift.m s:13WaylandClient6WindowV15setOpaqueRegionyyAA07SurfaceF0VSgYaKF swift.method Window.setOpaqueRegion(_:) func setOpaqueRegion(_ region: SurfaceRegion?) async throws - s:13WaylandClient6WindowV8setTitleyyAA0A6StringVYaKF swift.method Window.setTitle(_:) func setTitle(_ title: WaylandString) async throws - s:13WaylandClient6WindowV8setTitleyySSYaKF swift.method Window.setTitle(_:) func setTitle(_ title: String) async throws - +s:13WaylandClient6WindowV4show6damage27requestPresentationFeedback19timeoutMilliseconds_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_Sbs5Int32VyAA0K5FrameVYbKXEtYaKF swift.method Window.show(damage:requestPresentationFeedback:timeoutMilliseconds:_:) @discardableResult func show(damage: SurfaceDamageRegion?, requestPresentationFeedback: Bool, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"}} s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds_yAA19SurfaceDamageRegionVSg_s5Int32VyAA13SoftwareFrameVYbKXEtYaKF swift.method Window.show(damage:timeoutMilliseconds:_:) func show(damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareI7OutcomeOAA19SurfaceDamageRegionVSg_s5Int32VSbxAA0L16FrameReservationVYaYbKYCXEyx_AA0lR0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(damage:timeoutMilliseconds:requestPresentationFeedback:preparing:_:) @discardableResult func show(damage: SurfaceDamageRegion?, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} +s:13WaylandClient6WindowV4show27requestPresentationFeedback19timeoutMilliseconds_AA08SoftwareF7OutcomeOSb_s5Int32VyAA0J5FrameVYbKXEtYaKF swift.method Window.show(requestPresentationFeedback:timeoutMilliseconds:_:) @discardableResult func show(requestPresentationFeedback: Bool, timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"}} s:13WaylandClient6WindowV4show19timeoutMilliseconds_ys5Int32V_yAA13SoftwareFrameVYbKXEtYaKF swift.method Window.show(timeoutMilliseconds:_:) func show(timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws - s:13WaylandClient6WindowV4show19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareH7OutcomeOs5Int32V_SbxAA0K16FrameReservationVYaYbKYCXEyx_AA0kN0VtYbKXEtYaKs8SendableRzlF swift.method Window.show(timeoutMilliseconds:requestPresentationFeedback:preparing:_:) @discardableResult func show(timeoutMilliseconds: Int32 = WaylandDisplay.defaultConfigureTimeoutMilliseconds, requestPresentationFeedback: Bool = false, preparing prepare: nonisolated(nonsending) sending @Sendable (SoftwareFrameReservation) async throws -> Prepared, _ draw: sending @Sendable (Prepared, borrowing SoftwareFrame) throws -> Void) async throws -> SoftwarePresentationOutcome where Prepared : Sendable {"swiftExtension":{"extendedModule":"WaylandClient","typeKind":"swift.struct"},"swiftGenerics":{"constraints":[{"kind":"conformance","lhs":"Prepared","rhs":"Sendable","rhsPrecise":"s:s8SendableP"}],"parameters":[{"depth":0,"index":0,"name":"Prepared"}]}} s:13WaylandClient6WindowV9startDrag6source6seatID6serial4iconAA0E6SourceVAA0eK13ConfigurationV_AA04SeatH0VAA11InputSerialVAA0E4IconOtYaKF swift.method Window.startDrag(source:seatID:serial:icon:) func startDrag(source configuration: DragSourceConfiguration, seatID: SeatID, serial: InputSerial, icon: DragIcon = .none) async throws -> DragSource - @@ -5705,11 +5709,15 @@ s:13WaylandClient6WindowV2idAA0C2IDVvp memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4hash4intoys6HasherVz_tF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareH7OutcomeOs5Int32V_SbxAA0K16FrameReservationVYaYbKYCXEyx_AA0kN0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show19timeoutMilliseconds_ys5Int32V_yAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV4show27requestPresentationFeedback19timeoutMilliseconds_AA08SoftwareF7OutcomeOSb_s5Int32VyAA0J5FrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds27requestPresentationFeedback9preparing_AA08SoftwareI7OutcomeOAA19SurfaceDamageRegionVSg_s5Int32VSbxAA0L16FrameReservationVYaYbKYCXEyx_AA0lR0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV4show6damage19timeoutMilliseconds_yAA19SurfaceDamageRegionVSg_s5Int32VyAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV4show6damage27requestPresentationFeedback19timeoutMilliseconds_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_Sbs5Int32VyAA0K5FrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV5closeyyYaF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redraw27requestPresentationFeedback9preparing_AA08SoftwareF7OutcomeOSb_xAA0I16FrameReservationVYaYbKYCXEyx_AA0iK0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV6redraw27requestPresentationFeedback_AA08SoftwareF7OutcomeOSb_yAA0H5FrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback9preparing_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbxAA0J16FrameReservationVYaYbKYCXEyx_AA0jO0VtYbKXEtYaKs8SendableRzlF memberOf s:13WaylandClient6WindowV - +s:13WaylandClient6WindowV6redraw6damage27requestPresentationFeedback_AA08SoftwareG7OutcomeOAA19SurfaceDamageRegionVSg_SbyAA0I5FrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redraw6damage_yAA19SurfaceDamageRegionVSg_yAA13SoftwareFrameVYbKXEtYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV6redrawyyyAA13SoftwareFrameVYbKXEYaKF memberOf s:13WaylandClient6WindowV - s:13WaylandClient6WindowV7setIconyyAA0cE0OYaKF memberOf s:13WaylandClient6WindowV - diff --git a/docs/release.md b/docs/release.md index de8d5f27..35ab0a50 100644 --- a/docs/release.md +++ b/docs/release.md @@ -30,11 +30,13 @@ This checkpoint contains source-breaking `WaylandClient` changes: `dataTransfer`, and `presentation` cases. `WaylandDisplay.events` is now the complete cross-family ordered feed; specialized streams remain family-ordered convenience views. -- Update async software drawing calls for the new `SoftwarePresentationOutcome` - result. `Window.show(preparing:_:)` and `Window.redraw(preparing:_:)` now - distinguish presented, superseded, deferred, and closed attempts, revalidate - prepared generations before drawing, and accept an atomic - `requestPresentationFeedback` option. +- `Window.show(preparing:_:)` and `Window.redraw(preparing:_:)` now return + `SoftwarePresentationOutcome`, distinguishing presented, superseded, deferred, + and closed attempts. Calls that ignore the result remain valid because it is + discardable. Explicit `Void` bindings, typed function references, and wrappers + must adopt or discard the returned outcome. Matching overloads without a + preparation closure expose the same atomic `requestPresentationFeedback` + option and outcome. Prepared generations are revalidated before drawing. ## Required Gates From 81645c6d856ab522e3c56f5597e3ef5663b71d3b Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:26:56 -0400 Subject: [PATCH 07/10] Permit presentation recorder mutex tokens Declare the testing-only presentation request recorder mutex in the unsafe-token allowlist, matching the existing core and desktop recorder entries. --- safety/unsafe-token-allowlist.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/safety/unsafe-token-allowlist.tsv b/safety/unsafe-token-allowlist.tsv index 4a2930dd..679e6547 100644 --- a/safety/unsafe-token-allowlist.tsv +++ b/safety/unsafe-token-allowlist.tsv @@ -53,6 +53,7 @@ Sources/CWaylandProtocols/shims/display-core.c wl_proxy_set_queue Queue-at-creat Sources/CWaylandProtocols/shims/display-core.c wl_proxy_wrapper_destroy Queue-at-creation wrapper shim. Sources/CWaylandProtocols/shims/requests-core.c pthread_* Testing-only core request recording lock used to serialize hook globals under ThreadSanitizer. Sources/CWaylandProtocols/shims/requests-desktop.c pthread_* Testing-only desktop request recording lock used to serialize hook globals under ThreadSanitizer. +Sources/CWaylandProtocols/shims/requests-presentation.c pthread_* Testing-only presentation request recording lock used to serialize hook globals under ThreadSanitizer. Sources/CWaylandRuntimeShims/fd-shim.c pthread_* C shim blocks SIGPIPE around fd writes without changing process-global signal disposition. Sources/CWaylandProtocols/shims/requests-core.c wl_proxy_get_queue Queue assertion shim implementation. Sources/CWaylandProtocols/shims/requests-core.c swl_proxy_get_queue_raw Queue assertion shim implementation. From b03f8313352577ee8802ccc4ec533c33a10f7643 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:21:07 -0400 Subject: [PATCH 08/10] Stage software success before buffer commit --- .../Internal/Windowing/TopLevelWindow.swift | 184 ++++++++++++++--- .../WindowSoftwarePresentationTypes.swift | 82 ++++++++ .../Windowing/WindowSoftwarePresenter.swift | 192 +++++++----------- .../WindowSoftwarePresenterTests.swift | 113 ++++++++++- 4 files changed, 412 insertions(+), 159 deletions(-) create mode 100644 Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresentationTypes.swift diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index 70c17c76..32b67af4 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -35,6 +35,56 @@ struct PendingSoftwareFrameReservation { let reservedFrame: WindowReservedSoftwareFrame } +private struct StagedSoftwarePresentationSuccess { + let model: WindowModel + let publishesRedrawRequest: Bool +} + +private struct SoftwarePresentationSuccessStagingContext { + let model: WindowModel + let windowID: WindowID + let hasPendingSurfaceConfigure: Bool + + func stage( + generation: UInt64, + currentBufferAvailability: RedrawBufferAvailability + ) throws -> StagedSoftwarePresentationSuccess { + var stagedModel = model + let bufferAvailability = RedrawBufferAvailability.resolvingPendingConfigure( + hasPendingSurfaceConfigure, + currentBufferAvailability: currentBufferAvailability + ) + let effects = try stagedModel.reduce( + .presentationSucceeded( + generation: generation, + bufferAvailability: bufferAvailability + ) + ) + var publishesRedrawRequest = false + for effect in effects { + guard case .publishRedrawRequested(let effectWindowID) = effect, + effectWindowID == windowID, + !publishesRedrawRequest + else { + throw ClientError.window( + windowID, + .invalidLifecycleTransition( + .invalidTransition( + from: "software presentation success staging", + event: "unexpected effect \(effect)" + ) + ) + ) + } + publishesRedrawRequest = true + } + return StagedSoftwarePresentationSuccess( + model: stagedModel, + publishesRedrawRequest: publishesRedrawRequest + ) + } +} + // swiftlint:disable:next type_body_length package final class TopLevelWindow { package static let defaultConfigureTimeoutMS: Int32 = 1_000 @@ -582,9 +632,12 @@ package final class TopLevelWindow { model.reduce(.presentationStarted(request)) ) + let successStagingContext = softwarePresentationSuccessStagingContext() + var stagedSuccess: StagedSoftwarePresentationSuccess? + let result: WindowSoftwarePresentationResult do { let geometry = try surfaceGeometry(logicalSize: request.configuration.size) - let result = try softwarePresenter().present( + result = try softwarePresenter().present( context: WindowSoftwarePresentationContext( request: request, geometry: geometry, @@ -594,11 +647,15 @@ package final class TopLevelWindow { presentationFeedback: presentationFeedback ), draw: draw, + stageSuccess: { currentBufferAvailability in + stagedSuccess = try successStagingContext.stage( + generation: request.generation, + currentBufferAvailability: currentBufferAvailability + ) + }, runtime: &surfaceRuntime, pendingFrameRegistration: &pendingFrameRegistration ) - try interpretSoftwarePresentationFollowUp(result.followUp) - return result.outcome } catch let failure as WindowSoftwarePresentationFailure { failSoftwarePresentationIfStillActive(generation: request.generation) if case .userDraw = failure.presentationError { @@ -612,6 +669,22 @@ package final class TopLevelWindow { ) throw error } + + if result.outcome == .presented { + installSoftwarePresentationSuccess(stagedSuccess) + return .presented + } + + do { + try interpretSoftwarePresentationFollowUp(result.followUp) + return result.outcome + } catch { + failPresentationIfStillActive( + generation: request.generation, + error: .surfaceCommit(String(describing: error)) + ) + throw error + } } private func softwarePresenter() -> WindowSoftwarePresenter { @@ -655,19 +728,16 @@ package final class TopLevelWindow { return model.isClosed ? .closed : .superseded } + var stagedSuccess: StagedSoftwarePresentationSuccess? + let result: WindowSoftwarePresentationResult do { - _ = try consumeLatestConfigureIfAvailable() - let currentGeometry = try currentSurfaceGeometry() - guard !model.isClosed, - model.isCurrentSoftwarePresentation(pendingReservation.request), - currentGeometry == pendingReservation.geometry, - !Task.isCancelled - else { + guard try isCurrentSoftwarePresentation(pendingReservation) else { return try supersedeSoftwarePresentation(pendingReservation) } + let successStagingContext = softwarePresentationSuccessStagingContext() let presentationFeedback = try makePresentationFeedback() - let result = try softwarePresenter().presentReserved( + result = try softwarePresenter().presentReserved( pendingReservation.reservedFrame, context: WindowSoftwarePresentationContext( request: pendingReservation.request, @@ -678,18 +748,15 @@ package final class TopLevelWindow { presentationFeedback: presentationFeedback ), draw: draw, + stageSuccess: { currentBufferAvailability in + stagedSuccess = try successStagingContext.stage( + generation: pendingReservation.request.generation, + currentBufferAvailability: currentBufferAvailability + ) + }, runtime: &surfaceRuntime, pendingFrameRegistration: &pendingFrameRegistration ) - try interpretSoftwarePresentationFollowUp(result.followUp) - switch result.outcome { - case .presented: - return .presented - case .skippedClosed: - return .closed - case .skippedPendingFrame, .waitingForBuffer: - return .deferred - } } catch let failure as WindowSoftwarePresentationFailure { pendingReservation.reservedFrame.drawingBuffer.discard() failSoftwarePresentationIfStillActive( @@ -706,6 +773,60 @@ package final class TopLevelWindow { ) throw error } + + if result.outcome == .presented { + installSoftwarePresentationSuccess(stagedSuccess) + return .presented + } + + return try interpretDeferredSoftwarePresentation( + result, + pendingReservation: pendingReservation + ) + } + + private func isCurrentSoftwarePresentation( + _ pendingReservation: PendingSoftwareFrameReservation + ) throws -> Bool { + _ = try consumeLatestConfigureIfAvailable() + let currentGeometry = try currentSurfaceGeometry() + return !model.isClosed + && model.isCurrentSoftwarePresentation(pendingReservation.request) + && currentGeometry == pendingReservation.geometry + && !Task.isCancelled + } + + private func softwarePresentationSuccessStagingContext() + -> SoftwarePresentationSuccessStagingContext + { + SoftwarePresentationSuccessStagingContext( + model: model, + windowID: id, + hasPendingSurfaceConfigure: configureState.hasPendingSurfaceConfigure + ) + } + + private func interpretDeferredSoftwarePresentation( + _ result: WindowSoftwarePresentationResult, + pendingReservation: PendingSoftwareFrameReservation + ) throws -> SoftwarePresentationOutcome { + do { + try interpretSoftwarePresentationFollowUp(result.followUp) + switch result.outcome { + case .presented: + preconditionFailure("Presented software frames require staged success") + case .skippedClosed: + return .closed + case .skippedPendingFrame, .waitingForBuffer: + return .deferred + } + } catch { + pendingReservation.reservedFrame.drawingBuffer.discard() + failSoftwarePresentationIfStillActive( + generation: pendingReservation.request.generation + ) + throw error + } } private func supersedeSoftwarePresentation( @@ -772,15 +893,20 @@ package final class TopLevelWindow { try interpretWindowEffects(model.reduce(.presentationBlockedByBuffer)) case .resetTransientState: try interpretWindowEffects(model.reduce(.transientStateReset)) - case .succeeded(let generation): - try interpretWindowEffects( - model.reduce( - .presentationSucceeded( - generation: generation, - bufferAvailability: try redrawBufferAvailability() - ) - ) - ) + case .succeeded: + preconditionFailure("Software presentation success must be installed from staging") + } + } + + private func installSoftwarePresentationSuccess( + _ stagedSuccess: StagedSoftwarePresentationSuccess? + ) { + guard let stagedSuccess else { + preconditionFailure("Committed software presentation has no staged success") + } + model = stagedSuccess.model + if stagedSuccess.publishesRedrawRequest { + onRedrawRequested?() } } diff --git a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresentationTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresentationTypes.swift new file mode 100644 index 00000000..0fe7e221 --- /dev/null +++ b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresentationTypes.swift @@ -0,0 +1,82 @@ +import WaylandRaw + +struct WindowSoftwarePresentationResult { + let outcome: RedrawOutcome + let followUp: WindowSoftwarePresentationFollowUp? +} + +struct WindowReservedSoftwareFrame { + let reservation: SoftwareFrameReservation + let drawingBuffer: RawBuffer.ReservedDrawingBuffer +} + +struct WindowSoftwareFrameReservationResult { + let reservedFrame: WindowReservedSoftwareFrame? + let followUp: WindowSoftwarePresentationFollowUp? +} + +package enum WindowSoftwareFrameReservationOutcome: Equatable, Sendable { + case reserved(SoftwareFrameReservation) + case deferred + case closed +} + +enum WindowSoftwarePresentationFollowUp { + case fail(generation: UInt64, PresentationError) + case blockedByBuffer + case resetTransientState + case succeeded(generation: UInt64) +} + +struct WindowSoftwarePresentationFailure: Error { + let presentationError: PresentationError + let underlying: any Error +} + +package struct WindowSoftwareDrawFailure: Error { + package let underlying: any Error + + package init(underlying drawError: any Error) { + underlying = drawError + } +} + +package struct WindowPresentationFeedbackCommitRequest { + let request: () throws -> SurfacePresentationIdentity + let cancel: (SurfacePresentationIdentity) -> Void + + package init( + request feedbackRequest: @escaping () throws -> SurfacePresentationIdentity, + cancel cancelFeedback: @escaping (SurfacePresentationIdentity) -> Void + ) { + request = feedbackRequest + cancel = cancelFeedback + } +} + +package enum WindowSoftwarePresentationCommitSequence { + @discardableResult + package static func perform( + stageSuccess: () throws -> Void, + markDrawingBufferBusy: () -> Void, + requestFrameCallback: () -> Void, + requestPresentationFeedback: () -> SurfacePresentationIdentity?, + commit: () -> Void + ) rethrows -> SurfacePresentationIdentity? { + try stageSuccess() + markDrawingBufferBusy() + requestFrameCallback() + let feedbackIdentity = requestPresentationFeedback() + commit() + return feedbackIdentity + } +} + +struct WindowSoftwarePresentationContext { + let request: PresentationRequest + let geometry: SurfaceGeometry + let submitConstraints: SurfaceSubmitConstraints + let metadata: SurfaceCommitMetadata + let damage: SurfaceDamageRegion? + let presentationFeedback: WindowPresentationFeedbackCommitRequest? +} diff --git a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift index b92cd571..285c666c 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowSoftwarePresenter.swift @@ -1,87 +1,9 @@ import WaylandRaw -struct WindowSoftwarePresentationResult { - let outcome: RedrawOutcome - let followUp: WindowSoftwarePresentationFollowUp? -} - -struct WindowReservedSoftwareFrame { - let reservation: SoftwareFrameReservation - let drawingBuffer: RawBuffer.ReservedDrawingBuffer -} - -struct WindowSoftwareFrameReservationResult { - let reservedFrame: WindowReservedSoftwareFrame? - let followUp: WindowSoftwarePresentationFollowUp? -} - -package enum WindowSoftwareFrameReservationOutcome: Equatable, Sendable { - case reserved(SoftwareFrameReservation) - case deferred - case closed -} - -enum WindowSoftwarePresentationFollowUp { - case fail(generation: UInt64, PresentationError) - case blockedByBuffer - case resetTransientState - case succeeded(generation: UInt64) -} - -struct WindowSoftwarePresentationFailure: Error { - let presentationError: PresentationError - let underlying: any Error -} - -package struct WindowSoftwareDrawFailure: Error { - package let underlying: any Error - - package init(underlying drawError: any Error) { - underlying = drawError - } -} - -package struct WindowPresentationFeedbackCommitRequest { - let request: () throws -> SurfacePresentationIdentity - let cancel: (SurfacePresentationIdentity) -> Void - - package init( - request feedbackRequest: @escaping () throws -> SurfacePresentationIdentity, - cancel cancelFeedback: @escaping (SurfacePresentationIdentity) -> Void - ) { - request = feedbackRequest - cancel = cancelFeedback - } -} - -package enum WindowSoftwarePresentationCommitSequence { - @discardableResult - package static func perform( - markDrawingBufferBusy: () -> Void, - requestFrameCallback: () -> Void, - requestPresentationFeedback: () -> SurfacePresentationIdentity?, - commit: () -> Void - ) -> SurfacePresentationIdentity? { - markDrawingBufferBusy() - requestFrameCallback() - let feedbackIdentity = requestPresentationFeedback() - commit() - return feedbackIdentity - } -} - -struct WindowSoftwarePresentationContext { - let request: PresentationRequest - let geometry: SurfaceGeometry - let submitConstraints: SurfaceSubmitConstraints - let metadata: SurfaceCommitMetadata - let damage: SurfaceDamageRegion? - let presentationFeedback: WindowPresentationFeedbackCommitRequest? -} - private struct WindowSoftwareCommitContext { let preparedCommit: PreparedSurfaceFrameCommit let request: PresentationRequest + let bufferSize: TopLevelSize let presentationFeedback: WindowPresentationFeedbackCommitRequest? } @@ -96,6 +18,7 @@ struct WindowSoftwarePresenter { func present( context: WindowSoftwarePresentationContext, draw: (borrowing SoftwareFrame) throws -> Void, + stageSuccess: (RedrawBufferAvailability) throws -> Void = { _ in () }, runtime: inout SurfaceRuntime, pendingFrameRegistration: inout FrameCallbackRegistration? ) throws -> WindowSoftwarePresentationResult { @@ -138,8 +61,10 @@ struct WindowSoftwarePresenter { context: WindowSoftwareCommitContext( preparedCommit: preparedCommit, request: context.request, + bufferSize: context.geometry.bufferSize.rawSize, presentationFeedback: context.presentationFeedback ), + stageSuccess: stageSuccess, runtime: &runtime, pendingFrameRegistration: &pendingFrameRegistration, drawingBuffer: &drawingBuffer @@ -202,6 +127,7 @@ struct WindowSoftwarePresenter { _ reservedFrame: WindowReservedSoftwareFrame, context: WindowSoftwarePresentationContext, draw: (borrowing SoftwareFrame) throws -> Void, + stageSuccess: (RedrawBufferAvailability) throws -> Void = { _ in () }, runtime: inout SurfaceRuntime, pendingFrameRegistration: inout FrameCallbackRegistration? ) throws -> WindowSoftwarePresentationResult { @@ -236,8 +162,10 @@ struct WindowSoftwarePresenter { context: WindowSoftwareCommitContext( preparedCommit: preparedCommit, request: context.request, + bufferSize: context.geometry.bufferSize.rawSize, presentationFeedback: context.presentationFeedback ), + stageSuccess: stageSuccess, runtime: &runtime, pendingFrameRegistration: &pendingFrameRegistration, drawingBuffer: reservedFrame.drawingBuffer @@ -251,6 +179,7 @@ struct WindowSoftwarePresenter { private func performPreparedCommit( context: WindowSoftwareCommitContext, + stageSuccess: (RedrawBufferAvailability) throws -> Void, runtime: inout SurfaceRuntime, pendingFrameRegistration: inout FrameCallbackRegistration?, drawingBuffer: inout RawBuffer.DrawingBuffer @@ -263,27 +192,39 @@ struct WindowSoftwarePresenter { throw error } - _ = WindowSoftwarePresentationCommitSequence.perform( - markDrawingBufferBusy: { - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - }, - requestFrameCallback: { - requestReservedFrameCallback( - pendingFrameRegistration: &pendingFrameRegistration - ) - }, - requestPresentationFeedback: { - requestPresentationFeedbackAtPointOfNoReturn( - context.presentationFeedback - ) - }, - commit: { - SurfaceFrameCommitter.commit( - stagedCommit, - runtime: &runtime, - ) - } + let currentBufferAvailability = runtime.redrawBufferAvailability( + matching: context.bufferSize ) + do { + _ = try WindowSoftwarePresentationCommitSequence.perform( + stageSuccess: { + try stageSuccess(currentBufferAvailability) + }, + markDrawingBufferBusy: { + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + }, + requestFrameCallback: { + requestReservedFrameCallback( + pendingFrameRegistration: &pendingFrameRegistration + ) + }, + requestPresentationFeedback: { + requestPresentationFeedbackAtPointOfNoReturn( + context.presentationFeedback + ) + }, + commit: { + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &runtime, + ) + } + ) + } catch { + runtime.cancelFrameCallback() + drawingBuffer.discard() + throw error + } } private func drawFrame( @@ -459,6 +400,7 @@ struct WindowSoftwarePresenter { private func performPreparedReservedCommit( context: WindowSoftwareCommitContext, + stageSuccess: (RedrawBufferAvailability) throws -> Void, runtime: inout SurfaceRuntime, pendingFrameRegistration: inout FrameCallbackRegistration?, drawingBuffer: RawBuffer.ReservedDrawingBuffer @@ -471,26 +413,38 @@ struct WindowSoftwarePresenter { throw error } - _ = WindowSoftwarePresentationCommitSequence.perform( - markDrawingBufferBusy: { - _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) - }, - requestFrameCallback: { - requestReservedFrameCallback( - pendingFrameRegistration: &pendingFrameRegistration - ) - }, - requestPresentationFeedback: { - requestPresentationFeedbackAtPointOfNoReturn( - context.presentationFeedback - ) - }, - commit: { - SurfaceFrameCommitter.commit( - stagedCommit, - runtime: &runtime, - ) - } + let currentBufferAvailability = runtime.redrawBufferAvailability( + matching: context.bufferSize ) + do { + _ = try WindowSoftwarePresentationCommitSequence.perform( + stageSuccess: { + try stageSuccess(currentBufferAvailability) + }, + markDrawingBufferBusy: { + _ = drawingBuffer.markBusy(commitGeneration: context.request.generation) + }, + requestFrameCallback: { + requestReservedFrameCallback( + pendingFrameRegistration: &pendingFrameRegistration + ) + }, + requestPresentationFeedback: { + requestPresentationFeedbackAtPointOfNoReturn( + context.presentationFeedback + ) + }, + commit: { + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &runtime, + ) + } + ) + } catch { + runtime.cancelFrameCallback() + drawingBuffer.discard() + throw error + } } } diff --git a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift index 4e066791..b93516fb 100644 --- a/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift +++ b/Tests/WaylandClientTests/Rendering/WindowSoftwarePresenterTests.swift @@ -15,20 +15,29 @@ var events: [CommitSequenceEvent] = [] let identity = SurfacePresentationIdentity(rawValue: 7) - let returnedIdentity = WindowSoftwarePresentationCommitSequence.perform { - events.append(.markDrawingBufferBusy) - } requestFrameCallback: { - events.append(.frameCallback) - } requestPresentationFeedback: { - events.append(.presentationFeedback(operation)) - return identity - } commit: { - events.append(.commit(operation)) - } + let returnedIdentity = WindowSoftwarePresentationCommitSequence.perform( + stageSuccess: { + events.append(.stageSuccess) + }, + markDrawingBufferBusy: { + events.append(.markDrawingBufferBusy) + }, + requestFrameCallback: { + events.append(.frameCallback) + }, + requestPresentationFeedback: { + events.append(.presentationFeedback(operation)) + return identity + }, + commit: { + events.append(.commit(operation)) + } + ) #expect(returnedIdentity == identity) #expect( events == [ + .stageSuccess, .markDrawingBufferBusy, .frameCallback, .presentationFeedback(operation), @@ -36,10 +45,44 @@ ] ) } + + @Test + func successStagingFailureStopsBeforePointOfNoReturn() { + var events: [CommitSequenceEvent] = [] + + do { + _ = try WindowSoftwarePresentationCommitSequence.perform( + stageSuccess: { + events.append(.stageSuccess) + throw InjectedSuccessStagingFailure() + }, + markDrawingBufferBusy: { + events.append(.markDrawingBufferBusy) + }, + requestFrameCallback: { + events.append(.frameCallback) + }, + requestPresentationFeedback: { + events.append(.presentationFeedback(.redraw)) + return nil + }, + commit: { + events.append(.commit(.redraw)) + } + ) + Issue.record("expected success staging failure") + } catch is InjectedSuccessStagingFailure { + // The injected failure must remain observable before the point of no return. + } catch { + Issue.record("unexpected error: \(error)") + } + + #expect(events == [.stageSuccess]) + } } @Suite(.serialized) - struct WindowSoftwarePresenterTests { + struct WindowSoftwarePresenterTests { // swiftlint:disable:this type_body_length private struct RoleToken {} private struct FrameIDCaptureComplete: Error {} @@ -97,6 +140,52 @@ } } + @Test + func successStagingFailureLeavesDrawingBufferReusable() async throws { + try await withSoftwarePresentationRecording { + try exerciseSuccessStagingFailureLeavesDrawingBufferReusable() + } + } + + private func exerciseSuccessStagingFailureLeavesDrawingBufferReusable() throws { + let surface = try testSurface(pointer: 0x6A41) + let sharedMemory = try testSharedMemory(pointer: 0x6A42) + let pool = try sharedMemory.createPool(width: 64, height: 48, bufferCount: 1) + var runtime = SurfaceRuntime(role: .toplevelWindow) + runtime.recordConfigureReceived(serial: 1) + try runtime.acknowledgeConfigure(serial: 1) + var pendingFrameRegistration: FrameCallbackRegistration? + let presenter = softwarePresenter(surface: surface, pool: pool) + + do { + _ = try presenter.present( + context: try softwarePresentationContext(), + draw: { _ in () }, + stageSuccess: { currentBufferAvailability in + #expect(currentBufferAvailability == .unavailable) + throw InjectedSuccessStagingFailure() + }, + runtime: &runtime, + pendingFrameRegistration: &pendingFrameRegistration + ) + Issue.record("expected success staging failure") + } catch is InjectedSuccessStagingFailure { + // Success bookkeeping remains fallible only before the point of no return. + } catch { + Issue.record("unexpected error: \(error)") + } + + let hasPendingRegistration = hasPendingFrameRegistration(pendingFrameRegistration) + #expect(!hasPendingRegistration) + #expect(!pool.hasBusyBuffers) + #expect(pool.hasFreeBuffers) + #expect(unsafe swl_test_core_request_record().frame_sequence == 0) + #expect(unsafe swl_test_core_request_record().attach_sequence == 0) + #expect(unsafe swl_test_core_request_record().damage_sequence == 0) + #expect(unsafe swl_test_presentation_request_record().call_count == 0) + #expect(unsafe swl_test_core_request_record().commit_sequence == 0) + } + private func exerciseSubmitConstraintFailureLeavesDrawingBufferReusable() throws { let surface = try testSurface(pointer: 0x6A31) let sharedMemory = try testSharedMemory(pointer: 0x6A32) @@ -321,6 +410,7 @@ } private enum CommitSequenceEvent: Equatable { + case stageSuccess case markDrawingBufferBusy case frameCallback case presentationFeedback(ManagedPresentationOperation) @@ -328,4 +418,5 @@ } private struct InjectedDrawFailure: Error {} + private struct InjectedSuccessStagingFailure: Error {} #endif From 88c5f267591871441881e4a56d00ef03919ff33c Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:21:21 -0400 Subject: [PATCH 09/10] Verify forwarded frame callback recording --- .../WindowSoftwarePresentationPublicRequestTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift index 45d30f12..3c302614 100644 --- a/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift +++ b/Tests/WaylandClientTests/Windowing/WindowSoftwarePresentationPublicRequestTests.swift @@ -42,6 +42,12 @@ } #expect(outcome == .presented) + let coreRecord = unsafe swl_test_core_request_record() + #expect(unsafe coreRecord.frame_sequence > 0) + #expect(unsafe coreRecord.frame_sequence < coreRecord.attach_sequence) + #expect(unsafe coreRecord.attach_sequence < coreRecord.damage_sequence) + #expect(unsafe coreRecord.damage_sequence < coreRecord.commit_sequence) + let recordedCallCount = unsafe swl_test_presentation_request_record().call_count try await window.requestPresentationFeedback() #expect( From 6c5b09a5110c5b727af8c3ca9fc1f5667d0c51f5 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:21:21 -0400 Subject: [PATCH 10/10] Stage popup and external presentations before commit --- .../Popup/PopupRoleSurface+Presentation.swift | 85 ++++++++--- .../Internal/Popup/PopupRoleSurface.swift | 16 ++- .../Internal/Windowing/TopLevelWindow.swift | 101 ++++++++++--- .../WindowExternalBufferPresenter.swift | 103 +++++++------ .../Internal/Windowing/WindowModelTypes.swift | 25 ++++ .../Popup/PopupModelPresentationTests.swift | 55 +++++++ .../Rendering/SurfaceRuntimeSubmitTests.swift | 135 ++++++++---------- 7 files changed, 357 insertions(+), 163 deletions(-) diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift index 4975cdca..5d5b4976 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface+Presentation.swift @@ -1,3 +1,52 @@ +struct StagedPopupPresentationSuccess { + let model: PopupModel + let publishesRedrawRequest: Bool +} + +struct PopupPresentationSuccessStagingContext { + let model: PopupModel + let parentWindowID: WindowID + + func stage( + generation: UInt64, + bufferAvailability: RedrawBufferAvailability + ) throws -> StagedPopupPresentationSuccess { + var stagedModel = model + let effects = try stagedModel.reduce( + .presentationSucceeded( + generation: generation, + bufferAvailability: bufferAvailability + ) + ) + let expectedEvent = PopupLifecycleEvent( + popup: stagedModel.id, + parentWindowID: parentWindowID + ) + var publishesRedrawRequest = false + for effect in effects { + guard case .publishRedrawRequested(let event) = effect, + event == expectedEvent, + !publishesRedrawRequest + else { + throw ClientError.window( + parentWindowID, + .invalidLifecycleTransition( + .invalidTransition( + from: "popup presentation success staging", + event: "unexpected effect \(effect)" + ) + ) + ) + } + publishesRedrawRequest = true + } + return StagedPopupPresentationSuccess( + model: stagedModel, + publishesRedrawRequest: publishesRedrawRequest + ) + } +} + extension PopupRoleSurface { // swiftlint:disable:next function_body_length func performSoftwarePresent( @@ -70,11 +119,7 @@ extension PopupRoleSurface { } do { - pendingFrameRegistration = try requestSurfaceFrameCallback( - generation: request.generation - ) { [weak self] in - self?.handleFrameDone() - } + try reserveSurfaceFrameCallback(generation: request.generation) } catch { failActivePresentation( generation: request.generation, @@ -84,10 +129,17 @@ extension PopupRoleSurface { throw error } + let stagedCommit: StagedSurfaceFrameCommit + let stagedSuccess: StagedPopupPresentationSuccess do { - let stagedCommit = try stageSurfaceFrameCommit(preparedCommit) - _ = drawingBuffer.markBusy(commitGeneration: request.generation) - commitSurfaceFrame(stagedCommit) + stagedCommit = try stageSurfaceFrameCommit(preparedCommit) + stagedSuccess = try PopupPresentationSuccessStagingContext( + model: model, + parentWindowID: parentWindowID + ).stage( + generation: request.generation, + bufferAvailability: try redrawBufferAvailability() + ) } catch { pendingFrameRegistration = nil cancelSurfaceFrameCallback() @@ -95,14 +147,15 @@ extension PopupRoleSurface { throw error } - try interpretPopupEffects( - model.reduce( - .presentationSucceeded( - generation: request.generation, - bufferAvailability: try redrawBufferAvailability() - ) - ) - ) + _ = drawingBuffer.markBusy(commitGeneration: request.generation) + pendingFrameRegistration = requestReservedSurfaceFrameCallback { [weak self] in + self?.handleFrameDone() + } + commitSurfaceFrame(stagedCommit) + model = stagedSuccess.model + if stagedSuccess.publishesRedrawRequest { + onRedrawRequested?() + } return .presented } catch { failPresentationIfStillActive( diff --git a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift index 57227518..d15cc58a 100644 --- a/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift +++ b/Sources/WaylandClient/Internal/Popup/PopupRoleSurface.swift @@ -339,14 +339,18 @@ extension PopupRoleSurface { try surfaceRuntime.acknowledgeConfigure(serial: serial) } - package func requestSurfaceFrameCallback( - generation: UInt64, + package func reserveSurfaceFrameCallback(generation: UInt64) throws { + try SurfaceFrameCommitter.reserveFrameCallback( + runtime: &surfaceRuntime, + generation: generation + ) + } + + package func requestReservedSurfaceFrameCallback( onFrame: @escaping () -> Void - ) throws -> FrameCallbackRegistration { - try SurfaceFrameCommitter.requestFrameCallback( + ) -> FrameCallbackRegistration { + SurfaceFrameCommitter.requestReservedFrameCallback( on: surface, - runtime: &surfaceRuntime, - generation: generation, onFrame: onFrame ) } diff --git a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift index 32b67af4..fc84ff9b 100644 --- a/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift +++ b/Sources/WaylandClient/Internal/Windowing/TopLevelWindow.swift @@ -85,6 +85,59 @@ private struct SoftwarePresentationSuccessStagingContext { } } +private struct StagedExternalBufferPresentationSuccess { + let model: WindowModel + let result: PreviewBufferPresentationResult + let publishesRedrawRequest: Bool +} + +private struct ExternalSuccessStagingContext { + let model: WindowModel + let windowID: WindowID + let bufferAvailability: RedrawBufferAvailability + let capabilities: SurfaceCapabilitySnapshot + + func stage( + generation: UInt64, + commitPlan: SurfaceCommitPlan + ) throws -> StagedExternalBufferPresentationSuccess { + var stagedModel = model + let effects = try stagedModel.reduce( + .externalPresentationSucceeded( + generation: generation, + bufferAvailability: bufferAvailability + ) + ) + var publishesRedrawRequest = false + for effect in effects { + guard case .publishRedrawRequested(let effectWindowID) = effect, + effectWindowID == windowID, + !publishesRedrawRequest + else { + throw ClientError.window( + windowID, + .invalidLifecycleTransition( + .invalidTransition( + from: "external presentation success staging", + event: "unexpected effect \(effect)" + ) + ) + ) + } + publishesRedrawRequest = true + } + return try StagedExternalBufferPresentationSuccess( + model: stagedModel, + result: PreviewBufferPresentationResult( + generation: generation, + commitPlan: commitPlan, + capabilities: capabilities + ), + publishesRedrawRequest: publishesRedrawRequest + ) + } +} + // swiftlint:disable:next type_body_length package final class TopLevelWindow { package static let defaultConfigureTimeoutMS: Int32 = 1_000 @@ -1600,6 +1653,13 @@ extension TopLevelWindow { try ensureSubmitConstraintObjectsInstalled(for: submitConstraints) try ensureMetadataObjectsInstalled(for: metadata) try surfaceRuntime.preflightCommitMetadata(metadata) + let successStagingContext = ExternalSuccessStagingContext( + model: model, + windowID: id, + bufferAvailability: bufferAvailability, + capabilities: surfaceRuntime.capabilitySnapshot() + ) + var stagedSuccess: StagedExternalBufferPresentationSuccess? let presentationRequest = WindowExternalBufferPresentationRequest( buffer: buffer, surface: surface, @@ -1614,32 +1674,37 @@ extension TopLevelWindow { } let presentation = try WindowExternalBufferPresenter.present( presentationRequest, + stageSuccess: { commitPlan in + stagedSuccess = try successStagingContext.stage( + generation: generation, + commitPlan: commitPlan + ) + }, runtime: &surfaceRuntime, pendingFrameRegistration: &pendingFrameRegistration ) - do { - try interpretWindowEffects( - model.reduce( - .externalPresentationSucceeded( - generation: generation, - bufferAvailability: bufferAvailability - ) - ) - ) - } catch { - pendingFrameRegistration = nil - surfaceRuntime.cancelFrameCallback() - throw error + guard let stagedSuccess else { + preconditionFailure("Committed external presentation is missing staged success state") } - return try PreviewBufferPresentationResult( - generation: generation, - commitPlan: presentation.commitPlan, - capabilities: surfaceRuntime.capabilitySnapshot(), - presentationFeedbackIdentity: presentation.presentationFeedbackIdentity + return installExternalPresentationSuccess( + stagedSuccess, + feedbackIdentity: presentation.presentationFeedbackIdentity ) } + private func installExternalPresentationSuccess( + _ stagedSuccess: StagedExternalBufferPresentationSuccess, + feedbackIdentity: SurfacePresentationIdentity? + ) -> PreviewBufferPresentationResult { + let result = stagedSuccess.result.withPresentationFeedbackIdentity(feedbackIdentity) + model = stagedSuccess.model + if stagedSuccess.publishesRedrawRequest { + onRedrawRequested?() + } + return result + } + package func importPreviewSynchronizationTimelineOnOwnerThread( _ fileDescriptor: inout RawDrmSyncobjTimelineFD, identity: SurfaceSyncTimelineIdentity diff --git a/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift b/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift index f2705b2c..40c4494e 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowExternalBufferPresenter.swift @@ -15,6 +15,7 @@ struct WindowExternalBufferPresentationRequest { enum WindowExternalBufferPresenter { static func present( _ request: WindowExternalBufferPresentationRequest, + stageSuccess: (SurfaceCommitPlan) throws -> Void, runtime: inout SurfaceRuntime, pendingFrameRegistration: inout FrameCallbackRegistration? ) throws -> ( @@ -33,63 +34,71 @@ enum WindowExternalBufferPresenter { ), runtime: &runtime, ) - - return try performCommitSequence( - requestFrameCallback: { - pendingFrameRegistration = try SurfaceFrameCommitter.requestFrameCallback( - on: request.surface, - runtime: &runtime, - generation: request.generation, - onFrame: request.onFrameDone - ) - }, - requestPresentationFeedback: { - try request.presentationFeedback?.request() - }, - commit: { - try SurfaceFrameCommitter.commit( - preparedCommit, - runtime: &runtime - ) - }, - cancelFrameCallback: { - pendingFrameRegistration = nil - runtime.cancelFrameCallback() - }, - cancelPresentationFeedback: { feedbackIdentity in - request.presentationFeedback?.cancel(feedbackIdentity) - } + try SurfaceFrameCommitter.reserveFrameCallback( + runtime: &runtime, + generation: request.generation ) + + do { + return try performCommitSequence( + { + let stagedCommit = try SurfaceFrameCommitter.stage( + preparedCommit, + runtime: &runtime + ) + try stageSuccess(stagedCommit.preparedCommit.plan) + return stagedCommit + }, + requestFrameCallback: { + pendingFrameRegistration = + SurfaceFrameCommitter.requestReservedFrameCallback( + on: request.surface, + onFrame: request.onFrameDone + ) + }, + requestPresentationFeedback: { + requestPresentationFeedbackAtPointOfNoReturn(request.presentationFeedback) + }, + commit: { stagedCommit in + SurfaceFrameCommitter.commit( + stagedCommit, + runtime: &runtime + ) + } + ) + } catch { + pendingFrameRegistration = nil + runtime.cancelFrameCallback() + throw error + } } - static func performCommitSequence( - requestFrameCallback: () throws -> Void, - requestPresentationFeedback: () throws -> SurfacePresentationIdentity?, - commit: () throws -> SurfaceCommitPlan, - cancelFrameCallback: () -> Void, - cancelPresentationFeedback: (SurfacePresentationIdentity) -> Void - ) throws -> ( + static func performCommitSequence( + _ stageSuccess: () throws -> StagedCommit, + requestFrameCallback: () -> Void, + requestPresentationFeedback: () -> SurfacePresentationIdentity?, + commit: (StagedCommit) -> SurfaceCommitPlan + ) rethrows -> ( commitPlan: SurfaceCommitPlan, presentationFeedbackIdentity: SurfacePresentationIdentity? ) { - try requestFrameCallback() + let stagedCommit = try stageSuccess() + requestFrameCallback() + let feedbackIdentity = requestPresentationFeedback() + return (commit(stagedCommit), feedbackIdentity) + } - let feedbackIdentity: SurfacePresentationIdentity? - do { - feedbackIdentity = try requestPresentationFeedback() - } catch { - cancelFrameCallback() - throw error - } + private static func requestPresentationFeedbackAtPointOfNoReturn( + _ presentationFeedback: WindowPresentationFeedbackCommitRequest? + ) -> SurfacePresentationIdentity? { + guard let presentationFeedback else { return nil } do { - return (try commit(), feedbackIdentity) + return try presentationFeedback.request() } catch { - cancelFrameCallback() - if let feedbackIdentity { - cancelPresentationFeedback(feedbackIdentity) - } - throw error + preconditionFailure( + "Prepared presentation feedback request failed: \(error)" + ) } } } diff --git a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift index df95fad0..843caa06 100644 --- a/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift +++ b/Sources/WaylandClient/Internal/Windowing/WindowModelTypes.swift @@ -94,11 +94,36 @@ package struct PreviewBufferPresentationResult: Equatable, Sendable { throw PreviewBufferPresentationResultError.invalidGeneration(commitGeneration) } + self.init( + validatedGeneration: commitGeneration, + commitPlan: surfaceCommitPlan, + capabilities: surfaceCapabilities, + presentationFeedbackIdentity: feedbackIdentity + ) + } + + private init( + validatedGeneration commitGeneration: UInt64, + commitPlan surfaceCommitPlan: SurfaceCommitPlan, + capabilities surfaceCapabilities: SurfaceCapabilitySnapshot, + presentationFeedbackIdentity feedbackIdentity: SurfacePresentationIdentity? + ) { generation = commitGeneration commitPlan = surfaceCommitPlan capabilities = surfaceCapabilities presentationFeedbackIdentity = feedbackIdentity } + + package func withPresentationFeedbackIdentity( + _ feedbackIdentity: SurfacePresentationIdentity? + ) -> Self { + Self( + validatedGeneration: generation, + commitPlan: commitPlan, + capabilities: capabilities, + presentationFeedbackIdentity: feedbackIdentity + ) + } } package enum PreviewBufferPresentationResultError: Error, Equatable, Sendable { diff --git a/Tests/WaylandClientTests/Popup/PopupModelPresentationTests.swift b/Tests/WaylandClientTests/Popup/PopupModelPresentationTests.swift index 177dbcb3..50231ff4 100644 --- a/Tests/WaylandClientTests/Popup/PopupModelPresentationTests.swift +++ b/Tests/WaylandClientTests/Popup/PopupModelPresentationTests.swift @@ -101,6 +101,50 @@ struct PopupModelPresentationTests { #expect(model.presentation == .idle) } + @Test + func presentationSuccessStagesOnCopiedModel() throws { + let (model, request) = try activeModelWithStartedPresentation() + + let stagedSuccess = try PopupPresentationSuccessStagingContext( + model: model, + parentWindowID: parentWindowID + ).stage( + generation: request.generation, + bufferAvailability: .available + ) + + #expect(model.presentation == .drawing(request: request)) + #expect(stagedSuccess.model.presentation == .idle) + #expect(!stagedSuccess.publishesRedrawRequest) + } + + @Test + func presentationSuccessStagingFailureLeavesSourceModelDrawing() throws { + let (model, request) = try activeModelWithStartedPresentation() + + #expect( + throws: ClientError.window( + parentWindowID, + .invalidLifecycleTransition( + .presentationGenerationMismatch( + expected: request.generation, + actual: request.generation + 1 + ) + ) + ) + ) { + _ = try PopupPresentationSuccessStagingContext( + model: model, + parentWindowID: parentWindowID + ).stage( + generation: request.generation + 1, + bufferAvailability: .available + ) + } + + #expect(model.presentation == .drawing(request: request)) + } + private func popupModel() -> PopupModel { PopupModel( id: popupID, @@ -124,6 +168,17 @@ struct PopupModelPresentationTests { return model } + private func activeModelWithStartedPresentation() throws -> ( + model: PopupModel, + request: PopupPresentationRequest + ) { + var model = try activeModel() + let effects = try model.reduce(.redrawRequestConsumed(bufferAvailability: .available)) + let request = try #require(presentationRequest(from: effects)) + _ = try model.reduce(.presentationStarted(request)) + return (model, request) + } + private func configure(serial: UInt32) -> PopupConfigureSequence { PopupConfigureSequence( serial: serial, diff --git a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift index 2de50943..04dcbf50 100644 --- a/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift +++ b/Tests/WaylandClientTests/Rendering/SurfaceRuntimeSubmitTests.swift @@ -148,31 +148,31 @@ } @Test - func externalBufferCommitSequenceRequestsPresentationFeedbackBeforeCommit() + func externalBufferCommitSequenceStagesSuccessBeforeProtocolRequests() throws { var events: [ExternalBufferPresentationEvent] = [] let identity = SurfacePresentationIdentity(rawValue: 31) let expectedPlan = try testSurfaceCommitPlan() - let presentation = try WindowExternalBufferPresenter.performCommitSequence { + let presentation = WindowExternalBufferPresenter.performCommitSequence { + events.append(.stageSuccess) + return expectedPlan + } requestFrameCallback: { events.append(.frameCallback) } requestPresentationFeedback: { events.append(.presentationFeedback) return identity - } commit: { + } commit: { stagedPlan in events.append(.commit) - return expectedPlan - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cancelPresentationFeedback: { cancelledIdentity in - events.append(.cancelPresentationFeedback(cancelledIdentity)) + return stagedPlan } #expect(presentation.commitPlan == expectedPlan) #expect(presentation.presentationFeedbackIdentity == identity) #expect( events == [ + .stageSuccess, .frameCallback, .presentationFeedback, .commit, @@ -181,88 +181,74 @@ } @Test - func externalBufferFeedbackFailureDoesNotCommit() throws { + func externalBufferSuccessStagingFailureEmitsNoProtocolOperations() throws { var events: [ExternalBufferPresentationEvent] = [] + let expectedPlan = try testSurfaceCommitPlan() - #expect(throws: InjectedExternalFeedbackFailure.self) { + #expect(throws: InjectedExternalStagingFailure.self) { _ = try WindowExternalBufferPresenter.performCommitSequence { + events.append(.stageSuccess) + throw InjectedExternalStagingFailure() + } requestFrameCallback: { events.append(.frameCallback) } requestPresentationFeedback: { events.append(.presentationFeedback) - throw InjectedExternalFeedbackFailure() - } commit: { + return SurfacePresentationIdentity(rawValue: 32) + } commit: { _ in events.append(.commit) - return try testSurfaceCommitPlan() - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cancelPresentationFeedback: { cancelledIdentity in - events.append(.cancelPresentationFeedback(cancelledIdentity)) + return expectedPlan } } - #expect( - events == [ - .frameCallback, - .presentationFeedback, - .cancelFrameCallback, - ] - ) + #expect(events == [.stageSuccess]) } @Test - func externalBufferFrameCallbackFailureSkipsLaterOperations() { - var events: [ExternalBufferPresentationEvent] = [] + func externalBufferSuccessStagingFailureRecordsNoRequests() async throws { + try await CoreRequestRecordingGate.withExclusiveRecording { + swl_test_core_request_recording_begin() + defer { swl_test_core_request_recording_end() } - #expect(throws: InjectedExternalFrameFailure.self) { - _ = try WindowExternalBufferPresenter.performCommitSequence { - events.append(.frameCallback) - throw InjectedExternalFrameFailure() - } requestPresentationFeedback: { - events.append(.presentationFeedback) - return SurfacePresentationIdentity(rawValue: 32) - } commit: { - events.append(.commit) - return try testSurfaceCommitPlan() - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cancelPresentationFeedback: { cancelledIdentity in - events.append(.cancelPresentationFeedback(cancelledIdentity)) + let surface = try testSurface(pointer: 0x5651) + defer { surface.destroy() } + var runtime = try configuredRuntimeWithoutPendingFrame() + var pendingFrameRegistration: FrameCallbackRegistration? + let request = WindowExternalBufferPresentationRequest( + buffer: try testSurfaceBuffer(pointer: 0x5652), + surface: surface, + scaleInstallation: SurfaceScaleInstallation(), + generation: 1, + geometry: try testSurfaceGeometry(), + submitConstraints: .default, + metadata: .default, + presentationFeedback: nil + ) { + _ = () } - } - #expect(events == [.frameCallback]) - } - - @Test - func externalBufferCommitFailureCancelsFrameAndFeedback() { - var events: [ExternalBufferPresentationEvent] = [] - let identity = SurfacePresentationIdentity(rawValue: 33) + #expect(throws: InjectedExternalStagingFailure.self) { + _ = try WindowExternalBufferPresenter.present( + request, + stageSuccess: { _ in + throw InjectedExternalStagingFailure() + }, + runtime: &runtime, + pendingFrameRegistration: &pendingFrameRegistration + ) + } - #expect(throws: InjectedExternalCommitFailure.self) { - _ = try WindowExternalBufferPresenter.performCommitSequence { - events.append(.frameCallback) - } requestPresentationFeedback: { - events.append(.presentationFeedback) - return identity - } commit: { - events.append(.commit) - throw InjectedExternalCommitFailure() - } cancelFrameCallback: { - events.append(.cancelFrameCallback) - } cancelPresentationFeedback: { cancelledIdentity in - events.append(.cancelPresentationFeedback(cancelledIdentity)) + #expect(runtime.transactionSnapshot.pendingFrameCallbackGeneration == nil) + #expect(runtime.transactionSnapshot.lastCommittedFrame == nil) + #expect(unsafe swl_test_core_request_record().call_count == 0) + let hasPendingFrameRegistration: Bool + switch consume pendingFrameRegistration { + case .none: + hasPendingFrameRegistration = false + case .some: + hasPendingFrameRegistration = true } + #expect(!hasPendingFrameRegistration) } - - #expect( - events == [ - .frameCallback, - .presentationFeedback, - .commit, - .cancelFrameCallback, - .cancelPresentationFeedback(identity), - ] - ) } @Test @@ -695,16 +681,13 @@ } private enum ExternalBufferPresentationEvent: Equatable { + case stageSuccess case frameCallback case presentationFeedback case commit - case cancelFrameCallback - case cancelPresentationFeedback(SurfacePresentationIdentity) } - private struct InjectedExternalFeedbackFailure: Error {} - private struct InjectedExternalFrameFailure: Error {} - private struct InjectedExternalCommitFailure: Error {} + private struct InjectedExternalStagingFailure: Error {} // swiftlint:enable closure_body_length #endif