diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 787a47e5c5d..0562e2eab96 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -257,6 +257,10 @@ pub fn prepareDeinit(self: *Metal) void { /// Called after the swap chain had a bounded opportunity to drain. From this /// point, a late GPU callback must not touch renderer or target state. pub fn finishFrameGeneration(self: *Metal) void { + switch (self.presenter) { + .layer => |*layer| layer.advancePresentationGeneration(), + .external, .external_leased => {}, + } self.completion_generation.finish(); } @@ -325,7 +329,7 @@ pub fn initShaders( } /// Get the current size of the runtime surface. -pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { +pub fn surfaceSize(self: *Metal) !struct { width: u32, height: u32 } { const size: struct { width: u32, height: u32 } = switch (self.presenter) { .layer => |layer| layer_size: { const bounds = layer.layer.getProperty(graphics.Rect, "bounds"); @@ -351,13 +355,15 @@ pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { }, }; - // We need to clamp our runtime surface size to the maximum - // possible texture size since we can't create a screen buffer (texture) - // larger than that. - return .{ - .width = @min(size.width, self.max_texture_size), - .height = @min(size.height, self.max_texture_size), - }; + // Clamp before observing the generation so the validation dimensions are + // exactly the dimensions used to allocate the submitted render target. + const width = @min(size.width, self.max_texture_size); + const height = @min(size.height, self.max_texture_size); + switch (self.presenter) { + .layer => |*layer| _ = layer.observePresentationSize(width, height), + .external, .external_leased => {}, + } + return .{ .width = width, .height = height }; } /// Initialize a new render target which can be presented by this API. @@ -386,13 +392,20 @@ pub inline fn present( sync: bool, frame_token: rendererpkg.frame_lease.Token, host_context: u64, + presentation_generation: u64, ) !bool { switch (self.presenter) { .layer => |*layer| { if (sync) { - layer.setSurfaceSync(target.surface); + _ = layer.setSurfaceSync( + target.surface, + presentation_generation, + ); } else { - try layer.setSurface(target.surface); + try layer.setSurface( + target.surface, + presentation_generation, + ); } return false; }, @@ -447,35 +460,38 @@ pub fn preparePresentation( self: *Metal, target: Target, presentation: rendererpkg.FramePresentation, + presentation_generation: u64, ) PreparedPresentation { return switch (self.presenter) { .layer => |*layer| layer.prepareSurfaceWithPresentation( target.surface, + presentation_generation, presentation, ), .external, .external_leased => unreachable, }; } -/// Present one explicitly tokened frame. iOS acknowledges only after the -/// exact IOSurface passes the main-thread layer size guard and is assigned. +/// Present one explicitly tokened synchronous frame. The caller delivers the +/// returned callback only after the generic renderer has left `draw_mutex`. +/// iOS never enters this path: its synchronous request is deliberately armed +/// as an asynchronous completion and keeps the established queued behavior. pub inline fn presentWithPresentation( self: *Metal, target: Target, - sync: bool, + presentation_generation: u64, presentation: rendererpkg.FramePresentation, -) !void { - // A tokened render may be submitted from any embedder-owned queue. Layer - // assignment and acknowledgement must therefore use the main-thread path - // even when the GPU frame itself completed synchronously. - _ = sync; - switch (self.presenter) { - .layer => |*layer| try layer.setSurfaceWithPresentation( +) !?rendererpkg.FramePresentation { + return switch (self.presenter) { + .layer => |*layer| if (layer.setSurfaceSync( target.surface, - presentation, - ), - .external, .external_leased => return error.UnsupportedPresentation, - } + presentation_generation, + )) + presentation + else + null, + .external, .external_leased => error.UnsupportedPresentation, + }; } /// Present the last presented target again. (noop for Metal) @@ -630,17 +646,22 @@ pub inline fn beginFrame( // `loopEnter` normally binds first, but an embedder-owned synchronous // render can race renderer-thread startup. Binding is idempotent. self.completion_generation.bind(renderer); - var gated = presentation; - if (gated) |*value| { + const completion_lifetime = self.completion_generation.lifetime(); + const presentation_generation: u64 = switch (self.presenter) { + .layer => |*layer| layer.presentationGeneration(), + .external, .external_leased => 0, + }; + var gated_presentation = presentation; + if (gated_presentation) |*value| { value.delivery_gate = &waitForDrawCriticalSection; value.delivery_gate_userdata = renderer; } return try Frame.begin(.{ .queue = self.queue, - .completion_lifetime = self.completion_generation.lifetime(), - }, target, frame_token, host_context, gated); + .completion_lifetime = completion_lifetime, + .presentation_generation = presentation_generation, + }, target, frame_token, host_context, gated_presentation); } - fn waitForDrawCriticalSection(userdata: ?*anyopaque) callconv(.c) void { const renderer: *Renderer = @ptrCast(@alignCast(userdata.?)); renderer.draw_mutex.lock(); diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig index bb88e28e428..ce30012d2eb 100644 --- a/src/renderer/metal/Frame.zig +++ b/src/renderer/metal/Frame.zig @@ -24,6 +24,9 @@ pub const Options = struct { /// Ref-counted renderer access gate for asynchronous completion. completion_lifetime: *Metal.RendererCompletionLifetime, + + /// Monotonic layer lifetime observed when this frame was submitted. + presentation_generation: u64, }; /// MTLCommandBuffer @@ -51,6 +54,7 @@ pub fn begin( const block = CompletionBlock.init( .{ .completion_lifetime = opts.completion_lifetime, + .presentation_generation = opts.presentation_generation, .target = target, .sync = false, .frame_token = frame_token, @@ -70,6 +74,7 @@ pub fn begin( /// This is the block type used for the addCompletedHandler callback. const CompletionBlock = objc.Block(struct { completion_lifetime: *Metal.RendererCompletionLifetime, + presentation_generation: u64, target: *Target, sync: bool, frame_token: FrameToken, @@ -87,13 +92,24 @@ fn bufferCompleted( block: *const CompletionBlock.Context, buffer_id: objc.c.id, ) callconv(.c) void { + // Asynchronous and iOS completions always queue tokened presentation. + // Keep the fallback defensive so any future backend mode still consumes + // the exact delivery ownership. + if (bufferCompletedImpl(block, buffer_id)) |presentation| { + presentation.deliver(); + } +} + +fn bufferCompletedImpl( + block: *const CompletionBlock.Context, + buffer_id: objc.c.id, +) ?FramePresentation { // This reference was acquired immediately before the handler was armed. // It keeps only the gate alive, not renderer or target state. defer block.completion_lifetime.release(); // Teardown invalidates this gate before freeing renderer-owned memory. - // Do not even inspect the raw target pointer until a live lease exists. - var live = block.completion_lifetime.acquire() orelse return; + var live = block.completion_lifetime.acquire() orelse return null; defer live.deinit(); const renderer = live.context; @@ -106,25 +122,17 @@ fn bufferCompleted( else => .healthy, }; - // If the frame is healthy, present it. Tokened frames first detach their - // rendered target so the reusable slot cannot overwrite the pixels queued - // for main-thread layer assignment. + const presentation = presentationFromBlock(block); if (health == .healthy) { - completeHealthyFrame( + return completeHealthyFrame( renderer, block.target, block.sync, block.frame_token, block.host_context, - if (block.presentation_callback) |callback| .{ - .callback = callback, - .userdata = block.presentation_userdata, - .token = block.presentation_token, - .delivery_gate = block.presentation_delivery_gate, - .delivery_gate_userdata = block.presentation_delivery_gate_userdata, - } else null, + block.presentation_generation, + presentation, ); - return; } renderer.frameCompleted( @@ -133,6 +141,19 @@ fn bufferCompleted( block.frame_token, false, ); + return null; +} + +fn presentationFromBlock( + block: *const CompletionBlock.Context, +) ?FramePresentation { + return if (block.presentation_callback) |callback| .{ + .callback = callback, + .userdata = block.presentation_userdata, + .token = block.presentation_token, + .delivery_gate = block.presentation_delivery_gate, + .delivery_gate_userdata = block.presentation_delivery_gate_userdata, + } else null; } /// Present one healthy completed frame and recycle its exact swap-chain slot. @@ -144,23 +165,43 @@ fn completeHealthyFrame( sync: bool, frame_token: FrameToken, host_context: u64, + presentation_generation: u64, presentation: ?FramePresentation, -) void { +) ?FramePresentation { if (presentation) |value| { + // Only non-iOS synchronous completion enters this branch. Assignment + // is validated while renderer state is live; delivery is transferred + // to the generic caller so it occurs after `draw_mutex` unlocks. + if (sync) { + const completed = renderer.api.presentWithPresentation( + target.*, + presentation_generation, + value, + ) catch |err| failed: { + log.err("Failed to present tokened render target: err={}", .{err}); + break :failed null; + }; + renderer.frameCompleted(target, .healthy, frame_token, false); + return completed; + } + var frozen = renderer.api.detachPresentationTarget(target) catch |err| { log.warn("Failed to detach tokened render target: err={}", .{err}); renderer.frameCompleted(target, .healthy, frame_token, false); - return; + return null; }; defer frozen.releasePresentationOwnership(); - // Prepare retains the layer and IOSurface while renderer ownership is - // still protected by this frame. Recycling happens before dispatch, - // so an external callback can never precede renderer bookkeeping. - const prepared = renderer.api.preparePresentation(frozen, value); + // Prepare retains the layer, IOSurface, presentation lifetime, and + // exact observed generation before the replacement slot is recycled. + const prepared = renderer.api.preparePresentation( + frozen, + value, + presentation_generation, + ); renderer.frameCompleted(target, .healthy, frame_token, false); prepared.dispatch(); - return; + return null; } const host_acquired = renderer.api.present( @@ -169,6 +210,7 @@ fn completeHealthyFrame( sync, frame_token, host_context, + presentation_generation, ) catch |err| failed: { log.err("Failed to present render target: err={}", .{err}); break :failed false; @@ -179,6 +221,7 @@ fn completeHealthyFrame( frame_token, host_acquired, ); + return null; } /// Add a render pass to this frame with the provided attachments. @@ -232,7 +275,7 @@ pub inline fn complete(self: *Self, sync: bool) ?FramePresentation { if (use_sync) { self.buffer.msgSend(void, "waitUntilCompleted", .{}); self.block.sync = true; - CompletionBlock.invoke(&self.block, .{self.buffer.value}); + return bufferCompletedImpl(&self.block, self.buffer.value); } // Metal owns asynchronous presentation delivery through its completion @@ -303,6 +346,7 @@ test "tokened completion freezes target before recycling slot" { self: *@This(), target: MockTarget, _: FramePresentation, + _: u64, ) Prepared { self.state.append(.prepare, target.id); return .{ .target_id = target.id, .state = self.state }; @@ -315,10 +359,21 @@ test "tokened completion freezes target before recycling slot" { _: bool, _: FrameToken, _: u64, + _: u64, ) !bool { self.state.append(.present, target.id); return false; } + + fn presentWithPresentation( + self: *@This(), + target: MockTarget, + _: u64, + presentation: FramePresentation, + ) !?FramePresentation { + self.state.append(.present, target.id); + return presentation; + } }; const MockRenderer = struct { api: MockAPI, @@ -349,7 +404,7 @@ test "tokened completion freezes target before recycling slot" { var state: State = .{}; var renderer: MockRenderer = .{ .api = .{ .state = &state } }; var target: MockTarget = .{ .id = 1, .state = &state }; - completeHealthyFrame(&renderer, &target, false, 1, 0, presentation); + _ = completeHealthyFrame(&renderer, &target, false, 1, 0, 7, presentation); try testing.expectEqualSlices(Event, &.{ .{ .kind = .detach, .target_id = 1 }, .{ .kind = .prepare, .target_id = 1 }, @@ -363,7 +418,7 @@ test "tokened completion freezes target before recycling slot" { state = .{ .fail_detach = true }; renderer.api.state = &state; target = .{ .id = 4, .state = &state }; - completeHealthyFrame(&renderer, &target, false, 2, 0, presentation); + _ = completeHealthyFrame(&renderer, &target, false, 2, 0, 7, presentation); try testing.expectEqualSlices(Event, &.{ .{ .kind = .complete, .target_id = 4 }, }, state.events[0..state.len]); @@ -372,9 +427,30 @@ test "tokened completion freezes target before recycling slot" { state = .{}; renderer.api.state = &state; target = .{ .id = 7, .state = &state }; - completeHealthyFrame(&renderer, &target, false, 3, 0, null); + _ = completeHealthyFrame(&renderer, &target, false, 3, 0, 7, null); try testing.expectEqualSlices(Event, &.{ .{ .kind = .present, .target_id = 7 }, .{ .kind = .complete, .target_id = 7 }, }, state.events[0..state.len]); } + +test "late Metal completion cannot enter invalidated renderer lifetime" { + const TestContext = struct { + touched: bool = false, + }; + const TestLifetime = @import("CompletionLifetime.zig").Lifetime(TestContext); + + var context: TestContext = .{}; + const lifetime = try TestLifetime.create(std.testing.allocator); + lifetime.bind(&context); + + // Model independent completion ownership after the renderer generation + // invalidates and releases its own reference. + lifetime.retain(); + lifetime.invalidate(); + lifetime.release(); + + try std.testing.expect(lifetime.acquire() == null); + try std.testing.expect(!context.touched); + lifetime.release(); +} diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig index fe403722bb7..64c8a23fb24 100644 --- a/src/renderer/metal/IOSurfaceLayer.zig +++ b/src/renderer/metal/IOSurfaceLayer.zig @@ -19,6 +19,9 @@ var surface_updates_active_sentinel: usize = 0; /// The underlying CALayer layer: objc.Object, +/// Separately owned validity and size-generation state retained by queued +/// presentation blocks after the renderer releases the layer wrapper. +presentation_lifetime: *PresentationLifetime, pub fn init() !IOSurfaceLayer { // The layer returned by `[CALayer layer]` is autoreleased, which means @@ -31,6 +34,9 @@ pub fn init() !IOSurfaceLayer { ).retain(); errdefer layer.release(); + const presentation_lifetime = try PresentationLifetime.create(); + errdefer presentation_lifetime.release(); + // The layer gravity is set to top-left so that the contents aren't // stretched during resize operations before a new frame has been drawn. layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); @@ -41,10 +47,15 @@ pub fn init() !IOSurfaceLayer { .value = @ptrCast(&surface_updates_active_sentinel), }); - return .{ .layer = layer }; + return .{ + .layer = layer, + .presentation_lifetime = presentation_lifetime, + }; } pub fn release(self: *IOSurfaceLayer) void { + self.invalidateSurfaceUpdates(); + self.presentation_lifetime.release(); self.layer.release(); } @@ -57,6 +68,10 @@ pub fn detachFromHostIfDisplayCallbackOwned( display_cb: DisplayCallback, display_ctx: ?*anyopaque, ) void { + // Close the presentation gate before the main-queue detach barrier. Any + // already queued update then owns enough state to cancel without touching + // detached UI or embedder callback userdata. + self.presentation_lifetime.invalidate(); var block = DetachFromHostBlock.init(.{ .layer = self.layer.value, .display_cb = @ptrCast(@constCast(display_cb)), @@ -78,8 +93,12 @@ pub fn detachFromHostIfDisplayCallbackOwned( /// Sets the layer's `contents` to the provided IOSurface. /// /// Makes sure to do so on the main thread to avoid visual artifacts. -pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void { - return self.setSurface_(surface, null); +pub inline fn setSurface( + self: *IOSurfaceLayer, + surface: *IOSurface, + generation: u64, +) !void { + return self.setSurface_(surface, generation, null); } /// Sets the layer contents and acknowledges the exact token after the size @@ -87,9 +106,14 @@ pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void { pub inline fn setSurfaceWithPresentation( self: *IOSurfaceLayer, surface: *IOSurface, + generation: u64, presentation: FramePresentation, ) !void { - self.prepareSurfaceWithPresentation(surface, presentation).dispatch(); + self.prepareSurfaceWithPresentation( + surface, + generation, + presentation, + ).dispatch(); } /// A layer update whose Objective-C layer and IOSurface ownership no longer @@ -99,6 +123,8 @@ pub const PreparedSurfaceUpdate = struct { layer: objc.Object, surface: *IOSurface, presentation: FramePresentation, + lifetime: *PresentationLifetime, + generation: u64, /// Transfer this update to the main queue. Dispatch copies and retains the /// Objective-C layer capture; the callback consumes the IOSurface retain. @@ -108,6 +134,8 @@ pub const PreparedSurfaceUpdate = struct { var block = SetSurfaceBlock.init(.{ .layer = self.layer.value, .surface = self.surface, + .lifetime = self.lifetime, + .generation = self.generation, .presentation_callback = self.presentation.callback, .presentation_userdata = self.presentation.userdata, .presentation_token = self.presentation.token, @@ -126,19 +154,24 @@ pub const PreparedSurfaceUpdate = struct { pub fn prepareSurfaceWithPresentation( self: *IOSurfaceLayer, surface: *IOSurface, + generation: u64, presentation: FramePresentation, ) PreparedSurfaceUpdate { surface.retain(); + self.presentation_lifetime.retain(); return .{ .layer = self.layer.retain(), .surface = surface, .presentation = presentation, + .lifetime = self.presentation_lifetime, + .generation = generation, }; } fn setSurface_( self: *IOSurfaceLayer, surface: *IOSurface, + generation: u64, presentation: ?FramePresentation, ) !void { // We retain the surface to make sure it's not GC'd @@ -146,6 +179,7 @@ fn setSurface_( // // We release in the callback after setting the contents. surface.retain(); + self.presentation_lifetime.retain(); // NOTE: Since `self.layer` is passed as an `objc.c.id`, it's // automatically retained when the block is copied, so we // don't need to retain it ourselves like with the surface. @@ -153,6 +187,8 @@ fn setSurface_( var block = SetSurfaceBlock.init(.{ .layer = self.layer.value, .surface = surface, + .lifetime = self.presentation_lifetime, + .generation = generation, .presentation_callback = if (presentation) |value| value.callback else null, .presentation_userdata = if (presentation) |value| value.userdata else null, .presentation_token = if (presentation) |value| value.token else 0, @@ -181,6 +217,26 @@ fn setSurface_( } } +/// Record the clamped drawable dimensions observed for the frame about to be +/// encoded and return their monotonic generation. +pub fn observePresentationSize( + self: *IOSurfaceLayer, + width: usize, + height: usize, +) u64 { + return self.presentation_lifetime.observeSize(width, height); +} + +pub fn presentationGeneration(self: *IOSurfaceLayer) u64 { + return self.presentation_lifetime.currentGeneration(); +} + +/// Reject prepared updates from a swap-chain lifetime that has ended even +/// when the replacement lifetime happens to use the same drawable dimensions. +pub fn advancePresentationGeneration(self: *IOSurfaceLayer) void { + self.presentation_lifetime.advanceGeneration(); +} + fn surfaceUpdateRunsInline(is_main_thread: bool, tokened: bool) bool { return is_main_thread and !tokened; } @@ -193,6 +249,7 @@ fn surfaceUpdatesActive(self: *const IOSurfaceLayer) bool { /// The synchronous main-queue barrier orders invalidation after blocks already /// queued and before any later GPU completion blocks inspect the flag. pub fn invalidateSurfaceUpdates(self: *IOSurfaceLayer) void { + self.presentation_lifetime.invalidate(); var block = InvalidateSurfaceUpdatesBlock.init(.{ .layer = self.layer.value, }, &invalidateSurfaceUpdatesCallback); @@ -211,13 +268,37 @@ pub fn invalidateSurfaceUpdates(self: *IOSurfaceLayer) void { /// Sets the layer's `contents` to the provided IOSurface. /// /// Does not ensure this happens on the main thread. -pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { +pub inline fn setSurfaceSync( + self: *IOSurfaceLayer, + surface: *IOSurface, + generation: u64, +) bool { + var live = self.presentation_lifetime.acquire(generation) orelse + return false; + defer live.deinit(); + + if (!self.surfaceUpdatesActive()) return false; + const bounds = self.layer.getProperty(macos.graphics.Rect, "bounds"); + const scale = self.layer.getProperty(f64, "contentsScale"); + const width: usize = @intFromFloat(bounds.size.width * scale); + const height: usize = @intFromFloat(bounds.size.height * scale); + if (width != surface.getWidth() or height != surface.getHeight()) { + log.debug( + "setSurfaceSync(): surface is stale or wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", + .{ surface.getWidth(), surface.getHeight(), width, height }, + ); + return false; + } + self.layer.setProperty("contents", surface); + return true; } const SetSurfaceBlock = objc.Block(struct { layer: objc.c.id, surface: *IOSurface, + lifetime: *PresentationLifetime, + generation: u64, presentation_callback: ?*const fn (?*anyopaque, u64) callconv(.c) void, presentation_userdata: ?*anyopaque, presentation_token: u64, @@ -238,35 +319,46 @@ const InvalidateSurfaceUpdatesBlock = objc.Block(struct { fn setSurfaceCallback( block: *const SetSurfaceBlock.Context, ) callconv(.c) void { - const layer = objc.Object.fromId(block.layer); - const surface: *IOSurface = block.surface; + const lifetime = block.lifetime; // See explanation of why we retain and release in `setSurface`. - defer surface.release(); - - // Teardown invalidates on main. Blocks queued by a late GPU completion - // retain the layer and IOSurface but must not touch detached UI state or - // surface-owned callback userdata. - if (layer.getInstanceVariable("surface_updates_active").value == null) return; - - // We check to see if the surface is the appropriate size for - // the layer, if it's not then we discard it. This is because - // asynchronously drawn frames can sometimes finish just after - // a synchronously drawn frame during a resize, and if we don't - // discard the improperly sized surface it creates jank. - const bounds = layer.getProperty(macos.graphics.Rect, "bounds"); - const scale = layer.getProperty(f64, "contentsScale"); - const width: usize = @intFromFloat(bounds.size.width * scale); - const height: usize = @intFromFloat(bounds.size.height * scale); - if (width != surface.getWidth() or height != surface.getHeight()) { - log.debug( - "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", - .{ surface.getWidth(), surface.getHeight(), width, height }, - ); - return; + defer block.surface.release(); + defer lifetime.release(); + + { + // Generation observation, invalidation, and assignment share this + // lease, so a resize cannot advance between validation and assignment. + var live = lifetime.acquire(block.generation) orelse return; + defer live.deinit(); + + const layer = objc.Object.fromId(block.layer); + const surface: *IOSurface = block.surface; + + // Preserve the layer-owned delivery gate from current main. This is + // checked only after the independently owned lifetime is known live. + if (layer.getInstanceVariable("surface_updates_active").value == null) + return; + + // We check to see if the surface is the appropriate size for + // the layer, if it's not then we discard it. This is because + // asynchronously drawn frames can sometimes finish just after + // a synchronously drawn frame during a resize, and if we don't + // discard the improperly sized surface it creates jank. + const bounds = layer.getProperty(macos.graphics.Rect, "bounds"); + const scale = layer.getProperty(f64, "contentsScale"); + const width: usize = @intFromFloat(bounds.size.width * scale); + const height: usize = @intFromFloat(bounds.size.height * scale); + if (width != surface.getWidth() or height != surface.getHeight()) { + log.debug( + "setSurfaceCallback(): surface is stale or wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", + .{ surface.getWidth(), surface.getHeight(), width, height }, + ); + return; + } + + layer.setProperty("contents", surface); } - layer.setProperty("contents", surface); if (block.presentation_callback) |callback| { if (block.presentation_delivery_gate) |gate| { gate(block.presentation_delivery_gate_userdata); @@ -305,6 +397,86 @@ fn invalidateSurfaceUpdatesCallback( layer.setInstanceVariable("surface_updates_active", .{ .value = null }); } +const PresentationLifetime = struct { + const Self = @This(); + + refs: std.atomic.Value(usize) = std.atomic.Value(usize).init(1), + mutex: std.Thread.Mutex = .{}, + valid: bool = true, + generation: u64 = 1, + observed_width: usize = 0, + observed_height: usize = 0, + + const Live = struct { + owner: *Self, + + fn deinit(self: *Live) void { + self.owner.mutex.unlock(); + } + }; + + fn create() !*Self { + const self = try std.heap.c_allocator.create(Self); + self.* = .{}; + return self; + } + + fn retain(self: *Self) void { + const previous = self.refs.fetchAdd(1, .seq_cst); + std.debug.assert(previous > 0); + } + + fn release(self: *Self) void { + const previous = self.refs.fetchSub(1, .seq_cst); + std.debug.assert(previous > 0); + if (previous == 1) std.heap.c_allocator.destroy(self); + } + + fn invalidate(self: *Self) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.valid = false; + } + + fn acquire(self: *Self, generation: u64) ?Live { + self.mutex.lock(); + if (!self.valid or self.generation != generation) { + self.mutex.unlock(); + return null; + } + return .{ .owner = self }; + } + + fn observeSize(self: *Self, width: usize, height: usize) u64 { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (width != self.observed_width or height != self.observed_height) { + self.observed_width = width; + self.observed_height = height; + self.advanceGenerationLocked(); + } + return self.generation; + } + + fn advanceGeneration(self: *Self) void { + self.mutex.lock(); + defer self.mutex.unlock(); + self.advanceGenerationLocked(); + } + + fn advanceGenerationLocked(self: *Self) void { + std.debug.assert(self.generation < std.math.maxInt(u64)); + self.generation += 1; + } + + fn currentGeneration(self: *Self) u64 { + self.mutex.lock(); + defer self.mutex.unlock(); + return self.generation; + } +}; + pub const DisplayCallback = ?*align(8) const fn (?*anyopaque) void; pub fn setDisplayCallback( @@ -393,7 +565,7 @@ test "tokened surface updates defer delivery and teardown invalidates them" { try testing.expect(!surfaceUpdateRunsInline(false, false)); var layer = try IOSurfaceLayer.init(); - defer layer.layer.release(); + defer layer.release(); try testing.expect(layer.surfaceUpdatesActive()); layer.invalidateSurfaceUpdates(); try testing.expect(!layer.surfaceUpdatesActive()); @@ -410,11 +582,15 @@ test "tokened surface updates defer delivery and teardown invalidates them" { }); defer surface.deinit(); surface.retain(); + const generation = layer.presentationGeneration(); + layer.presentation_lifetime.retain(); var state: CallbackState = .{}; var block = SetSurfaceBlock.init(.{ .layer = layer.layer.value, .surface = surface, + .lifetime = layer.presentation_lifetime, + .generation = generation, .presentation_callback = &CallbackState.callback, .presentation_userdata = &state, .presentation_token = 42, @@ -426,3 +602,20 @@ test "tokened surface updates defer delivery and teardown invalidates them" { try testing.expectEqual(@as(usize, 0), state.gate_count); try testing.expectEqual(@as(usize, 0), state.callback_count); } + +test "stale same-size generation cannot acquire presentation lifetime" { + var lifetime: PresentationLifetime = .{}; + + const stale_generation = lifetime.observeSize(80, 24); + _ = lifetime.observeSize(81, 24); + const current_generation = lifetime.observeSize(80, 24); + + // The drawable returned to its prior dimensions, but the old frame still + // belongs to an earlier lifetime and must not overwrite layer contents. + try std.testing.expect(stale_generation != current_generation); + try std.testing.expect(lifetime.acquire(stale_generation) == null); + + var current = lifetime.acquire(current_generation); + try std.testing.expect(current != null); + current.?.deinit(); +}