From dad1e7069a7017d6e4a15874713e4e47d069e7a0 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:39:50 -0400 Subject: [PATCH 1/5] test: define move-only graphics lease acceptance --- .../Package.resolved | 15 +++++ .../InvalidGraphicsLeaseClient/Package.swift | 31 ++++++++++ .../Sources/FrameLeaseCopyClient/main.swift | 7 +++ .../Sources/RenderLeaseCopyClient/main.swift | 7 +++ ...raphicsExternalBufferSubmissionTests.swift | 56 +++++++++++++++++++ ...aylandGraphicsSubmissionFailureTests.swift | 33 +++++++++++ 6 files changed, 149 insertions(+) create mode 100644 IntegrationTests/InvalidGraphicsLeaseClient/Package.resolved create mode 100644 IntegrationTests/InvalidGraphicsLeaseClient/Package.swift create mode 100644 IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift create mode 100644 IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Package.resolved b/IntegrationTests/InvalidGraphicsLeaseClient/Package.resolved new file mode 100644 index 00000000..b7a05e5b --- /dev/null +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "82fe6a734b1321b76695302df8919def673929b1e6292c7f0de3feed9cfcfa1f", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + } + ], + "version" : 3 +} diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift new file mode 100644 index 00000000..d871c1e4 --- /dev/null +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.3.2 +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), +] + +let package = Package( + name: "InvalidGraphicsLeaseClient", + dependencies: [ + .package(name: "WaylandClientKit", path: "../..") + ], + targets: [ + .executableTarget( + name: "FrameLeaseCopyClient", + dependencies: [ + .product(name: "WaylandGraphicsPreview", package: "WaylandClientKit") + ], + swiftSettings: swiftSettings + ), + .executableTarget( + name: "RenderLeaseCopyClient", + dependencies: [ + .product(name: "WaylandGraphicsPreview", package: "WaylandClientKit") + ], + swiftSettings: swiftSettings + ), + ] +) diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift new file mode 100644 index 00000000..77558f76 --- /dev/null +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift @@ -0,0 +1,7 @@ +import WaylandGraphicsPreview + +func useFrameLeaseTwice(_ lease: WaylandGraphicsFrameLease) async { + let duplicate = lease + await lease.cancel() + await duplicate.cancel() +} diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift new file mode 100644 index 00000000..3e77f822 --- /dev/null +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift @@ -0,0 +1,7 @@ +import WaylandGraphicsPreview + +func useRenderLeaseTwice(_ lease: WaylandGraphicsExternalBufferRenderLease) async { + let duplicate = lease + await lease.cancel() + await duplicate.cancel() +} diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift index 62c0b4cc..53467ab8 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -1589,6 +1589,62 @@ struct WaylandGraphicsExternalBufferLifecycleTests { await storage.closeForTesting() } + @Test + func renderLeaseCancelReleasesFrameAndBufferReservation() async throws { + let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) + let storage = externalBufferStorage(window: window) + let firstLease = try await storage.nextFrame() + let buffer = try await registerTestExternalBuffer( + storage: storage, + lease: firstLease, + descriptor: try testExternalDescriptor() + ) + + let renderLease = try await firstLease.reserveExternalBuffer(buffer) + await renderLease.cancel() + + let secondLease = try await storage.nextFrame() + let secondRenderLease = try await secondLease.reserveExternalBuffer(buffer) + await secondRenderLease.cancel() + + await storage.closeForTesting() + } + + @Test(.timeLimit(.minutes(1))) + func abandoningRenderLeaseReleasesFrameAndBufferReservation() async throws { + let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) + let storage = externalBufferStorage(window: window) + let firstLease = try await storage.nextFrame() + let buffer = try await registerTestExternalBuffer( + storage: storage, + lease: firstLease, + descriptor: try testExternalDescriptor() + ) + + do { + let abandonedRenderLease = try await firstLease.reserveExternalBuffer(buffer) + _ = abandonedRenderLease.buffer + _ = abandonedRenderLease.contract + } + + for _ in 0..<100 { + do { + let replacementLease = try await storage.nextFrame() + let replacementRenderLease = try await replacementLease.reserveExternalBuffer( + buffer + ) + await replacementRenderLease.cancel() + await storage.closeForTesting() + return + } catch WaylandGraphicsError.frameLeaseActive { + await Task.yield() + } + } + + await storage.closeForTesting() + Issue.record("abandoned render lease did not release its frame and buffer") + } + @Test func frameLeaseCannotReserveMultipleExternalBuffers() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift index 7aa850b4..8658b361 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift @@ -250,6 +250,39 @@ struct WaylandGraphicsSubmissionFailureTests { #expect(await window.damages() == [nil]) } + @Test(.timeLimit(.minutes(1))) + func abandoningFrameLeaseAllowsNextFrame() async throws { + let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) + let storage = WaylandGraphicsWindowBackingStorage( + window: window, + runtimePath: .softwareFallback( + capabilities: softwareOnlySurfaceCapabilities(), + reason: .forcedSoftware + ) + ) + + do { + let abandonedLease = try await storage.nextFrame() + _ = abandonedLease.size + _ = abandonedLease.contract + _ = abandonedLease.runtimePath + } + + for _ in 0..<100 { + do { + let replacementLease = try await storage.nextFrame() + await replacementLease.cancel() + await storage.closeForTesting() + return + } catch WaylandGraphicsError.frameLeaseActive { + await Task.yield() + } + } + + await storage.closeForTesting() + Issue.record("abandoned frame lease did not release its backing") + } + @Test func windowLifecycleAndWindowSubmissionFailuresAreDistinct() { let windowID = WindowID(rawValue: 45) From 2a7911954ab92b9694ad028bc8c9e37bd5d5303c Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:00:34 -0400 Subject: [PATCH 2/5] feat: make graphics lease authority move-only --- .../main.swift | 16 +- .../WaylandGraphicsPreviewClientTests.swift | 1 + .../Sources/FrameLeaseCopyClient/main.swift | 6 +- .../Sources/RenderLeaseCopyClient/main.swift | 8 +- Sources/WaylandClientKitTool/main.swift | 36 +++ .../ProjectAutomation.swift | 2 + .../WaylandGraphicsSubmissionState.swift | 18 +- .../Public/WaylandGraphicsSubmission.swift | 282 ++++++++++++++---- .../GPUPreviewLiveCapabilityTests.swift | 27 +- ...raphicsExternalBufferSubmissionTests.swift | 265 ++++------------ .../WaylandGraphicsFrameLeaseStateTests.swift | 8 +- ...aylandGraphicsSubmissionFailureTests.swift | 89 ++++++ 12 files changed, 465 insertions(+), 293 deletions(-) diff --git a/Examples/GraphicsPreviewExternalBufferSmoke/main.swift b/Examples/GraphicsPreviewExternalBufferSmoke/main.swift index e80ec5c2..78e79f80 100644 --- a/Examples/GraphicsPreviewExternalBufferSmoke/main.swift +++ b/Examples/GraphicsPreviewExternalBufferSmoke/main.swift @@ -109,7 +109,10 @@ enum GraphicsPreviewExternalBufferSmoke { count: pool.buffers.count ) var releaseCount = 0 - for frameIndex in 0.. StressPool { var renderers: [ExternalDmabufRenderer] = [] @@ -204,6 +204,7 @@ enum GraphicsPreviewExternalBufferSmoke { let configuration = try requireExternalConfiguration( firstLease.contract ) + let synchronization = firstLease.contract.synchronization let renderer: ExternalDmabufRenderer do { renderer = try ExternalDmabufRenderer( @@ -264,7 +265,7 @@ enum GraphicsPreviewExternalBufferSmoke { log("release count: 2") log("same-registration submissions: 2") log("reuse count: 1") - log("sync mode: \(firstLease.contract.synchronization)") + log("sync mode: \(synchronization)") log("release mechanism: \(result.releaseMechanism)") log("release synchronization: \(releaseSynchronizationStatus(result))") log("target device: \(configuration.renderNode)") @@ -338,7 +339,6 @@ enum GraphicsPreviewExternalBufferSmoke { log("release: not observed") log("fallback reason: none") log("failure: expected-negative-test(\(error))") - await lease.cancel() } } diff --git a/IntegrationTests/GraphicsPreviewClient/Tests/WaylandGraphicsPreviewClientTests/WaylandGraphicsPreviewClientTests.swift b/IntegrationTests/GraphicsPreviewClient/Tests/WaylandGraphicsPreviewClientTests/WaylandGraphicsPreviewClientTests.swift index c1d4dd53..581f44cc 100644 --- a/IntegrationTests/GraphicsPreviewClient/Tests/WaylandGraphicsPreviewClientTests/WaylandGraphicsPreviewClientTests.swift +++ b/IntegrationTests/GraphicsPreviewClient/Tests/WaylandGraphicsPreviewClientTests/WaylandGraphicsPreviewClientTests.swift @@ -190,6 +190,7 @@ struct WaylandGraphicsPreviewClientTests { let configuration = try #require( lease.contract.externalBufferConfigurations.first ) + _ = lease.runtimePath let plane = try WaylandGraphicsExternalBufferPlane( fileDescriptor: try OwnedFileDescriptor(adopting: -1), offset: 0, diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift index 77558f76..daad5610 100644 --- a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseCopyClient/main.swift @@ -1,7 +1,7 @@ import WaylandGraphicsPreview -func useFrameLeaseTwice(_ lease: WaylandGraphicsFrameLease) async { - let duplicate = lease - await lease.cancel() +func useFrameLeaseTwice(_ frameLease: consuming WaylandGraphicsFrameLease) async { + let duplicate = frameLease + await frameLease.cancel() await duplicate.cancel() } diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift index 3e77f822..53dbb54b 100644 --- a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/RenderLeaseCopyClient/main.swift @@ -1,7 +1,9 @@ import WaylandGraphicsPreview -func useRenderLeaseTwice(_ lease: WaylandGraphicsExternalBufferRenderLease) async { - let duplicate = lease - await lease.cancel() +func useRenderLeaseTwice( + _ renderLease: consuming WaylandGraphicsExternalBufferRenderLease +) async { + let duplicate = renderLease + await renderLease.cancel() await duplicate.cancel() } diff --git a/Sources/WaylandClientKitTool/main.swift b/Sources/WaylandClientKitTool/main.swift index 7980fbc0..45a31730 100644 --- a/Sources/WaylandClientKitTool/main.swift +++ b/Sources/WaylandClientKitTool/main.swift @@ -1067,6 +1067,41 @@ private func verifyInvalidGraphicsPolicyClientIsRejected(context: ToolContext) t } } +private func verifyGraphicsLeaseCopyingIsRejected(context: ToolContext) throws { + let packagePath = context.repository.url( + "IntegrationTests/InvalidGraphicsLeaseClient" + ).path + let targets = [ + ("FrameLeaseCopyClient", "frameLease"), + ("RenderLeaseCopyClient", "renderLease"), + ] + let scratch = try context.fileSystem.createTemporaryDirectory( + prefix: "waylandclientkit-invalid-graphics-leases" + ) + defer { ignoreCleanupError { try context.fileSystem.removeItem(scratch) } } + + for (target, variableName) in targets { + let result = try context.swift.runSwift( + [ + "build", "--disable-index-store", "--package-path", packagePath, + "--scratch-path", scratch.path, "--target", target, + ], + repository: context.repository, + environment: try compilerFilterEnvironment(context: context), + requireSuccess: false + ) + guard result.exitCode != 0 else { + throw ToolError("graphics lease copy client unexpectedly compiled \(target)") + } + let diagnostics = result.stdout + result.stderr + guard diagnostics.contains("'\(variableName)' consumed more than once") else { + throw ToolError( + "graphics lease copy client failed before move-only ownership was checked" + ) + } + } +} + private func verifyManagedIdentityConstructionIsRejected(context: ToolContext) throws { let packagePath = context.repository.url( "IntegrationTests/InvalidManagedIdentityClient" @@ -1363,6 +1398,7 @@ private func runRequired(context: ToolContext) throws { context: context, packagePath: Test.IntegrationFrameworkHost.packagePath) try runIntegrationPackage(context: context, packagePath: Test.IntegrationTinyUI.packagePath) try verifyInvalidGraphicsPolicyClientIsRejected(context: context) + try verifyGraphicsLeaseCopyingIsRejected(context: context) try verifyManagedIdentityConstructionIsRejected(context: context) try verifyMissingApplicationIdentityIsRejected(context: context) } diff --git a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift index b46ca973..997ce34f 100644 --- a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift +++ b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift @@ -437,6 +437,7 @@ public struct SwiftCommandResolver { "IntegrationTests/GraphicsPreviewClient/Package.swift", "IntegrationTests/FrameworkHostClient/Package.swift", "IntegrationTests/TinyUIPrototype/Package.swift", + "IntegrationTests/InvalidGraphicsLeaseClient/Package.swift", "IntegrationTests/InvalidManagedIdentityClient/Package.swift", "IntegrationTests/InvalidApplicationIdentityClient/Package.swift", ] @@ -449,6 +450,7 @@ public struct SwiftCommandResolver { "IntegrationTests/GraphicsPreviewClient/Tests", "IntegrationTests/FrameworkHostClient/Tests", "IntegrationTests/TinyUIPrototype/Tests", + "IntegrationTests/InvalidGraphicsLeaseClient/Sources", "IntegrationTests/InvalidManagedIdentityClient/Sources", "IntegrationTests/InvalidApplicationIdentityClient/Sources", ] diff --git a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionState.swift b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionState.swift index 6d01e54f..9db70d2b 100644 --- a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionState.swift +++ b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionState.swift @@ -105,6 +105,10 @@ package enum WaylandGraphicsFrameSubmissionOperation: Equatable, Sendable { } } +package enum WaylandGraphicsFrameLeaseInvariantError: Error, Equatable, Sendable { + case staleOrConsumedAuthority +} + package struct WaylandGraphicsFrameLeaseState: Equatable, Sendable { package enum State: Equatable, Sendable { case open(OpenState) @@ -216,10 +220,10 @@ package struct WaylandGraphicsFrameLeaseState: Equatable, Sendable { case .closed: throw WaylandGraphicsError.backingClosed case .submitting: - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority case .open(let openState): guard openState.activeLeaseID == leaseID else { - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority } let operation: WaylandGraphicsFrameSubmissionOperation @@ -246,10 +250,10 @@ package struct WaylandGraphicsFrameLeaseState: Equatable, Sendable { case .closed: throw WaylandGraphicsError.backingClosed case .submitting: - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority case .open(let openState): guard openState.activeLeaseID == leaseID else { - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority } return openState.hasSubmittedFrame ? .redraw : .show @@ -261,10 +265,10 @@ package struct WaylandGraphicsFrameLeaseState: Equatable, Sendable { case .closed: throw WaylandGraphicsError.backingClosed case .submitting: - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority case .open(let openState): guard openState.activeLeaseID == leaseID else { - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority } } } @@ -274,7 +278,7 @@ package struct WaylandGraphicsFrameLeaseState: Equatable, Sendable { case .closed: throw WaylandGraphicsError.backingClosed case .open: - throw WaylandGraphicsError.frameLeaseConsumed + throw WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority case .submitting(let submissionState): state = .open( OpenState( diff --git a/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift b/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift index 371c2325..81fba85d 100644 --- a/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift +++ b/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift @@ -1209,12 +1209,20 @@ public struct WaylandGraphicsExternalBufferSubmissionReceipt: Sendable { } } -public struct WaylandGraphicsExternalBufferRenderLease: Sendable { - public let buffer: WaylandGraphicsExternalBuffer - public let contract: WaylandGraphicsFrameContract - +public struct WaylandGraphicsExternalBufferRenderLease: ~Copyable, Sendable { + private let reservedBuffer: WaylandGraphicsExternalBuffer + private let frameContract: WaylandGraphicsFrameContract private let frameLeaseID: WaylandGraphicsFrameLeaseID private let storage: WaylandGraphicsWindowBackingStorage + private let lifetime: WaylandGraphicsLeaseLifetime + + public var buffer: WaylandGraphicsExternalBuffer { + borrowing get { reservedBuffer } + } + + public var contract: WaylandGraphicsFrameContract { + borrowing get { frameContract } + } package init( buffer externalBuffer: WaylandGraphicsExternalBuffer, @@ -1222,20 +1230,22 @@ public struct WaylandGraphicsExternalBufferRenderLease: Sendable { frameLeaseID leaseID: WaylandGraphicsFrameLeaseID, storage backingStorage: WaylandGraphicsWindowBackingStorage ) { - buffer = externalBuffer - contract = frameContract + reservedBuffer = externalBuffer + self.frameContract = frameContract frameLeaseID = leaseID storage = backingStorage + lifetime = WaylandGraphicsLeaseLifetime( + leaseID: leaseID, + storage: backingStorage + ) } @discardableResult - public func submit( + public consuming func submit( metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil ) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt { - try await storage.submitRegisteredExternalBuffer( - leaseID: frameLeaseID, - buffer: buffer, + try await submitTerminally( acquireSynchronization: nil, metadata: frameMetadata, schedule: frameSchedule @@ -1243,22 +1253,50 @@ public struct WaylandGraphicsExternalBufferRenderLease: Sendable { } @discardableResult - public func submit( + public consuming func submit( acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil ) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt { - try await storage.submitRegisteredExternalBuffer( - leaseID: frameLeaseID, - buffer: buffer, + try await submitTerminally( acquireSynchronization: acquireSynchronization, metadata: frameMetadata, schedule: frameSchedule ) } - public func cancel() async { - await storage.cancelExternalBufferReservation(buffer, leaseID: frameLeaseID) + public consuming func cancel() async { + let leaseID = frameLeaseID + let storage = storage + lifetime.disarm() + await storage.cancel(leaseID: leaseID) + } + + private consuming func submitTerminally( + acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization?, + metadata: WaylandGraphicsFrameMetadata, + schedule: WaylandGraphicsFrameSchedule? + ) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt { + let leaseID = frameLeaseID + let buffer = reservedBuffer + let storage = storage + lifetime.disarm() + do { + return try await storage.submitRegisteredExternalBuffer( + leaseID: leaseID, + buffer: buffer, + acquireSynchronization: acquireSynchronization, + metadata: metadata, + schedule: schedule + ) + } catch { + await storage.cancel(leaseID: leaseID) + throw error + } + } + + deinit { + lifetime.abandon() } } @@ -1306,7 +1344,6 @@ public enum WaylandGraphicsError: Error, Equatable, Sendable { case windowClosed case backingClosed case frameLeaseActive - case frameLeaseConsumed case unsupportedMetadata case invalidDamageRegion case unsupportedPacing @@ -1321,6 +1358,50 @@ public enum WaylandGraphicsError: Error, Equatable, Sendable { case submissionFailed(WaylandGraphicsSubmissionFailure) } +// SAFETY: The armed state is protected by `lock`. Abandonment claims the +// cleanup exactly once before scheduling work on the backing actor. +@safe +final class WaylandGraphicsLeaseLifetime: @unchecked Sendable { + private let lock = NSLock() + private let leaseID: WaylandGraphicsFrameLeaseID + private let storage: WaylandGraphicsWindowBackingStorage + private var isArmed = true + + init( + leaseID: WaylandGraphicsFrameLeaseID, + storage: WaylandGraphicsWindowBackingStorage + ) { + self.leaseID = leaseID + self.storage = storage + } + + func disarm() { + lock.withLock { + isArmed = false + } + } + + func abandon() { + let shouldCancel = lock.withLock { + guard isArmed else { return false } + isArmed = false + return true + } + guard shouldCancel else { return } + + let leaseID = leaseID + let storage = storage + // swiftlint:disable:next no_unstructured_task + Task { + await storage.cancel(leaseID: leaseID) + } + } + + deinit { + abandon() + } +} + public struct WaylandGraphicsWindowBacking: Sendable { public let window: Window private let storage: WaylandGraphicsWindowBackingStorage @@ -1402,13 +1483,25 @@ public struct WaylandGraphicsWindowBacking: Sendable { extension WaylandGraphicsWindowBacking: Identifiable {} -public struct WaylandGraphicsFrameLease: Sendable { - public let size: PositivePixelSize - public let contract: WaylandGraphicsFrameContract - public let runtimePath: WaylandGraphicsRuntimePath - +public struct WaylandGraphicsFrameLease: ~Copyable, Sendable { + private let frameSize: PositivePixelSize + private let frameContract: WaylandGraphicsFrameContract + private let frameRuntimePath: WaylandGraphicsRuntimePath private let storage: WaylandGraphicsWindowBackingStorage private let id: WaylandGraphicsFrameLeaseID + private let lifetime: WaylandGraphicsLeaseLifetime + + public var size: PositivePixelSize { + borrowing get { frameSize } + } + + public var contract: WaylandGraphicsFrameContract { + borrowing get { frameContract } + } + + public var runtimePath: WaylandGraphicsRuntimePath { + borrowing get { frameRuntimePath } + } init( id leaseID: WaylandGraphicsFrameLeaseID, @@ -1418,95 +1511,160 @@ public struct WaylandGraphicsFrameLease: Sendable { storage backingStorage: WaylandGraphicsWindowBackingStorage ) { id = leaseID - size = frameSize - contract = frameContract - runtimePath = frameRuntimePath + self.frameSize = frameSize + self.frameContract = frameContract + self.frameRuntimePath = frameRuntimePath storage = backingStorage + lifetime = WaylandGraphicsLeaseLifetime( + leaseID: leaseID, + storage: backingStorage + ) } @discardableResult - public func submit(_ frame: WaylandGraphicsSubmittedFrame) async throws + public consuming func submit(_ frame: WaylandGraphicsSubmittedFrame) async throws -> WaylandGraphicsFrameResult { - try await storage.submit(leaseID: id, frame: frame) + try await submitTerminally( + frame, + schedule: nil, + beforeSubmissionEffect: noThrowingGraphicsPreviewSubmissionHook, + afterSubmissionEffect: noGraphicsPreviewSubmissionHook + ) } @discardableResult - public func submit( + public consuming func submit( _ frame: WaylandGraphicsSubmittedFrame, schedule frameSchedule: WaylandGraphicsFrameSchedule ) async throws -> WaylandGraphicsFrameResult { - try await storage.submit( - leaseID: id, - frame: frame, - schedule: frameSchedule + try await submitTerminally( + frame, + schedule: frameSchedule, + beforeSubmissionEffect: noThrowingGraphicsPreviewSubmissionHook, + afterSubmissionEffect: noGraphicsPreviewSubmissionHook ) } @discardableResult - public func submitSoftware( + public consuming func submitSoftware( metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void ) async throws -> WaylandGraphicsFrameResult { - try await storage.submitSoftware( - leaseID: id, - metadata: frameMetadata, - draw - ) + let leaseID = id + let storage = storage + lifetime.disarm() + do { + return try await storage.submitSoftware( + leaseID: leaseID, + metadata: frameMetadata, + draw + ) + } catch { + await storage.cancel(leaseID: leaseID) + throw error + } } @discardableResult - public func submitSoftware( + public consuming func submitSoftware( schedule frameSchedule: WaylandGraphicsFrameSchedule, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void ) async throws -> WaylandGraphicsFrameResult { - try await storage.submitSoftware( - leaseID: id, - metadata: frameMetadata, - schedule: frameSchedule, - draw - ) + let leaseID = id + let storage = storage + lifetime.disarm() + do { + return try await storage.submitSoftware( + leaseID: leaseID, + metadata: frameMetadata, + schedule: frameSchedule, + draw + ) + } catch { + await storage.cancel(leaseID: leaseID) + throw error + } } @discardableResult - package func submitForTestingBeforeSubmissionEffect( + package consuming func submitForTestingBeforeSubmissionEffect( _ frame: WaylandGraphicsSubmittedFrame, _ beforeSubmissionEffect: @Sendable @escaping () async throws -> Void ) async throws -> WaylandGraphicsFrameResult { - try await storage.submit( - leaseID: id, - frame: frame, + try await submitTerminally( + frame, + schedule: nil, beforeSubmissionEffect: beforeSubmissionEffect, - afterSubmissionEffect: noThrowingGraphicsPreviewSubmissionHook + afterSubmissionEffect: noGraphicsPreviewSubmissionHook ) } @discardableResult - package func submitForTesting( + package consuming func submitForTesting( _ frame: WaylandGraphicsSubmittedFrame, afterSubmissionEffect: @Sendable @escaping () async throws -> Void ) async throws -> WaylandGraphicsFrameResult { - try await storage.submit( - leaseID: id, - frame: frame, + try await submitTerminally( + frame, + schedule: nil, beforeSubmissionEffect: noThrowingGraphicsPreviewSubmissionHook, afterSubmissionEffect: afterSubmissionEffect ) } - public func cancel() async { - await storage.cancel(leaseID: id) + public consuming func cancel() async { + let leaseID = id + let storage = storage + lifetime.disarm() + await storage.cancel(leaseID: leaseID) } - public func reserveExternalBuffer( + public consuming func reserveExternalBuffer( _ buffer: WaylandGraphicsExternalBuffer ) async throws -> WaylandGraphicsExternalBufferRenderLease { - try await storage.reserveExternalBuffer( - buffer, - leaseID: id, - contract: contract - ) + let leaseID = id + let contract = frameContract + let storage = storage + lifetime.disarm() + do { + return try await storage.reserveExternalBuffer( + buffer, + leaseID: leaseID, + contract: contract + ) + } catch { + await storage.cancel(leaseID: leaseID) + throw error + } + } + + private consuming func submitTerminally( + _ frame: WaylandGraphicsSubmittedFrame, + schedule: WaylandGraphicsFrameSchedule?, + beforeSubmissionEffect: @Sendable @escaping () async throws -> Void, + afterSubmissionEffect: @Sendable @escaping () async throws -> Void + ) async throws -> WaylandGraphicsFrameResult { + let leaseID = id + let storage = storage + lifetime.disarm() + do { + return try await storage.submit( + leaseID: leaseID, + frame: frame, + schedule: schedule, + beforeSubmissionEffect: beforeSubmissionEffect, + afterSubmissionEffect: afterSubmissionEffect + ) + } catch { + await storage.cancel(leaseID: leaseID) + throw error + } + } + + deinit { + lifetime.abandon() } } diff --git a/Tests/WaylandGPUPreviewTests/GPUPreviewLiveCapabilityTests.swift b/Tests/WaylandGPUPreviewTests/GPUPreviewLiveCapabilityTests.swift index 2dc190e3..75663dae 100644 --- a/Tests/WaylandGPUPreviewTests/GPUPreviewLiveCapabilityTests.swift +++ b/Tests/WaylandGPUPreviewTests/GPUPreviewLiveCapabilityTests.swift @@ -162,8 +162,13 @@ struct GPUPreviewLiveCapabilityTests { await backing.close() - await expectGraphicsError(.backingClosed) { - try await lease.submit(.clearColor(.black)) + do { + _ = try await lease.submit(.clearColor(.black)) + Issue.record("Expected WaylandGraphicsError.backingClosed") + } catch let error as WaylandGraphicsError { + #expect(error == .backingClosed) + } catch { + Issue.record("Expected WaylandGraphicsError.backingClosed, got \(error)") } } } @@ -182,12 +187,17 @@ struct GPUPreviewLiveCapabilityTests { ) let lease = try await backing.nextFrame() - await expectGraphicsError(.backingClosed) { - try await lease.submitForTesting( + do { + _ = try await lease.submitForTesting( .clearColor(.black) ) { await backing.close() } + Issue.record("Expected WaylandGraphicsError.backingClosed") + } catch let error as WaylandGraphicsError { + #expect(error == .backingClosed) + } catch { + Issue.record("Expected WaylandGraphicsError.backingClosed, got \(error)") } } } @@ -206,12 +216,17 @@ struct GPUPreviewLiveCapabilityTests { ) let failedLease = try await backing.nextFrame() - await expectGraphicsError(.unsupportedPacing) { - try await failedLease.submitForTestingBeforeSubmissionEffect( + do { + _ = try await failedLease.submitForTestingBeforeSubmissionEffect( .clearColor(.black) ) { throw WaylandGraphicsError.unsupportedPacing } + Issue.record("Expected WaylandGraphicsError.unsupportedPacing") + } catch let error as WaylandGraphicsError { + #expect(error == .unsupportedPacing) + } catch { + Issue.record("Expected WaylandGraphicsError.unsupportedPacing, got \(error)") } let retryLease = try await backing.nextFrame() diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift index 53467ab8..3fa2af04 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -384,8 +384,9 @@ struct ExternalSoftwareFallbackLifecycleTests { ) ) let externalLease = try await storage.nextFrame() + let externalContract = externalLease.contract let configurationID = try #require( - externalLease.contract.recommendedExternalConfigurationID + externalContract.recommendedExternalConfigurationID ) let buffer = try await registerTestExternalBuffer( storage: storage, @@ -398,7 +399,7 @@ struct ExternalSoftwareFallbackLifecycleTests { let fallbackLease = try await storage.nextFrame() let runtimePath = await storage.runtimePathSnapshotForTesting() - #expect(fallbackLease.contract.generation != externalLease.contract.generation) + #expect(fallbackLease.contract.generation != externalContract.generation) #expect(fallbackLease.contract.externalBufferConfigurations.isEmpty) #expect(runtimePath.backing == .fallback(.surfaceFeedbackUnavailable)) #expect(runtimePath.surfaceFeedback == .failed(.surfaceFeedbackUnavailable)) @@ -420,7 +421,7 @@ struct ExternalSoftwareFallbackLifecycleTests { do { _ = try await storage.registerExternalBuffer( try testExternalDescriptor(), - contract: externalLease.contract, + contract: externalContract, configurationID: configurationID ) Issue.record("expected stale external contract rejection") @@ -430,7 +431,6 @@ struct ExternalSoftwareFallbackLifecycleTests { Issue.record("unexpected error: \(error)") } - await fallbackLease.cancel() await storage.closeForTesting() #expect(destroyRecorder.count == 1) } @@ -449,6 +449,7 @@ struct ExternalSoftwareFallbackLifecycleTests { ) ) let externalLease = try await storage.nextFrame() + let externalGeneration = externalLease.contract.generation let buffer = try await registerTestExternalBuffer( storage: storage, lease: externalLease, @@ -460,7 +461,7 @@ struct ExternalSoftwareFallbackLifecycleTests { await window.setSurfaceFeedbackSynchronization(nil) let fallbackLease = try await storage.nextFrame() - #expect(fallbackLease.contract.generation != externalLease.contract.generation) + #expect(fallbackLease.contract.generation != externalGeneration) #expect(fallbackLease.contract.externalBufferConfigurations.isEmpty) #expect(await storage.externalBufferLifecycleSnapshotForTesting().retiring == 1) #expect(await storage.externalBufferSubmittedSlotRawValuesForTesting() == [0]) @@ -489,7 +490,6 @@ struct ExternalSoftwareFallbackLifecycleTests { #expect(await storage.externalBufferAvailableSlotRawValuesForTesting().isEmpty) #expect(destroyRecorder.count == 1) - await fallbackLease.cancel() await storage.closeForTesting() #expect(destroyRecorder.count == 1) } @@ -690,14 +690,24 @@ struct ExternalBufferSyncTests { let acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization = .drmSyncobj(acquirePoint) - await #expect( - throws: WaylandGraphicsError.unavailable(.externalSynchronizationUnavailable) - ) { + do { _ = try await renderLease.submit( acquireSynchronization: acquireSynchronization ) + Issue.record("expected unavailable external synchronization") + } catch { + #expect( + (error as? WaylandGraphicsError) + == WaylandGraphicsError.unavailable( + .externalSynchronizationUnavailable + ) + ) } + let replacementLease = try await storage.nextFrame() + let replacementRenderLease = try await replacementLease.reserveExternalBuffer(buffer) + await replacementRenderLease.cancel() + await storage.closeForTesting() } @@ -726,12 +736,18 @@ struct ExternalBufferSyncTests { let acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization = .drmSyncobj(acquirePoint) - await #expect( - throws: WaylandGraphicsError.unavailable(.externalSynchronizationUnavailable) - ) { + do { _ = try await renderLease.submit( acquireSynchronization: acquireSynchronization ) + Issue.record("expected unavailable external synchronization") + } catch { + #expect( + (error as? WaylandGraphicsError) + == WaylandGraphicsError.unavailable( + .externalSynchronizationUnavailable + ) + ) } await storage.closeForTesting() @@ -833,11 +849,20 @@ struct ExternalBufferSyncTests { descriptor: try testExternalDescriptor() ) let renderLease = try await lease.reserveExternalBuffer(buffer) - await #expect( - throws: WaylandGraphicsError.unavailable(.externalSynchronizationUnavailable) - ) { + let acquireSynchronization = WaylandGraphicsExternalAcquireSynchronization.drmSyncobj( + acquirePoint + ) + do { _ = try await renderLease.submit( - acquireSynchronization: .drmSyncobj(acquirePoint) + acquireSynchronization: acquireSynchronization + ) + Issue.record("expected unavailable external synchronization") + } catch { + #expect( + (error as? WaylandGraphicsError) + == WaylandGraphicsError.unavailable( + .externalSynchronizationUnavailable + ) ) } @@ -1003,7 +1028,7 @@ struct ExternalBufferPresentationFeedbackTests { _ = try await blockedLease.reserveExternalBuffer(submitted.buffer) Issue.record("presentation feedback must not release external buffer") } catch WaylandGraphicsError.externalBufferUnavailable { - await blockedLease.cancel() + // Failed reservation consumed and released the frame permission. } catch { Issue.record("unexpected error: \(error)") } @@ -1090,13 +1115,19 @@ struct WaylandGraphicsExternalBufferLifecycleTests { ) ) - await #expect(throws: WaylandGraphicsError.invalidDamageRegion) { + do { _ = try await failedLease.submit(frame) + Issue.record("expected invalid damage rejection") + } catch { + #expect( + (error as? WaylandGraphicsError) == WaylandGraphicsError.invalidDamageRegion + ) } #expect(await window.cancelPresentationRequests == 1) - await failedLease.cancel() - #expect(await window.cancelPresentationRequests == 1) + let recoveryLease = try await storage.nextFrame() + await recoveryLease.cancel() + #expect(await window.cancelPresentationRequests == 2) await storage.closeForTesting() } @@ -1396,29 +1427,6 @@ struct WaylandGraphicsExternalBufferLifecycleTests { #expect(await storage.externalBufferAvailableSlotRawValuesForTesting().isEmpty) } - @Test - func reserveAfterLeaseCancelDoesNotSubmitExternalBuffer() async throws { - let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) - let storage = externalBufferStorage(window: window) - let lease = try await storage.nextFrame() - let buffer = try await registerTestExternalBuffer( - storage: storage, - lease: lease, - descriptor: try testExternalDescriptor() - ) - - await lease.cancel() - - do { - _ = try await lease.reserveExternalBuffer(buffer) - Issue.record("expected consumed lease failure") - } catch WaylandGraphicsError.frameLeaseConsumed { - #expect(await window.importRequests == 1) - } catch { - Issue.record("unexpected error: \(error)") - } - } - @Test func submitAfterBackingCloseDoesNotImportExternalBuffer() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) @@ -1546,7 +1554,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { _ = try await blockedLease.reserveExternalBuffer(buffer) Issue.record("expected registered external buffer to remain busy") } catch WaylandGraphicsError.externalBufferUnavailable { - await blockedLease.cancel() + // Failed reservation consumed and released the frame permission. } catch { Issue.record("unexpected error: \(error)") } @@ -1564,31 +1572,6 @@ struct WaylandGraphicsExternalBufferLifecycleTests { await storage.closeForTesting() } - @Test - func frameLeaseCancelReleasesReservedExternalBuffer() async throws { - let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) - let storage = externalBufferStorage(window: window) - let firstLease = try await storage.nextFrame() - let buffer = try await registerTestExternalBuffer( - storage: storage, - lease: firstLease, - descriptor: try testExternalDescriptor( - modifier: WaylandGraphicsDRMFormatModifier.linear.rawValue, - offset: 0, - fd: testOwnedFileDescriptor() - ) - ) - - _ = try await firstLease.reserveExternalBuffer(buffer) - await firstLease.cancel() - - let secondLease = try await storage.nextFrame() - _ = try await secondLease.reserveExternalBuffer(buffer) - await secondLease.cancel() - - await storage.closeForTesting() - } - @Test func renderLeaseCancelReleasesFrameAndBufferReservation() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) @@ -1645,117 +1628,6 @@ struct WaylandGraphicsExternalBufferLifecycleTests { Issue.record("abandoned render lease did not release its frame and buffer") } - @Test - func frameLeaseCannotReserveMultipleExternalBuffers() async throws { - let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) - let storage = externalBufferStorage(window: window) - let lease = try await storage.nextFrame() - let firstBuffer = try await registerTestExternalBuffer( - storage: storage, - lease: lease, - descriptor: try testExternalDescriptor() - ) - let secondBuffer = try await registerTestExternalBuffer( - storage: storage, - lease: lease, - descriptor: try testExternalDescriptor() - ) - - _ = try await lease.reserveExternalBuffer(firstBuffer) - await #expect(throws: (any Error).self) { - _ = try await lease.reserveExternalBuffer(secondBuffer) - } - await lease.cancel() - - await storage.closeForTesting() - } - - @Test - func failedSoftwareSubmitReleasesExternalBufferReservation() async throws { - let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) - let storage = externalBufferStorage(window: window) - let firstLease = try await storage.nextFrame() - let buffer = try await registerTestExternalBuffer( - storage: storage, - lease: firstLease, - descriptor: try testExternalDescriptor() - ) - - _ = try await firstLease.reserveExternalBuffer(buffer) - await #expect( - throws: WaylandGraphicsError.unavailable(.managedGPUSubmissionUnavailable) - ) { - try await firstLease.submitSoftware { _ in - Issue.record("unexpected software draw") - } - } - - let secondLease = try await storage.nextFrame() - _ = try await secondLease.reserveExternalBuffer(buffer) - await secondLease.cancel() - - await storage.closeForTesting() - } - - @Test - func staleRenderLeaseCancelDoesNotClearNewReservation() async throws { - let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) - let storage = externalBufferStorage(window: window) - let firstLease = try await storage.nextFrame() - let buffer = try await registerTestExternalBuffer( - storage: storage, - lease: firstLease, - descriptor: try testExternalDescriptor( - modifier: WaylandGraphicsDRMFormatModifier.linear.rawValue, - offset: 0, - fd: testOwnedFileDescriptor() - ) - ) - - let firstRenderLease = try await firstLease.reserveExternalBuffer(buffer) - let firstReceipt = try await firstRenderLease.submit() - await window.emitImportedBufferRelease(at: 0) - #expect(await firstReceipt.waitForRelease() == .released) - - let secondLease = try await storage.nextFrame() - let secondRenderLease = try await secondLease.reserveExternalBuffer(buffer) - await firstRenderLease.cancel() - _ = try await secondRenderLease.submit() - - await storage.closeForTesting() - } - - @Test - func cancelDuringExternalSubmitDoesNotClearSubmission() async throws { - let hook = ExternalBufferPresentationHook() - let window = try ExternalBufferFakeManagedWindow( - importBehavior: .succeed - ) { - await hook.run() - } - let storage = externalBufferStorage(window: window) - let lease = try await storage.nextFrame() - let buffer = try await registerTestExternalBuffer( - storage: storage, - lease: lease, - descriptor: try testExternalDescriptor( - modifier: WaylandGraphicsDRMFormatModifier.linear.rawValue, - offset: 0, - fd: testOwnedFileDescriptor() - ) - ) - - let renderLease = try await lease.reserveExternalBuffer(buffer) - await hook.set { await renderLease.cancel() } - let receipt = try await renderLease.submit() - - #expect(await storage.externalBufferSubmittedSlotRawValuesForTesting() == [0]) - await window.emitImportedBufferRelease(at: 0) - #expect(await receipt.waitForRelease() == .released) - - await storage.closeForTesting() - } - @Test func registeredExternalBufferCanUnregisterWhenAvailable() async throws { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) @@ -1781,7 +1653,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { _ = try await lease.reserveExternalBuffer(buffer) Issue.record("expected unregistered external buffer to be unavailable") } catch WaylandGraphicsError.externalBufferUnavailable { - await lease.cancel() + // Failed reservation consumed and released the frame permission. } catch { Issue.record("unexpected error: \(error)") } @@ -1831,6 +1703,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { let window = try ExternalBufferFakeManagedWindow(importBehavior: .succeed) let storage = externalBufferStorage(window: window) let firstLease = try await storage.nextFrame() + let firstGeneration = firstLease.contract.generation let buffer = try await registerTestExternalBuffer( storage: storage, lease: firstLease, @@ -1845,14 +1718,14 @@ struct WaylandGraphicsExternalBufferLifecycleTests { await window.setGeometry(try testGraphicsSurfaceGeometry(width: 128, height: 96)) let secondLease = try await storage.nextFrame() - #expect(secondLease.contract.generation != firstLease.contract.generation) + #expect(secondLease.contract.generation != firstGeneration) #expect(await storage.externalBufferAvailableSlotRawValuesForTesting().isEmpty) do { _ = try await secondLease.reserveExternalBuffer(buffer) Issue.record("expected old-generation external buffer to be retired") } catch WaylandGraphicsError.externalBufferUnavailable { - await secondLease.cancel() + // Failed reservation consumed and released the frame permission. } catch { Issue.record("unexpected error: \(error)") } @@ -1943,7 +1816,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { _ = try await blockedLease.reserveExternalBuffer(buffer) Issue.record("release facts must not make a submitted buffer reusable") } catch WaylandGraphicsError.externalBufferUnavailable { - await blockedLease.cancel() + // Failed reservation consumed and released the frame permission. } catch { Issue.record("unexpected error: \(error)") } @@ -2221,6 +2094,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { ) ) let firstLease = try await storage.nextFrame() + let firstGeneration = firstLease.contract.generation let buffer = try await registerTestExternalBuffer( storage: storage, lease: firstLease, @@ -2236,7 +2110,7 @@ struct WaylandGraphicsExternalBufferLifecycleTests { await window.setGeometry(try testGraphicsSurfaceGeometry(width: 128, height: 96)) let secondLease = try await storage.nextFrame() - #expect(secondLease.contract.generation != firstLease.contract.generation) + #expect(secondLease.contract.generation != firstGeneration) #expect(await storage.externalBufferSubmittedSlotRawValuesForTesting() == [0]) await window.emitImportedBufferRelease(at: 0) @@ -2261,25 +2135,16 @@ struct WaylandGraphicsExternalBufferLifecycleTests { #expect(await storage.externalBufferSubmittedSlotRawValuesForTesting().isEmpty) #expect(await storage.externalBufferAvailableSlotRawValuesForTesting().isEmpty) - await expectExternalBufferUnavailable { - _ = try await secondLease.reserveExternalBuffer(buffer) - } - await secondLease.cancel() - - await storage.closeForTesting() - } - - private func expectExternalBufferUnavailable( - _ operation: () async throws -> Void - ) async { do { - try await operation() + _ = try await secondLease.reserveExternalBuffer(buffer) Issue.record("expected external buffer to be unavailable") } catch WaylandGraphicsError.externalBufferUnavailable { - // Expected. + // Expected. The failed reservation released the frame permission. } catch { Issue.record("unexpected error: \(error)") } + + await storage.closeForTesting() } private func externalReleaseMonitorCountReaches( @@ -2710,7 +2575,7 @@ private func testGraphicsSurfaceGeometry(width: Int, height: Int) throws -> Surf private func registerAndSubmitTestExternalBuffer( storage: WaylandGraphicsWindowBackingStorage, - lease: WaylandGraphicsFrameLease, + lease: consuming WaylandGraphicsFrameLease, descriptor: consuming WaylandGraphicsExternalBufferDescriptor, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil @@ -2855,7 +2720,7 @@ private func explicitReleasePoint( private func registerTestExternalBuffer( storage: WaylandGraphicsWindowBackingStorage, - lease: WaylandGraphicsFrameLease, + lease: borrowing WaylandGraphicsFrameLease, descriptor: consuming WaylandGraphicsExternalBufferDescriptor ) async throws -> WaylandGraphicsExternalBuffer { let configurationID = try #require( diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsFrameLeaseStateTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsFrameLeaseStateTests.swift index d0ce9acb..447c1ab8 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsFrameLeaseStateTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsFrameLeaseStateTests.swift @@ -34,7 +34,7 @@ struct WaylandGraphicsFrameLeaseStateTests { #expect( try leaseState.prepareSubmission(leaseID: leaseID) == .show ) - #expect(throws: WaylandGraphicsError.frameLeaseConsumed) { + #expect(throws: WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority) { try leaseState.prepareSubmission(leaseID: leaseID) } } @@ -45,7 +45,7 @@ struct WaylandGraphicsFrameLeaseStateTests { let leaseID = try leaseState.issueLease() _ = try leaseState.prepareSubmission(leaseID: leaseID) - #expect(throws: WaylandGraphicsError.frameLeaseConsumed) { + #expect(throws: WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority) { try leaseState.prepareSubmission(leaseID: leaseID) } } @@ -55,7 +55,7 @@ struct WaylandGraphicsFrameLeaseStateTests { var leaseState = WaylandGraphicsFrameLeaseState() _ = try leaseState.issueLease() - #expect(throws: WaylandGraphicsError.frameLeaseConsumed) { + #expect(throws: WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority) { try leaseState.prepareSubmission(leaseID: 999) } } @@ -106,7 +106,7 @@ struct WaylandGraphicsFrameLeaseStateTests { func finishSubmissionWithoutInFlightSubmissionIsRejected() { var leaseState = WaylandGraphicsFrameLeaseState() - #expect(throws: WaylandGraphicsError.frameLeaseConsumed) { + #expect(throws: WaylandGraphicsFrameLeaseInvariantError.staleOrConsumedAuthority) { try leaseState.finishSubmission() } } diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift index 8658b361..f0a5d13d 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift @@ -283,6 +283,95 @@ struct WaylandGraphicsSubmissionFailureTests { Issue.record("abandoned frame lease did not release its backing") } + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithSubmissionEndsClosed() async throws { + let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) + let storage = WaylandGraphicsWindowBackingStorage( + window: window, + runtimePath: .softwareFallback( + capabilities: softwareOnlySurfaceCapabilities(), + reason: .forcedSoftware + ) + ) + let lease = try await storage.nextFrame() + + do { + _ = try await lease.submitForTesting(.clearColor(.black)) { + await storage.closeForTesting() + } + Issue.record("expected backing close to win submission completion") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } + + do { + let unexpectedLease = try await storage.nextFrame() + await unexpectedLease.cancel() + Issue.record("closed backing unexpectedly issued another lease") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } + } + + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithCancellationEndsClosed() async throws { + let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) + let storage = WaylandGraphicsWindowBackingStorage( + window: window, + runtimePath: .softwareFallback( + capabilities: softwareOnlySurfaceCapabilities(), + reason: .forcedSoftware + ) + ) + let lease = try await storage.nextFrame() + + async let closing: Void = storage.closeForTesting() + await lease.cancel() + await closing + + do { + let unexpectedLease = try await storage.nextFrame() + await unexpectedLease.cancel() + Issue.record("closed backing unexpectedly issued another lease") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } + } + + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithAbandonmentEndsClosed() async throws { + let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) + let storage = WaylandGraphicsWindowBackingStorage( + window: window, + runtimePath: .softwareFallback( + capabilities: softwareOnlySurfaceCapabilities(), + reason: .forcedSoftware + ) + ) + + do { + let abandonedLease = try await storage.nextFrame() + _ = abandonedLease.runtimePath + } + await storage.closeForTesting() + + do { + let unexpectedLease = try await storage.nextFrame() + await unexpectedLease.cancel() + Issue.record("closed backing unexpectedly issued another lease") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } + } + @Test func windowLifecycleAndWindowSubmissionFailuresAreDistinct() { let windowID = WindowID(rawValue: 45) From 30110e8c9d9fd4af1649f40b2d69b96512d9d179 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:06:17 -0400 Subject: [PATCH 3/5] test: enforce move-only graphics lease contracts --- .../InvalidGraphicsLeaseClient/Package.swift | 7 + .../FrameLeaseTransferClient/main.swift | 10 + README.md | 5 +- Sources/WaylandClientKitTool/main.swift | 9 +- .../ProjectAutomation.swift | 15 +- .../SourceOwnershipAPIBaseline.swift | 409 ++++++++++++++++++ .../WaylandGraphicsSubmissionStorage.swift | 7 - .../Public/WaylandGraphicsSubmission.swift | 2 +- .../ExternalBufferSubmission.md | 7 + .../FrameLeases.md | 30 +- .../PublicAPIBaselineTests.swift | 42 ++ .../PublicAPIOverloadBaselineTests.swift | 52 +++ .../GPUWindowPresenterStateTests.swift | 11 +- ...raphicsExternalBufferSubmissionTests.swift | 4 +- .../WaylandGraphicsLeaseLifetimeTests.swift | 102 +++++ ...aylandGraphicsSubmissionFailureTests.swift | 124 +----- docs/documentation-symbol-coverage.json | 2 +- docs/public-api-audit.md | 9 + docs/public-api-baseline.md | 55 ++- 19 files changed, 727 insertions(+), 175 deletions(-) create mode 100644 IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseTransferClient/main.swift create mode 100644 Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift create mode 100644 Tests/WaylandClientKitToolTests/PublicAPIOverloadBaselineTests.swift create mode 100644 Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsLeaseLifetimeTests.swift diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift index d871c1e4..a864711e 100644 --- a/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Package.swift @@ -20,6 +20,13 @@ let package = Package( ], swiftSettings: swiftSettings ), + .executableTarget( + name: "FrameLeaseTransferClient", + dependencies: [ + .product(name: "WaylandGraphicsPreview", package: "WaylandClientKit") + ], + swiftSettings: swiftSettings + ), .executableTarget( name: "RenderLeaseCopyClient", dependencies: [ diff --git a/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseTransferClient/main.swift b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseTransferClient/main.swift new file mode 100644 index 00000000..7a3fd2c0 --- /dev/null +++ b/IntegrationTests/InvalidGraphicsLeaseClient/Sources/FrameLeaseTransferClient/main.swift @@ -0,0 +1,10 @@ +import WaylandGraphicsPreview + +func useTransferredFrameLease( + _ frameLease: consuming WaylandGraphicsFrameLease, + buffer: WaylandGraphicsExternalBuffer +) async throws { + let renderLease = try await frameLease.reserveExternalBuffer(buffer) + await frameLease.cancel() + await renderLease.cancel() +} diff --git a/README.md b/README.md index 9968053b..7f16d125 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ swift run wck identity verify-generated ``` `ci cheap` runs static checks. `ci required` adds the public API baseline, -strict build, unit tests, external integration packages, and the expected-failure -graphics policy client. `ci check` also verifies Markdown and DocC. +strict build, unit tests, external integration packages, and expected-failure +clients for invalid graphics policy and graphics-lease copying or reuse. `ci check` +also verifies Markdown and DocC. See [Tooling](docs/tooling.md) and [Protocol Generation](docs/generation.md) for command ownership and generated diff --git a/Sources/WaylandClientKitTool/main.swift b/Sources/WaylandClientKitTool/main.swift index 45a31730..bb083bf3 100644 --- a/Sources/WaylandClientKitTool/main.swift +++ b/Sources/WaylandClientKitTool/main.swift @@ -1067,12 +1067,13 @@ private func verifyInvalidGraphicsPolicyClientIsRejected(context: ToolContext) t } } -private func verifyGraphicsLeaseCopyingIsRejected(context: ToolContext) throws { +private func verifyGraphicsLeaseOwnershipIsEnforced(context: ToolContext) throws { let packagePath = context.repository.url( "IntegrationTests/InvalidGraphicsLeaseClient" ).path let targets = [ ("FrameLeaseCopyClient", "frameLease"), + ("FrameLeaseTransferClient", "frameLease"), ("RenderLeaseCopyClient", "renderLease"), ] let scratch = try context.fileSystem.createTemporaryDirectory( @@ -1091,12 +1092,12 @@ private func verifyGraphicsLeaseCopyingIsRejected(context: ToolContext) throws { requireSuccess: false ) guard result.exitCode != 0 else { - throw ToolError("graphics lease copy client unexpectedly compiled \(target)") + throw ToolError("invalid graphics lease client unexpectedly compiled \(target)") } let diagnostics = result.stdout + result.stderr guard diagnostics.contains("'\(variableName)' consumed more than once") else { throw ToolError( - "graphics lease copy client failed before move-only ownership was checked" + "invalid graphics lease client failed before move-only ownership was checked" ) } } @@ -1398,7 +1399,7 @@ private func runRequired(context: ToolContext) throws { context: context, packagePath: Test.IntegrationFrameworkHost.packagePath) try runIntegrationPackage(context: context, packagePath: Test.IntegrationTinyUI.packagePath) try verifyInvalidGraphicsPolicyClientIsRejected(context: context) - try verifyGraphicsLeaseCopyingIsRejected(context: context) + try verifyGraphicsLeaseOwnershipIsEnforced(context: context) try verifyManagedIdentityConstructionIsRejected(context: context) try verifyMissingApplicationIdentityIsRejected(context: context) } diff --git a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift index 997ce34f..affb2cc2 100644 --- a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift +++ b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift @@ -932,7 +932,17 @@ public struct PublicAPIAuditor { let report = try SemanticPublicAPIBaseline(fileSystem: context.fileSystem).render( symbolGraphs: verifier.publicProductSymbolGraphs() ) - return "# WaylandClientKit Semantic Public API Report\n\n\(report)" + let ownershipReport = try SourceOwnershipAPIBaseline( + fileSystem: context.fileSystem, + runner: context.runner + ).render( + moduleSources: Dictionary( + uniqueKeysWithValues: DocCVerifier.publicProducts.map { product in + (product.moduleName, context.repository.url("Sources/\(product.moduleName)")) + } + ) + ) + return "# WaylandClientKit Semantic Public API Report\n\n\(report)\n\(ownershipReport)" } public func verify(update: Bool, environment: [String: String] = [:]) throws { @@ -956,7 +966,8 @@ public struct PublicAPIAuditor { # WaylandClientKit Public API Baseline This baseline records compiler-emitted public symbols and relationships for - vended library products. Source locations and formatting are excluded, while + vended library products, plus compiler-checked ownership markers omitted by + Swift symbol graphs. Source locations and formatting are excluded, while continuation-line signature changes remain visible. Preview products are included so source-breaking preview API drift is reviewed. diff --git a/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift b/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift new file mode 100644 index 00000000..2e2ecbfc --- /dev/null +++ b/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift @@ -0,0 +1,409 @@ +import Foundation + +/// Supplements compiler symbol graphs with ownership markers that Swift's +/// symbol-graph emitter currently omits from type and accessor declarations. +/// +/// Source ownership is read from Swift's parse-only AST dump. That keeps +/// comments, literals, source formatting, and declaration modifier order out +/// of the API model without requiring imported modules to be type checked a +/// second time. +public struct SourceOwnershipAPIBaseline { + public let fileSystem: FileSystem + public let runner: ProcessRunner + public let swiftCompilerExecutable: String + + public init( + fileSystem: FileSystem = LocalFileSystem(), + runner: ProcessRunner = ProcessRunner(), + swiftCompilerExecutable: String = "swiftc" + ) { + self.fileSystem = fileSystem + self.runner = runner + self.swiftCompilerExecutable = swiftCompilerExecutable + } + + public func render(moduleSources: [String: URL]) throws -> String { + var records = Set() + for (module, root) in moduleSources { + let sources = try fileSystem.walk(root, includingDirectories: false) + .filter { $0.pathExtension == "swift" } + .sorted { $0.path < $1.path } + var parsedSources: [ParsedOwnershipSource] = [] + for source in sources { + let sourceText = try fileSystem.readText(source) + guard sourceText.contains("Copyable") || sourceText.contains("borrowing") else { + continue + } + let result = try runner.run( + swiftCompilerExecutable, + [ + "-frontend", + "-dump-parse", + "-D", + "SWIFT_PACKAGE", + source.path, + ], + workingDirectory: root + ) + parsedSources.append( + ParsedOwnershipSource( + source: sourceText, + declarations: SwiftParseDumpParser().parse(result.stdout) + ) + ) + } + records.formUnion( + SourceOwnershipExtractor().records(module: module, sources: parsedSources) + ) + } + + var lines = [ + "## Ownership Markers", + "", + "Swift symbol graphs omit `~Copyable` and accessor ownership. These", + "compiler-parsed source declarations supplement", + "the semantic symbol records above.", + "", + "```text", + ] + lines.append(contentsOf: records.sorted()) + lines.append(contentsOf: ["```", ""]) + return lines.joined(separator: "\n") + } +} + +private struct SwiftParseDumpParser { + func parse(_ dump: String) -> [ParsedSwiftDeclaration] { + var declarations: [ParsedSwiftDeclaration] = [] + var stack: [Int] = [] + + for line in dump.split(separator: "\n", omittingEmptySubsequences: false) { + let text = String(line) + let indentation = text.prefix { $0 == " " }.count + while let last = stack.last, declarations[last].indentation >= indentation { + stack.removeLast() + } + + guard let nodeKind = nodeKind(in: text) else { continue } + if nodeKind.hasSuffix("_decl") { + let declaration = ParsedSwiftDeclaration( + kind: nodeKind, + indentation: indentation, + name: firstQuotedValue(in: text), + sourceLocation: sourceLocation(in: text), + parent: stack.last + ) + declarations.append(declaration) + stack.append(declarations.index(before: declarations.endIndex)) + } else if nodeKind == "access_control_attr", text.contains("access_level=public") { + if let owner = stack.last { + declarations[owner].hasPublicAccess = true + } + } else if nodeKind == "borrowing_attr", let owner = stack.last { + declarations[owner].hasBorrowingAttribute = true + } + } + return declarations + } + + private func nodeKind(in line: String) -> String? { + let trimmed = line.drop { $0 == " " } + guard trimmed.first == "(" else { return nil } + let remainder = trimmed.dropFirst() + return String(remainder.prefix { !$0.isWhitespace && $0 != ")" }) + } + + private func firstQuotedValue(in line: String) -> String? { + guard let openingQuote = line.firstIndex(of: "\"") else { return nil } + var index = line.index(after: openingQuote) + var value = "" + var isEscaped = false + while index < line.endIndex { + let character = line[index] + if isEscaped { + value.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + return value + } else { + value.append(character) + } + index = line.index(after: index) + } + return nil + } + + private func sourceLocation(in line: String) -> SourceLocation? { + guard + let rangeStart = line.range(of: "range=["), + let rangeEnd = line[rangeStart.upperBound...].range(of: " - ") + else { return nil } + let location = line[rangeStart.upperBound..= 3, + let sourceLine = Int(components[components.count - 2]), + let sourceColumn = Int(components[components.count - 1]) + else { return nil } + return SourceLocation(line: sourceLine, column: sourceColumn) + } +} + +private struct SourceOwnershipExtractor { + func records( + module: String, + sources: [ParsedOwnershipSource] + ) -> Set { + var noncopyableTypes = Set() + for source in sources { + for (index, declaration) in source.declarations.enumerated() + where declaration.isNominalValueType + && isExternallyPublic(index, in: source.declarations) + && NoncopyableInheritanceParser(source: source.source) + .containsMarker(at: declaration.sourceLocation) + { + if let typeName = typeName(for: index, in: source.declarations) { + noncopyableTypes.insert(typeName) + } + } + } + + var records = Set(noncopyableTypes.map { "\(module).\($0)\t~Copyable" }) + for source in sources { + for (index, declaration) in source.declarations.enumerated() + where declaration.kind == "var_decl" + && isPublicMember(index, in: source.declarations) + && hasBorrowingGetter(index, in: source.declarations) + { + guard + let containingType = containingTypeName(for: index, in: source.declarations), + noncopyableTypes.contains(containingType), + let propertyName = declaration.name + else { continue } + records.insert( + "\(module).\(containingType).\(propertyName)\tborrowing get" + ) + } + } + return records + } + + private func hasBorrowingGetter( + _ property: Int, + in declarations: [ParsedSwiftDeclaration] + ) -> Bool { + declarations.indices.contains { index in + declarations[index].parent == property + && declarations[index].kind == "accessor_decl" + && declarations[index].hasBorrowingAttribute + } + } + + private func isExternallyPublic( + _ declaration: Int, + in declarations: [ParsedSwiftDeclaration] + ) -> Bool { + guard isPublicMember(declaration, in: declarations) else { return false } + var ancestor = declarations[declaration].parent + while let index = ancestor { + let node = declarations[index] + if node.isTypeDeclaration, !isPublicMember(index, in: declarations) { + return false + } + ancestor = node.parent + } + return true + } + + private func isPublicMember( + _ declaration: Int, + in declarations: [ParsedSwiftDeclaration] + ) -> Bool { + if declarations[declaration].hasPublicAccess { + return true + } + guard let parent = declarations[declaration].parent else { return false } + return declarations[parent].kind == "extension_decl" + && declarations[parent].hasPublicAccess + } + + private func typeName( + for declaration: Int, + in declarations: [ParsedSwiftDeclaration] + ) -> String? { + guard let name = declarations[declaration].name else { return nil } + guard let parent = containingTypeName(for: declaration, in: declarations) else { + return name + } + return "\(parent).\(name)" + } + + private func containingTypeName( + for declaration: Int, + in declarations: [ParsedSwiftDeclaration] + ) -> String? { + var components: [String] = [] + var ancestor = declarations[declaration].parent + while let index = ancestor { + let node = declarations[index] + if node.kind == "extension_decl", let name = node.name { + let normalized = name.prefix { $0 != "<" && !$0.isWhitespace } + components.insert(String(normalized), at: 0) + break + } + if node.isTypeDeclaration, let name = node.name { + components.insert(name, at: 0) + } + ancestor = node.parent + } + return components.isEmpty ? nil : components.joined(separator: ".") + } +} + +private struct NoncopyableInheritanceParser { + let source: String + + func containsMarker(at location: SourceLocation?) -> Bool { + guard let location else { return false } + let header = declarationHeader(startingAt: location) + var angleDepth = 0 + var inheritanceStart: String.Index? + var inheritanceEnd: String.Index? + var index = header.startIndex + + while index < header.endIndex { + let character = header[index] + if character == "<" { + angleDepth += 1 + index = header.index(after: index) + continue + } + if character == ">" { + angleDepth = max(0, angleDepth - 1) + index = header.index(after: index) + continue + } + if angleDepth == 0, character == ":", inheritanceStart == nil { + inheritanceStart = header.index(after: index) + index = header.index(after: index) + continue + } + if angleDepth == 0, character.isLetter || character == "_" { + let wordStart = index + repeat { + index = header.index(after: index) + } while index < header.endIndex + && (header[index].isLetter || header[index].isNumber || header[index] == "_") + if header[wordStart.. String { + let bytes = Array(source.utf8) + guard let start = utf8Offset(of: location, in: bytes) else { return "" } + var output: [UInt8] = [] + var index = start + var blockCommentDepth = 0 + var isLineComment = false + + while index < bytes.count { + let byte = bytes[index] + let next = index + 1 < bytes.count ? bytes[index + 1] : nil + if isLineComment { + if byte == 0x0A { + isLineComment = false + output.append(byte) + } + index += 1 + continue + } + if blockCommentDepth > 0 { + if byte == 0x2F, next == 0x2A { + blockCommentDepth += 1 + index += 2 + } else if byte == 0x2A, next == 0x2F { + blockCommentDepth -= 1 + index += 2 + } else { + index += 1 + } + continue + } + if byte == 0x2F, next == 0x2F { + isLineComment = true + index += 2 + continue + } + if byte == 0x2F, next == 0x2A { + output.append(0x20) + blockCommentDepth = 1 + index += 2 + continue + } + if byte == 0x7B { + break + } + output.append(byte) + index += 1 + } + return String(bytes: output, encoding: .utf8) ?? "" + } + + private func utf8Offset(of location: SourceLocation, in bytes: [UInt8]) -> Int? { + guard location.line > 0, location.column > 0 else { return nil } + var line = 1 + var offset = 0 + while line < location.line, offset < bytes.count { + if bytes[offset] == 0x0A { + line += 1 + } + offset += 1 + } + guard line == location.line else { return nil } + let result = offset + location.column - 1 + return result < bytes.count ? result : nil + } +} + +private struct ParsedOwnershipSource { + let source: String + let declarations: [ParsedSwiftDeclaration] +} + +private struct ParsedSwiftDeclaration { + let kind: String + let indentation: Int + let name: String? + let sourceLocation: SourceLocation? + let parent: Int? + var hasPublicAccess = false + var hasBorrowingAttribute = false + + var isNominalValueType: Bool { + kind == "struct_decl" || kind == "enum_decl" + } + + var isTypeDeclaration: Bool { + isNominalValueType || kind == "class_decl" || kind == "protocol_decl" + } +} + +private struct SourceLocation { + let line: Int + let column: Int +} diff --git a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift index 80763954..c8071c8e 100644 --- a/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift +++ b/Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift @@ -1835,13 +1835,6 @@ package actor WaylandGraphicsWindowBackingStorage { } } - package func cancelExternalBufferReservation( - _ buffer: WaylandGraphicsExternalBuffer, - leaseID: WaylandGraphicsFrameLeaseID - ) { - releaseExternalBufferReservation(bufferID: buffer.id, leaseID: leaseID) - } - private func releaseExternalBufferReservation( bufferID: WaylandGraphicsExternalBufferID, leaseID: WaylandGraphicsFrameLeaseID diff --git a/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift b/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift index 81fba85d..533ce665 100644 --- a/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift +++ b/Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift @@ -1361,7 +1361,7 @@ public enum WaylandGraphicsError: Error, Equatable, Sendable { // SAFETY: The armed state is protected by `lock`. Abandonment claims the // cleanup exactly once before scheduling work on the backing actor. @safe -final class WaylandGraphicsLeaseLifetime: @unchecked Sendable { +private final class WaylandGraphicsLeaseLifetime: @unchecked Sendable { private let lock = NSLock() private let leaseID: WaylandGraphicsFrameLeaseID private let storage: WaylandGraphicsWindowBackingStorage diff --git a/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/ExternalBufferSubmission.md b/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/ExternalBufferSubmission.md index b6beba34..ce39a78f 100644 --- a/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/ExternalBufferSubmission.md +++ b/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/ExternalBufferSubmission.md @@ -31,6 +31,13 @@ expose raw protocol or renderer objects, pointers, or descriptor integers. ``WaylandGraphicsExternalBufferSubmissionReceipt/waitForRelease()``. Reuse it only after `.released`. +Reservation consumes the move-only frame lease and transfers its sole authority +into a move-only ``WaylandGraphicsExternalBufferRenderLease``. The original frame +lease cannot be reused. Submit or cancel consumes the render lease; cancelling +releases both the buffer reservation and the complete frame permission. Failed +reservations, failed submissions, and abandoned leases release their authority +inside WCK so later frames and buffer reservations are not stranded. + Registration imports a descriptor once for reuse. It is scoped to the backing, window, frame generation, and selected configuration. If that scope changes during import, WCK removes the unpublished import and returns `backingClosed` or diff --git a/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/FrameLeases.md b/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/FrameLeases.md index 578d4cb0..6c15f244 100644 --- a/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/FrameLeases.md +++ b/Sources/WaylandGraphicsPreview/WaylandGraphicsPreview.docc/FrameLeases.md @@ -1,14 +1,15 @@ # Frame Leases -``WaylandGraphicsFrameLease`` is the single-use permission to submit one frame -through a ``WaylandGraphicsWindowBacking``. +``WaylandGraphicsFrameLease`` is the move-only, single-use permission to submit +one frame through a ``WaylandGraphicsWindowBacking``. ## Lifecycle Call ``WaylandGraphicsWindowBacking/nextFrame()`` to obtain a lease. Inspect ``WaylandGraphicsFrameLease/contract`` before rendering; it contains the current surface generation, authoritative geometry, buffer candidates, synchronization, -and render-device identity. +and render-device identity. Reading `size`, `contract`, or `runtimePath` borrows +the lease without consuming it. Submit the lease once with ``WaylandGraphicsFrameLease/submit(_:)``, ``WaylandGraphicsFrameLease/submitSoftware(metadata:_:)``, or by reserving a @@ -17,9 +18,17 @@ registered external buffer with returned render lease. Cancel the frame lease with ``WaylandGraphicsFrameLease/cancel()`` when no frame will be produced. -A backing allows only one active lease. A submitted or cancelled lease cannot be -submitted again. Closing the backing makes future lease operations fail with a -typed error. +Submitting, cancelling, or reserving an external buffer consumes the lease, so +valid Swift source cannot copy the permission or use it twice. Reserving an +external buffer transfers the only remaining frame authority into the returned +move-only render lease. Submitting or cancelling that render lease consumes it; +render-lease cancellation cancels the entire frame permission. + +A backing allows only one active lease. A consuming operation that throws +terminally releases its authority inside WCK because the caller no longer owns a +lease to cancel. Dropping an unfinished frame or render lease schedules the same +cleanup so it cannot strand `nextFrame()` or a buffer slot. Closing the backing +makes future lease operations fail with a typed error. ## Software, Clear, And External Frames @@ -33,11 +42,12 @@ import, commit, release tracking, and reuse gating. Use ``WaylandGraphicsFrameMetadata`` and ``WaylandGraphicsDamageRegion`` to describe optional metadata and logical damage. WaylandClientKit validates metadata -and damage before consuming the lease for commit work. +and damage before irreversible commit work. Validation failure still terminally +releases the consumed frame authority. -WaylandClientKit owns lease state, retry behavior after pre-commit failures, -post-commit terminal state, and buffer reuse. Frameworks own frame scheduling -and failure policy. +WaylandClientKit owns lease state, new-frame retry behavior after pre-commit +failures, post-commit terminal state, and buffer reuse. Frameworks own frame +scheduling and failure policy. A contract generation changes with surface geometry. Work for stale geometry belongs to a new frame rather than the current lease. diff --git a/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift b/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift index e9775134..70979abc 100644 --- a/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift +++ b/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift @@ -111,6 +111,48 @@ struct PublicAPIBaselineTests { } } + @Test + func capturesOwnershipMarkersOmittedBySymbolGraphs() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("waylandclientkit-api-ownership-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("FixtureLease.swift") + try """ + public struct CopyableFixture: Sendable { // ~Copyable + private let marker = "} borrowing get" + } + + public struct GenericConstraintFixture: Sendable + where Element: ~Copyable {} + + public struct CommentConstraint: Sendable/**/where T: ~Copyable {} + + // These braces and ownership words must not affect the baseline: } borrowing get + public struct FixtureLease: + ~ /* ownership { marker */ Copyable, + Sendable + { + private let marker = "}" + } + + public extension FixtureLease { + var value: Int { + borrowing get { 42 } + } + } + """.write(to: source, atomically: true, encoding: .utf8) + + let report = try SourceOwnershipAPIBaseline().render( + moduleSources: ["Fixture": root] + ) + + #expect(report.contains("Fixture.FixtureLease\t~Copyable")) + #expect(report.contains("Fixture.FixtureLease.value\tborrowing get")) + #expect(!report.contains("Fixture.CopyableFixture\t~Copyable")) + #expect(!report.contains("Fixture.GenericConstraintFixture\t~Copyable")) + #expect(!report.contains("Fixture.CommentConstraint\t~Copyable")) + } + @Test func ignoresDocComments() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/Tests/WaylandClientKitToolTests/PublicAPIOverloadBaselineTests.swift b/Tests/WaylandClientKitToolTests/PublicAPIOverloadBaselineTests.swift new file mode 100644 index 00000000..78593487 --- /dev/null +++ b/Tests/WaylandClientKitToolTests/PublicAPIOverloadBaselineTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@testable import WaylandClientKitToolSupport + +@Suite +struct PublicAPIOverloadBaselineTests { + @Test + func preservesCompleteSignaturesForOverloadedConsumingMethods() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("waylandclientkit-api-overload-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let graph = root.appendingPathComponent("Fixture.symbols.json") + let declarations = [ + ( + precise: "s:7Fixture5LeaseV6submityySiF", + path: "Lease.submit(_:)", + declaration: "consuming func submit(_ value: Int)" + ), + ( + precise: "s:7Fixture5LeaseV6submityySSF", + path: "Lease.submit(_:)", + declaration: "consuming func submit(_ value: String)" + ), + ] + let symbols: [[String: Any]] = declarations.map { declaration in + [ + "kind": ["identifier": "swift.method"], + "identifier": ["precise": declaration.precise], + "pathComponents": [declaration.path], + "declarationFragments": [["spelling": declaration.declaration]], + "accessLevel": "public", + ] + } + let graphData = try JSONSerialization.data( + withJSONObject: [ + "module": ["name": "Fixture"], + "symbols": symbols, + "relationships": [], + ], + options: [.sortedKeys] + ) + try graphData.write(to: graph) + + let report = try SemanticPublicAPIBaseline().render(symbolGraphs: [graph]) + + for declaration in declarations { + #expect(report.contains(declaration.path)) + #expect(report.contains(declaration.declaration)) + } + } +} diff --git a/Tests/WaylandGPUPreviewTests/GPUWindowPresenterStateTests.swift b/Tests/WaylandGPUPreviewTests/GPUWindowPresenterStateTests.swift index e5f2006a..14b911da 100644 --- a/Tests/WaylandGPUPreviewTests/GPUWindowPresenterStateTests.swift +++ b/Tests/WaylandGPUPreviewTests/GPUWindowPresenterStateTests.swift @@ -1829,12 +1829,9 @@ struct ManagedGPUPreviewStoragePreparationTests { managedGPUBacking: backing ) - _ = try await storage.nextFrame() + let lease = try await storage.nextFrame() await window.clearEvents() - let result = try await storage.submit( - leaseID: 1, - frame: .clearColor(.black) - ) + let result = try await lease.submit(.clearColor(.black)) #expect(await window.eventSnapshot() == [.preparePresentation, .geometry]) #expect(backing.submittedGeometries == [configuredGeometry]) @@ -1871,8 +1868,8 @@ struct ManagedGPUPreviewStoragePreparationTests { managedGPUBacking: backing ) - _ = try await storage.nextFrame() - _ = try await storage.submit(leaseID: 1, frame: .clearColor(.black)) + let firstLease = try await storage.nextFrame() + _ = try await firstLease.submit(.clearColor(.black)) await window.setConfiguredGeometry(leaseGeometry) await window.clearEvents() diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift index 3fa2af04..09bf4857 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsExternalBufferSubmissionTests.swift @@ -1610,7 +1610,9 @@ struct WaylandGraphicsExternalBufferLifecycleTests { _ = abandonedRenderLease.contract } - for _ in 0..<100 { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline { do { let replacementLease = try await storage.nextFrame() let replacementRenderLease = try await replacementLease.reserveExternalBuffer( diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsLeaseLifetimeTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsLeaseLifetimeTests.swift new file mode 100644 index 00000000..184c5f4d --- /dev/null +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsLeaseLifetimeTests.swift @@ -0,0 +1,102 @@ +import Testing +import WaylandGraphicsPreview + +@Suite +struct WaylandGraphicsLeaseLifetimeTests { + @Test(.timeLimit(.minutes(1))) + func abandoningFrameLeaseAllowsNextFrame() async throws { + let storage = try leaseLifetimeStorage() + + do { + let abandonedLease = try await storage.nextFrame() + _ = abandonedLease.size + _ = abandonedLease.contract + _ = abandonedLease.runtimePath + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(1)) + while clock.now < deadline { + do { + let replacementLease = try await storage.nextFrame() + await replacementLease.cancel() + await storage.closeForTesting() + return + } catch WaylandGraphicsError.frameLeaseActive { + await Task.yield() + } + } + + await storage.closeForTesting() + Issue.record("abandoned frame lease did not release its backing") + } + + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithSubmissionEndsClosed() async throws { + let storage = try leaseLifetimeStorage() + let lease = try await storage.nextFrame() + + do { + _ = try await lease.submitForTesting(.clearColor(.black)) { + await storage.closeForTesting() + } + Issue.record("expected backing close to win submission completion") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } + + await expectBackingClosed(storage) + } + + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithCancellationEndsClosed() async throws { + let storage = try leaseLifetimeStorage() + let lease = try await storage.nextFrame() + + async let closing: Void = storage.closeForTesting() + await lease.cancel() + await closing + + await expectBackingClosed(storage) + } + + @Test(.timeLimit(.minutes(1))) + func backingCloseRacingWithAbandonmentEndsClosed() async throws { + let storage = try leaseLifetimeStorage() + + do { + let abandonedLease = try await storage.nextFrame() + _ = abandonedLease.runtimePath + } + await storage.closeForTesting() + + await expectBackingClosed(storage) + } +} + +private func leaseLifetimeStorage() throws -> WaylandGraphicsWindowBackingStorage { + let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) + return WaylandGraphicsWindowBackingStorage( + window: window, + runtimePath: .softwareFallback( + capabilities: softwareOnlySurfaceCapabilities(), + reason: .forcedSoftware + ) + ) +} + +private func expectBackingClosed( + _ storage: WaylandGraphicsWindowBackingStorage +) async { + do { + let unexpectedLease = try await storage.nextFrame() + await unexpectedLease.cancel() + Issue.record("closed backing unexpectedly issued another lease") + } catch WaylandGraphicsError.backingClosed { + // Expected terminal state. + } catch { + Issue.record("unexpected error: \(error)") + } +} diff --git a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift index f0a5d13d..c9b72a12 100644 --- a/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift +++ b/Tests/WaylandGraphicsPreviewAPITests/WaylandGraphicsSubmissionFailureTests.swift @@ -250,128 +250,6 @@ struct WaylandGraphicsSubmissionFailureTests { #expect(await window.damages() == [nil]) } - @Test(.timeLimit(.minutes(1))) - func abandoningFrameLeaseAllowsNextFrame() async throws { - let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) - let storage = WaylandGraphicsWindowBackingStorage( - window: window, - runtimePath: .softwareFallback( - capabilities: softwareOnlySurfaceCapabilities(), - reason: .forcedSoftware - ) - ) - - do { - let abandonedLease = try await storage.nextFrame() - _ = abandonedLease.size - _ = abandonedLease.contract - _ = abandonedLease.runtimePath - } - - for _ in 0..<100 { - do { - let replacementLease = try await storage.nextFrame() - await replacementLease.cancel() - await storage.closeForTesting() - return - } catch WaylandGraphicsError.frameLeaseActive { - await Task.yield() - } - } - - await storage.closeForTesting() - Issue.record("abandoned frame lease did not release its backing") - } - - @Test(.timeLimit(.minutes(1))) - func backingCloseRacingWithSubmissionEndsClosed() async throws { - let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) - let storage = WaylandGraphicsWindowBackingStorage( - window: window, - runtimePath: .softwareFallback( - capabilities: softwareOnlySurfaceCapabilities(), - reason: .forcedSoftware - ) - ) - let lease = try await storage.nextFrame() - - do { - _ = try await lease.submitForTesting(.clearColor(.black)) { - await storage.closeForTesting() - } - Issue.record("expected backing close to win submission completion") - } catch WaylandGraphicsError.backingClosed { - // Expected terminal state. - } catch { - Issue.record("unexpected error: \(error)") - } - - do { - let unexpectedLease = try await storage.nextFrame() - await unexpectedLease.cancel() - Issue.record("closed backing unexpectedly issued another lease") - } catch WaylandGraphicsError.backingClosed { - // Expected terminal state. - } catch { - Issue.record("unexpected error: \(error)") - } - } - - @Test(.timeLimit(.minutes(1))) - func backingCloseRacingWithCancellationEndsClosed() async throws { - let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) - let storage = WaylandGraphicsWindowBackingStorage( - window: window, - runtimePath: .softwareFallback( - capabilities: softwareOnlySurfaceCapabilities(), - reason: .forcedSoftware - ) - ) - let lease = try await storage.nextFrame() - - async let closing: Void = storage.closeForTesting() - await lease.cancel() - await closing - - do { - let unexpectedLease = try await storage.nextFrame() - await unexpectedLease.cancel() - Issue.record("closed backing unexpectedly issued another lease") - } catch WaylandGraphicsError.backingClosed { - // Expected terminal state. - } catch { - Issue.record("unexpected error: \(error)") - } - } - - @Test(.timeLimit(.minutes(1))) - func backingCloseRacingWithAbandonmentEndsClosed() async throws { - let window = try FakeManagedGraphicsWindow(showDrawFailures: 0) - let storage = WaylandGraphicsWindowBackingStorage( - window: window, - runtimePath: .softwareFallback( - capabilities: softwareOnlySurfaceCapabilities(), - reason: .forcedSoftware - ) - ) - - do { - let abandonedLease = try await storage.nextFrame() - _ = abandonedLease.runtimePath - } - await storage.closeForTesting() - - do { - let unexpectedLease = try await storage.nextFrame() - await unexpectedLease.cancel() - Issue.record("closed backing unexpectedly issued another lease") - } catch WaylandGraphicsError.backingClosed { - // Expected terminal state. - } catch { - Issue.record("unexpected error: \(error)") - } - } - @Test func windowLifecycleAndWindowSubmissionFailuresAreDistinct() { let windowID = WindowID(rawValue: 45) @@ -492,7 +370,7 @@ private struct InjectedUnexpectedSubmissionError: Error, CustomStringConvertible } } -private actor FakeManagedGraphicsWindow: WaylandGraphicsManagedWindow { +actor FakeManagedGraphicsWindow: WaylandGraphicsManagedWindow { nonisolated let id = WindowID(rawValue: 700) private let geometryValue: SurfaceGeometry diff --git a/docs/documentation-symbol-coverage.json b/docs/documentation-symbol-coverage.json index f3459089..f08d0d86 100644 --- a/docs/documentation-symbol-coverage.json +++ b/docs/documentation-symbol-coverage.json @@ -6,7 +6,7 @@ }, "WaylandGraphicsPreview" : { "documented" : 32, - "eligible" : 244 + "eligible" : 243 } } } diff --git a/docs/public-api-audit.md b/docs/public-api-audit.md index b91c5f21..05515d06 100644 --- a/docs/public-api-audit.md +++ b/docs/public-api-audit.md @@ -391,6 +391,15 @@ Current preview contract: - Backing close is nonthrowing because it only joins deterministic resource retirement. External buffer planes use one initializer with a defaulted zero plane index. +- Frame and external-buffer render leases are move-only single-use authority. + Their inspection properties borrow; submit, cancel, and external reservation + operations consume. External reservation transfers the only frame authority + into the returned render lease. Failed terminal operations and abandoned + leases release their frame and buffer ownership inside WCK. +- `WaylandGraphicsError.frameLeaseActive` remains a public coordination error + for requesting another frame while a lease is unfinished. Duplicate or stale + lease use is no longer valid source, so the former public + `frameLeaseConsumed` case is now a package-internal state-machine invariant. - The managed preview submission path can create a window backing, lease a frame, attempt a package-internal GPU clear-frame path, fall back to software when policy allows, submit arbitrary software drawing, return a typed frame diff --git a/docs/public-api-baseline.md b/docs/public-api-baseline.md index 2e688d50..0ab03684 100644 --- a/docs/public-api-baseline.md +++ b/docs/public-api-baseline.md @@ -1,7 +1,8 @@ # WaylandClientKit Public API Baseline This baseline records compiler-emitted public symbols and relationships for -vended library products. Source locations and formatting are excluded, while +vended library products, plus compiler-checked ownership markers omitted by +Swift symbol graphs. Source locations and formatting are excluded, while continuation-line signature changes remain visible. Preview products are included so source-breaking preview API drift is reviewed. @@ -5907,7 +5908,6 @@ s:22WaylandGraphicsPreview0aB5ErrorO13backingClosedyA2CmF swift.enum.case Waylan s:22WaylandGraphicsPreview0aB5ErrorO25externalBufferUnavailableyAcA0ab8ExternalF2IDV_AA0abhF9LifecycleOtcACmF swift.enum.case WaylandGraphicsError.externalBufferUnavailable(id:state:) case externalBufferUnavailable(id: WaylandGraphicsExternalBufferID, state: WaylandGraphicsExternalBufferLifecycle) - s:22WaylandGraphicsPreview0aB5ErrorO16fallbackRequiredyAcA0aB6ReasonOcACmF swift.enum.case WaylandGraphicsError.fallbackRequired(_:) case fallbackRequired(WaylandGraphicsReason) - s:22WaylandGraphicsPreview0aB5ErrorO16frameLeaseActiveyA2CmF swift.enum.case WaylandGraphicsError.frameLeaseActive case frameLeaseActive - -s:22WaylandGraphicsPreview0aB5ErrorO18frameLeaseConsumedyA2CmF swift.enum.case WaylandGraphicsError.frameLeaseConsumed case frameLeaseConsumed - s:22WaylandGraphicsPreview0aB5ErrorO19invalidDamageRegionyA2CmF swift.enum.case WaylandGraphicsError.invalidDamageRegion case invalidDamageRegion - s:22WaylandGraphicsPreview0aB5ErrorO18staleFrameContractyAcA0aB17SurfaceGenerationV_AFtcACmF swift.enum.case WaylandGraphicsError.staleFrameContract(rendered:current:) case staleFrameContract(rendered: WaylandGraphicsSurfaceGeneration, current: WaylandGraphicsSurfaceGeneration) - s:22WaylandGraphicsPreview0aB5ErrorO16submissionFailedyAcA0aB17SubmissionFailureOcACmF swift.enum.case WaylandGraphicsError.submissionFailed(_:) case submissionFailed(WaylandGraphicsSubmissionFailure) - @@ -5962,11 +5962,11 @@ s:22WaylandGraphicsPreview0aB20ExternalBufferPlanesO3oneyAcA0abdE5PlaneVcACmF sw s:22WaylandGraphicsPreview0aB20ExternalBufferPlanesO5threeyAcA0abdE5PlaneV_A2FtcACmF swift.enum.case WaylandGraphicsExternalBufferPlanes.three(_:_:_:) case three(WaylandGraphicsExternalBufferPlane, WaylandGraphicsExternalBufferPlane, WaylandGraphicsExternalBufferPlane) - s:22WaylandGraphicsPreview0aB20ExternalBufferPlanesO3twoyAcA0abdE5PlaneV_AFtcACmF swift.enum.case WaylandGraphicsExternalBufferPlanes.two(_:_:) case two(WaylandGraphicsExternalBufferPlane, WaylandGraphicsExternalBufferPlane) - s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV swift.struct WaylandGraphicsExternalBufferRenderLease struct WaylandGraphicsExternalBufferRenderLease - -s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6bufferAA0abdE0Vvp swift.property WaylandGraphicsExternalBufferRenderLease.buffer let buffer: WaylandGraphicsExternalBuffer - -s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6cancelyyYaF swift.method WaylandGraphicsExternalBufferRenderLease.cancel() func cancel() async - -s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV8contractAA0aB13FrameContractVvp swift.property WaylandGraphicsExternalBufferRenderLease.contract let contract: WaylandGraphicsFrameContract - -s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6submit22acquireSynchronization8metadata8scheduleAA0abdE17SubmissionReceiptVAA0abd7AcquireJ0O_AA0aB13FrameMetadataVAA0abP8ScheduleVSgtYaKF swift.method WaylandGraphicsExternalBufferRenderLease.submit(acquireSynchronization:metadata:schedule:) @discardableResult func submit(acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt - -s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6submit8metadata8scheduleAA0abdE17SubmissionReceiptVAA0aB13FrameMetadataV_AA0abM8ScheduleVSgtYaKF swift.method WaylandGraphicsExternalBufferRenderLease.submit(metadata:schedule:) @discardableResult func submit(metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt - +s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6bufferAA0abdE0Vvp swift.property WaylandGraphicsExternalBufferRenderLease.buffer var buffer: WaylandGraphicsExternalBuffer { get } - +s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6cancelyyYaF swift.method WaylandGraphicsExternalBufferRenderLease.cancel() consuming func cancel() async - +s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV8contractAA0aB13FrameContractVvp swift.property WaylandGraphicsExternalBufferRenderLease.contract var contract: WaylandGraphicsFrameContract { get } - +s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6submit22acquireSynchronization8metadata8scheduleAA0abdE17SubmissionReceiptVAA0abd7AcquireJ0O_AA0aB13FrameMetadataVAA0abP8ScheduleVSgtYaKF swift.method WaylandGraphicsExternalBufferRenderLease.submit(acquireSynchronization:metadata:schedule:) @discardableResult consuming func submit(acquireSynchronization: WaylandGraphicsExternalAcquireSynchronization, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt - +s:22WaylandGraphicsPreview0aB25ExternalBufferRenderLeaseV6submit8metadata8scheduleAA0abdE17SubmissionReceiptVAA0aB13FrameMetadataV_AA0abM8ScheduleVSgtYaKF swift.method WaylandGraphicsExternalBufferRenderLease.submit(metadata:schedule:) @discardableResult consuming func submit(metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, schedule frameSchedule: WaylandGraphicsFrameSchedule? = nil) async throws -> WaylandGraphicsExternalBufferSubmissionReceipt - s:22WaylandGraphicsPreview0aB31ExternalBufferSubmissionReceiptV swift.struct WaylandGraphicsExternalBufferSubmissionReceipt struct WaylandGraphicsExternalBufferSubmissionReceipt - s:22WaylandGraphicsPreview0aB31ExternalBufferSubmissionReceiptV8bufferIDAA0abdeI0Vvp swift.property WaylandGraphicsExternalBufferSubmissionReceipt.bufferID let bufferID: WaylandGraphicsExternalBufferID - s:22WaylandGraphicsPreview0aB31ExternalBufferSubmissionReceiptV18contractGenerationAA0ab7SurfaceI0Vvp swift.property WaylandGraphicsExternalBufferSubmissionReceipt.contractGeneration let contractGeneration: WaylandGraphicsSurfaceGeneration - @@ -6031,15 +6031,15 @@ s:22WaylandGraphicsPreview0aB13FrameContractV34recommendedExternalConfigurationI s:22WaylandGraphicsPreview0aB13FrameContractV11runtimePathAA0ab7RuntimeG0Vvp swift.property WaylandGraphicsFrameContract.runtimePath let runtimePath: WaylandGraphicsRuntimePath - s:22WaylandGraphicsPreview0aB13FrameContractV15synchronizationAA0aB35ExternalSynchronizationAvailabilityOvp swift.property WaylandGraphicsFrameContract.synchronization let synchronization: WaylandGraphicsExternalSynchronizationAvailability - s:22WaylandGraphicsPreview0aB10FrameLeaseV swift.struct WaylandGraphicsFrameLease struct WaylandGraphicsFrameLease - -s:22WaylandGraphicsPreview0aB10FrameLeaseV6cancelyyYaF swift.method WaylandGraphicsFrameLease.cancel() func cancel() async - -s:22WaylandGraphicsPreview0aB10FrameLeaseV8contractAA0abD8ContractVvp swift.property WaylandGraphicsFrameLease.contract let contract: WaylandGraphicsFrameContract - -s:22WaylandGraphicsPreview0aB10FrameLeaseV21reserveExternalBufferyAA0abgh6RenderE0VAA0abgH0VYaKF swift.method WaylandGraphicsFrameLease.reserveExternalBuffer(_:) func reserveExternalBuffer(_ buffer: WaylandGraphicsExternalBuffer) async throws -> WaylandGraphicsExternalBufferRenderLease - -s:22WaylandGraphicsPreview0aB10FrameLeaseV11runtimePathAA0ab7RuntimeG0Vvp swift.property WaylandGraphicsFrameLease.runtimePath let runtimePath: WaylandGraphicsRuntimePath - -s:22WaylandGraphicsPreview0aB10FrameLeaseV4size0A6Client17PositivePixelSizeVvp swift.property WaylandGraphicsFrameLease.size let size: PositivePixelSize - -s:22WaylandGraphicsPreview0aB10FrameLeaseV6submityAA0abD6ResultVAA0ab9SubmittedD0OYaKF swift.method WaylandGraphicsFrameLease.submit(_:) @discardableResult func submit(_ frame: WaylandGraphicsSubmittedFrame) async throws -> WaylandGraphicsFrameResult - -s:22WaylandGraphicsPreview0aB10FrameLeaseV6submit_8scheduleAA0abD6ResultVAA0ab9SubmittedD0O_AA0abD8ScheduleVtYaKF swift.method WaylandGraphicsFrameLease.submit(_:schedule:) @discardableResult func submit(_ frame: WaylandGraphicsSubmittedFrame, schedule frameSchedule: WaylandGraphicsFrameSchedule) async throws -> WaylandGraphicsFrameResult - -s:22WaylandGraphicsPreview0aB10FrameLeaseV14submitSoftware8metadata_AA0abD6ResultVAA0abD8MetadataV_y0A6Client0gD0VYbKXEtYaKF swift.method WaylandGraphicsFrameLease.submitSoftware(metadata:_:) @discardableResult func submitSoftware(metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> WaylandGraphicsFrameResult - -s:22WaylandGraphicsPreview0aB10FrameLeaseV14submitSoftware8schedule8metadata_AA0abD6ResultVAA0abD8ScheduleV_AA0abD8MetadataVy0A6Client0gD0VYbKXEtYaKF swift.method WaylandGraphicsFrameLease.submitSoftware(schedule:metadata:_:) @discardableResult func submitSoftware(schedule frameSchedule: WaylandGraphicsFrameSchedule, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> WaylandGraphicsFrameResult - +s:22WaylandGraphicsPreview0aB10FrameLeaseV6cancelyyYaF swift.method WaylandGraphicsFrameLease.cancel() consuming func cancel() async - +s:22WaylandGraphicsPreview0aB10FrameLeaseV8contractAA0abD8ContractVvp swift.property WaylandGraphicsFrameLease.contract var contract: WaylandGraphicsFrameContract { get } - +s:22WaylandGraphicsPreview0aB10FrameLeaseV21reserveExternalBufferyAA0abgh6RenderE0VAA0abgH0VYaKF swift.method WaylandGraphicsFrameLease.reserveExternalBuffer(_:) consuming func reserveExternalBuffer(_ buffer: WaylandGraphicsExternalBuffer) async throws -> WaylandGraphicsExternalBufferRenderLease - +s:22WaylandGraphicsPreview0aB10FrameLeaseV11runtimePathAA0ab7RuntimeG0Vvp swift.property WaylandGraphicsFrameLease.runtimePath var runtimePath: WaylandGraphicsRuntimePath { get } - +s:22WaylandGraphicsPreview0aB10FrameLeaseV4size0A6Client17PositivePixelSizeVvp swift.property WaylandGraphicsFrameLease.size var size: PositivePixelSize { get } - +s:22WaylandGraphicsPreview0aB10FrameLeaseV6submityAA0abD6ResultVAA0ab9SubmittedD0OYaKF swift.method WaylandGraphicsFrameLease.submit(_:) @discardableResult consuming func submit(_ frame: WaylandGraphicsSubmittedFrame) async throws -> WaylandGraphicsFrameResult - +s:22WaylandGraphicsPreview0aB10FrameLeaseV6submit_8scheduleAA0abD6ResultVAA0ab9SubmittedD0O_AA0abD8ScheduleVtYaKF swift.method WaylandGraphicsFrameLease.submit(_:schedule:) @discardableResult consuming func submit(_ frame: WaylandGraphicsSubmittedFrame, schedule frameSchedule: WaylandGraphicsFrameSchedule) async throws -> WaylandGraphicsFrameResult - +s:22WaylandGraphicsPreview0aB10FrameLeaseV14submitSoftware8metadata_AA0abD6ResultVAA0abD8MetadataV_y0A6Client0gD0VYbKXEtYaKF swift.method WaylandGraphicsFrameLease.submitSoftware(metadata:_:) @discardableResult consuming func submitSoftware(metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> WaylandGraphicsFrameResult - +s:22WaylandGraphicsPreview0aB10FrameLeaseV14submitSoftware8schedule8metadata_AA0abD6ResultVAA0abD8ScheduleV_AA0abD8MetadataVy0A6Client0gD0VYbKXEtYaKF swift.method WaylandGraphicsFrameLease.submitSoftware(schedule:metadata:_:) @discardableResult consuming func submitSoftware(schedule frameSchedule: WaylandGraphicsFrameSchedule, metadata frameMetadata: WaylandGraphicsFrameMetadata = .default, _ draw: sending @Sendable (borrowing SoftwareFrame) throws -> Void) async throws -> WaylandGraphicsFrameResult - s:22WaylandGraphicsPreview0aB13FrameMetadataV swift.struct WaylandGraphicsFrameMetadata struct WaylandGraphicsFrameMetadata - s:22WaylandGraphicsPreview0aB13FrameMetadataV5alphaAA0aB13AlphaModifierVSgvp swift.property WaylandGraphicsFrameMetadata.alpha var alpha: WaylandGraphicsAlphaModifier? - s:22WaylandGraphicsPreview0aB13FrameMetadataV19colorRepresentationAA0ab5ColorG0VSgvp swift.property WaylandGraphicsFrameMetadata.colorRepresentation var colorRepresentation: WaylandGraphicsColorRepresentation? - @@ -6712,7 +6712,6 @@ s:22WaylandGraphicsPreview0aB5ErrorO16fallbackRequiredyAcA0aB6ReasonOcACmF membe s:22WaylandGraphicsPreview0aB5ErrorO16frameLeaseActiveyA2CmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - s:22WaylandGraphicsPreview0aB5ErrorO16submissionFailedyAcA0aB17SubmissionFailureOcACmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - s:22WaylandGraphicsPreview0aB5ErrorO17unsupportedPacingyA2CmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - -s:22WaylandGraphicsPreview0aB5ErrorO18frameLeaseConsumedyA2CmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - s:22WaylandGraphicsPreview0aB5ErrorO18staleFrameContractyAcA0aB17SurfaceGenerationV_AFtcACmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - s:22WaylandGraphicsPreview0aB5ErrorO19invalidDamageRegionyA2CmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - s:22WaylandGraphicsPreview0aB5ErrorO19unsupportedMetadatayA2CmF memberOf s:22WaylandGraphicsPreview0aB5ErrorO - @@ -6769,3 +6768,25 @@ s:22WaylandGraphicsPreview0aB9XRGBColorV4blues5UInt8Vvp memberOf s:22WaylandGrap s:22WaylandGraphicsPreview0aB9XRGBColorV5blackACvpZ memberOf s:22WaylandGraphicsPreview0aB9XRGBColorV - s:22WaylandGraphicsPreview0aB9XRGBColorV5greens5UInt8Vvp memberOf s:22WaylandGraphicsPreview0aB9XRGBColorV - ``` + +## Ownership Markers + +Swift symbol graphs omit `~Copyable` and accessor ownership. These +compiler-parsed source declarations supplement +the semantic symbol records above. + +```text +WaylandClient.OwnedFileDescriptor ~Copyable +WaylandClient.SoftwareFrame ~Copyable +WaylandClient.SoftwareFrameBuffer ~Copyable +WaylandGraphicsPreview.WaylandGraphicsExternalBufferDescriptor ~Copyable +WaylandGraphicsPreview.WaylandGraphicsExternalBufferPlane ~Copyable +WaylandGraphicsPreview.WaylandGraphicsExternalBufferPlanes ~Copyable +WaylandGraphicsPreview.WaylandGraphicsExternalBufferRenderLease ~Copyable +WaylandGraphicsPreview.WaylandGraphicsExternalBufferRenderLease.buffer borrowing get +WaylandGraphicsPreview.WaylandGraphicsExternalBufferRenderLease.contract borrowing get +WaylandGraphicsPreview.WaylandGraphicsFrameLease ~Copyable +WaylandGraphicsPreview.WaylandGraphicsFrameLease.contract borrowing get +WaylandGraphicsPreview.WaylandGraphicsFrameLease.runtimePath borrowing get +WaylandGraphicsPreview.WaylandGraphicsFrameLease.size borrowing get +``` From f06acf197bb7fb5a2cef2404077739591011cc43 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:33:24 -0400 Subject: [PATCH 4/5] fix: address graphics lease review feedback --- .../ProjectAutomation.swift | 5 ++++- .../SwiftToolchain.swift | 13 +++++++++++++ .../ToolSupportTests.swift | 11 +++++++++++ docs/release.md | 9 ++++++++- docs/tooling.md | 2 +- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift index affb2cc2..1ce36440 100644 --- a/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift +++ b/Sources/WaylandClientKitToolSupport/ProjectAutomation.swift @@ -934,7 +934,10 @@ public struct PublicAPIAuditor { ) let ownershipReport = try SourceOwnershipAPIBaseline( fileSystem: context.fileSystem, - runner: context.runner + runner: context.runner, + swiftCompilerExecutable: context.swift.swiftCompilerExecutable( + environment: context.runner.environment + ) ).render( moduleSources: Dictionary( uniqueKeysWithValues: DocCVerifier.publicProducts.map { product in diff --git a/Sources/WaylandClientKitToolSupport/SwiftToolchain.swift b/Sources/WaylandClientKitToolSupport/SwiftToolchain.swift index d1330846..932b9aaa 100644 --- a/Sources/WaylandClientKitToolSupport/SwiftToolchain.swift +++ b/Sources/WaylandClientKitToolSupport/SwiftToolchain.swift @@ -37,6 +37,19 @@ public struct SwiftToolchain: Sendable { return "swift" } + public func swiftCompilerExecutable( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> String { + let selectedSwift = try swiftExecutable(environment: environment) + let resolvedSwift = + if selectedSwift.contains("/") { + URL(fileURLWithPath: selectedSwift) + } else { + try runner.executableURL(for: selectedSwift) + } + return resolvedSwift.deletingLastPathComponent().appendingPathComponent("swiftc").path + } + @discardableResult public func runSwift( _ arguments: [String], diff --git a/Tests/WaylandClientKitToolTests/ToolSupportTests.swift b/Tests/WaylandClientKitToolTests/ToolSupportTests.swift index bd398bb4..c6f3b72f 100644 --- a/Tests/WaylandClientKitToolTests/ToolSupportTests.swift +++ b/Tests/WaylandClientKitToolTests/ToolSupportTests.swift @@ -69,6 +69,17 @@ struct ToolSupportTests { #expect(toolchain.swiftPMBuildRoot(repository: Repository(root: root)).path == scratch.path) } + @Test + func swiftCompilerUsesSelectedToolchain() throws { + let selectedSwift = "/opt/swift-6.3/usr/bin/swift" + + #expect( + try SwiftToolchain().swiftCompilerExecutable( + environment: ["SWIFT_BIN": selectedSwift] + ) == "/opt/swift-6.3/usr/bin/swiftc" + ) + } + @Test func processRunnerRestoresCurrentDirectoryAfterWorkingDirectoryRun() throws { try withToolProcessFixtureLock { diff --git a/docs/release.md b/docs/release.md index 35ab0a50..8ebf2982 100644 --- a/docs/release.md +++ b/docs/release.md @@ -8,7 +8,8 @@ Review [Compatibility Policy](compatibility-policy.md) for public API changes an ## Current Checkpoint Migration -This checkpoint contains source-breaking `WaylandClient` changes: +This checkpoint contains source-breaking `WaylandClient` and +`WaylandGraphicsPreview` changes: - Replace `EventStreamConfiguration.displayEventCapacity` and `PositiveInt.defaultDisplayEventCapacity` with `eventCapacity` and @@ -37,6 +38,12 @@ This checkpoint contains source-breaking `WaylandClient` changes: 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. +- `WaylandGraphicsFrameLease` and `WaylandGraphicsExternalBufferRenderLease` are + now move-only. Submission and cancellation consume the lease, and reserving an + external buffer consumes the frame lease and transfers sole authority to the + returned render lease. Snapshot metadata before a consuming call, acquire a + new frame after a throwing terminal operation, and remove handling for the + deleted `WaylandGraphicsError.frameLeaseConsumed` case. ## Required Gates diff --git a/docs/tooling.md b/docs/tooling.md index ad2cb058..4ea9c7f3 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -42,7 +42,7 @@ tool-only targets. | Gate | Checks | | --- | --- | | `swift run wck ci cheap` | Format, lint, generated files, manifests, shims, dependency and import boundaries, identity declarations, and unsafe tokens. | -| `swift run wck ci required` | Public API and documentation baselines, strict build, unit tests, integration packages, and the expected-failure graphics policy client. | +| `swift run wck ci required` | Public API and documentation baselines, strict build, unit tests, integration packages, the expected-failure graphics policy client, and invalid graphics-lease clients for duplicate consumption and post-transfer reuse. | | `swift run wck ci check` | Cheap and required gates plus Markdown and DocC verification. | | `swift run wck ci release` | Check gate plus release builds, release tests, freshness checks, sanitizers where configured, and an available live or headless Wayland path. | From 7dc340ec0714accb63900b844ed2304fe585d834 Mon Sep 17 00:00:00 2001 From: conner <83147518+conxlgtm@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:07:18 -0400 Subject: [PATCH 5/5] fix: close remaining graphics lease review gaps --- .../SourceOwnershipAPIBaseline.swift | 34 +++++++---- .../PublicAPIBaselineTests.swift | 42 ------------- .../PublicAPIOwnershipBaselineTests.swift | 60 +++++++++++++++++++ docs/strict-memory-safety-audit.md | 32 ++++++++++ safety/unsafe-token-allowlist.tsv | 1 + 5 files changed, 117 insertions(+), 52 deletions(-) create mode 100644 Tests/WaylandClientKitToolTests/PublicAPIOwnershipBaselineTests.swift diff --git a/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift b/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift index 2e2ecbfc..7844c706 100644 --- a/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift +++ b/Sources/WaylandClientKitToolSupport/SourceOwnershipAPIBaseline.swift @@ -31,7 +31,11 @@ public struct SourceOwnershipAPIBaseline { var parsedSources: [ParsedOwnershipSource] = [] for source in sources { let sourceText = try fileSystem.readText(source) - guard sourceText.contains("Copyable") || sourceText.contains("borrowing") else { + guard + sourceText.contains("Copyable") + || sourceText.contains("borrowing") + || sourceText.contains("consuming") + else { continue } let result = try runner.run( @@ -101,6 +105,8 @@ private struct SwiftParseDumpParser { } } else if nodeKind == "borrowing_attr", let owner = stack.last { declarations[owner].hasBorrowingAttribute = true + } else if nodeKind == "consuming_attr", let owner = stack.last { + declarations[owner].hasConsumingAttribute = true } } return declarations @@ -175,30 +181,37 @@ private struct SourceOwnershipExtractor { for (index, declaration) in source.declarations.enumerated() where declaration.kind == "var_decl" && isPublicMember(index, in: source.declarations) - && hasBorrowingGetter(index, in: source.declarations) { guard let containingType = containingTypeName(for: index, in: source.declarations), noncopyableTypes.contains(containingType), - let propertyName = declaration.name + let propertyName = declaration.name, + let getterOwnership = getterOwnership(index, in: source.declarations) else { continue } records.insert( - "\(module).\(containingType).\(propertyName)\tborrowing get" + "\(module).\(containingType).\(propertyName)\t\(getterOwnership) get" ) } } return records } - private func hasBorrowingGetter( + private func getterOwnership( _ property: Int, in declarations: [ParsedSwiftDeclaration] - ) -> Bool { - declarations.indices.contains { index in - declarations[index].parent == property - && declarations[index].kind == "accessor_decl" - && declarations[index].hasBorrowingAttribute + ) -> String? { + for index in declarations.indices + where declarations[index].parent == property + && declarations[index].kind == "accessor_decl" + { + if declarations[index].hasBorrowingAttribute { + return "borrowing" + } + if declarations[index].hasConsumingAttribute { + return "consuming" + } } + return nil } private func isExternallyPublic( @@ -393,6 +406,7 @@ private struct ParsedSwiftDeclaration { let parent: Int? var hasPublicAccess = false var hasBorrowingAttribute = false + var hasConsumingAttribute = false var isNominalValueType: Bool { kind == "struct_decl" || kind == "enum_decl" diff --git a/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift b/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift index 70979abc..e9775134 100644 --- a/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift +++ b/Tests/WaylandClientKitToolTests/PublicAPIBaselineTests.swift @@ -111,48 +111,6 @@ struct PublicAPIBaselineTests { } } - @Test - func capturesOwnershipMarkersOmittedBySymbolGraphs() throws { - let root = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("waylandclientkit-api-ownership-tests-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - let source = root.appendingPathComponent("FixtureLease.swift") - try """ - public struct CopyableFixture: Sendable { // ~Copyable - private let marker = "} borrowing get" - } - - public struct GenericConstraintFixture: Sendable - where Element: ~Copyable {} - - public struct CommentConstraint: Sendable/**/where T: ~Copyable {} - - // These braces and ownership words must not affect the baseline: } borrowing get - public struct FixtureLease: - ~ /* ownership { marker */ Copyable, - Sendable - { - private let marker = "}" - } - - public extension FixtureLease { - var value: Int { - borrowing get { 42 } - } - } - """.write(to: source, atomically: true, encoding: .utf8) - - let report = try SourceOwnershipAPIBaseline().render( - moduleSources: ["Fixture": root] - ) - - #expect(report.contains("Fixture.FixtureLease\t~Copyable")) - #expect(report.contains("Fixture.FixtureLease.value\tborrowing get")) - #expect(!report.contains("Fixture.CopyableFixture\t~Copyable")) - #expect(!report.contains("Fixture.GenericConstraintFixture\t~Copyable")) - #expect(!report.contains("Fixture.CommentConstraint\t~Copyable")) - } - @Test func ignoresDocComments() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/Tests/WaylandClientKitToolTests/PublicAPIOwnershipBaselineTests.swift b/Tests/WaylandClientKitToolTests/PublicAPIOwnershipBaselineTests.swift new file mode 100644 index 00000000..b7f951ff --- /dev/null +++ b/Tests/WaylandClientKitToolTests/PublicAPIOwnershipBaselineTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +import WaylandClientKitToolSupport + +@Suite +struct PublicAPIOwnershipBaselineTests { + @Test + func capturesOwnershipMarkersOmittedBySymbolGraphs() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("waylandclientkit-api-ownership-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("FixtureLease.swift") + try """ + public struct CopyableFixture: Sendable { // ~Copyable + private let marker = "} borrowing get" + } + + public struct GenericConstraintFixture: Sendable + where Element: ~Copyable {} + + public struct CommentConstraint: Sendable/**/where T: ~Copyable {} + + // These braces and ownership words must not affect the baseline: } borrowing get + public struct FixtureLease: + ~ /* ownership { marker */ Copyable, + Sendable + { + private let marker = "}" + } + + public extension FixtureLease { + var value: Int { + borrowing get { 42 } + } + } + """.write(to: source, atomically: true, encoding: .utf8) + try """ + extension FixtureLease { + public var consumedValue: Int { + consuming get { 42 } + } + } + """.write( + to: root.appendingPathComponent("ConsumingGetter.swift"), + atomically: true, + encoding: .utf8 + ) + + let report = try SourceOwnershipAPIBaseline().render( + moduleSources: ["Fixture": root] + ) + + #expect(report.contains("Fixture.FixtureLease\t~Copyable")) + #expect(report.contains("Fixture.FixtureLease.value\tborrowing get")) + #expect(report.contains("Fixture.FixtureLease.consumedValue\tconsuming get")) + #expect(!report.contains("Fixture.CopyableFixture\t~Copyable")) + #expect(!report.contains("Fixture.GenericConstraintFixture\t~Copyable")) + #expect(!report.contains("Fixture.CommentConstraint\t~Copyable")) + } +} diff --git a/docs/strict-memory-safety-audit.md b/docs/strict-memory-safety-audit.md index a368fe81..3e99cee4 100644 --- a/docs/strict-memory-safety-audit.md +++ b/docs/strict-memory-safety-audit.md @@ -17,6 +17,7 @@ below. | `DataTransferSourceWriter` | One writer state and worker thread | Its condition-protected state may cross the writer thread boundary | Each queued write owns its data and descriptor until completion or cancellation | Draining or cancellation removes the queued request and closes the descriptor once | Shutdown wakes and joins the worker before state is released | | Raw dma-buf, GBM, and EGL wrappers | The importing graphics backing or render target | Setup and rendering paths with their documented owner-thread or lock boundary | Owned descriptors, proxies, and graphics objects stay in their wrapper until transfer | Move-only descriptor values transfer ownership into import. Destroy or retirement invalidates the wrapper | Wrapper teardown releases the native object when normal retirement was missed | | External-buffer release and presentation registries | The graphics backing storage | Registry locks protect lookup state. Completion happens after unlocking | A registry entry retains its completion cell until a terminal result | Release, failure, or backing close removes the entry before completing waiters | Backing close completes every remaining receipt and cancels its monitor task | +| `WaylandGraphicsLeaseLifetime` | One active frame or external-render permission | A move-only lease may cross executors; cleanup runs on the backing actor | `NSLock` protects the one-shot armed state, while lease ID and actor storage are immutable | A terminal operation disarms the token before awaiting. External reservation creates a new token for the transferred authority | Lease or token `deinit` atomically claims abandonment and schedules actor cancellation after unlocking | ## Shared Memory and Borrowed Buffers @@ -366,6 +367,37 @@ Tests: `DataTransferSourceWriterSourceCancellationTests` cover source-side write job descriptor release and cancellation behavior. +## Graphics Lease Lifetime Boundary + +Remaining unsafe constructs: + +- `WaylandGraphicsLeaseLifetime` is a private `@unchecked Sendable` reference + retained by each move-only frame or external-buffer render lease. It carries + an immutable lease ID and backing actor reference plus one lock-protected + armed bit so abandonment can cross executor boundaries. + +Audit invariant: + +- `isArmed` is read or changed only while holding the private `NSLock`. + `disarm()` and `abandon()` therefore choose at most one cleanup owner. +- Terminal consuming operations disarm before their first suspension. The + library explicitly cancels actor state if a terminal operation throws because + the caller can no longer recover the consumed lease. +- External-buffer reservation disarms the frame token and returns a render lease + with a new token holding the sole remaining frame authority. +- Abandonment captures only the immutable lease ID and backing actor after the + lock is released, then schedules cancellation on that actor. No lock is held + across asynchronous work. + +Tests: + +- `WaylandGraphicsLeaseLifetimeTests` covers frame-lease abandonment plus + submission, cancellation, and abandonment races with backing close. +- `WaylandGraphicsExternalBufferSubmissionTests` covers render-lease + cancellation and abandonment, authority transfer, and terminal external-buffer + cleanup. `WaylandGraphicsSubmissionFailureTests` covers terminal frame failure + cleanup and acquisition of replacement authority. + ## linux-dmabuf Boundary Remaining unsafe constructs: diff --git a/safety/unsafe-token-allowlist.tsv b/safety/unsafe-token-allowlist.tsv index 679e6547..f46d7ea8 100644 --- a/safety/unsafe-token-allowlist.tsv +++ b/safety/unsafe-token-allowlist.tsv @@ -28,6 +28,7 @@ Sources/WaylandGraphicsCore/Internal/GBM/GBMBuffer.swift @unchecked Sendable Dma Sources/WaylandGraphicsCore/Internal/GBM/DRMRenderNodeSelection.swift UnsafeBufferPointer Borrow dmabuf device bytes and C string storage for libdrm render-node lookup. Sources/WaylandGraphicsCore/Internal/GBM/DRMRenderNodeSelection.swift UnsafeMutableBufferPointer Borrow render-node path storage for libdrm render-node lookup. Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift @unchecked Sendable External-buffer import plan owns plane fds during package-internal dmabuf import handoff without exposing raw handles publicly. +Sources/WaylandGraphicsPreview/Public/WaylandGraphicsSubmission.swift @unchecked Sendable Private NSLock-protected lease lifetime token crosses executors and schedules one-shot actor cleanup only after releasing the lock. Sources/WaylandGraphicsPreview/Internal/WaylandGraphicsSubmissionStorage.swift @unchecked Sendable Private lock-protected external-buffer release registry and timeline monitor bridge compositor release callbacks to public receipts. Sources/WaylandGPUPreview/GPUWindowPresenter.swift @unchecked Sendable Private lock-protected presenter state crosses the window owner-thread callback boundary while preserving buffer lifetime behind presenter methods. Sources/WaylandGraphicsCore/Internal/EGL/** UnsafeMutableRawPointer EGL preview display, context, and surface lifetime wrappers.