From 34123c38fa06f43df4be61e2ecd3dd825563865b Mon Sep 17 00:00:00 2001 From: nomaterials Date: Fri, 12 Jun 2026 16:56:07 +0200 Subject: [PATCH 1/3] frameclock_wayland: add Wayland timing adapter crate Introduce frameclock_wayland with the Wayland timing facts that previously only lived inside subduction_backend_wayland: compositor-aligned clock selection and reads (Clock, now, timebase), wp_presentation feedback facts (SubmissionId, PresentEvent, PresentEventQueue, presented-timestamp conversion), and the frame-callback ticker state machine (TickerState), decoupled from the backend's output registry by taking the target OutputId from the caller. The backend keeps its private copies for now; migrating it and the examples to this crate is left to future implementation, along with present-hint estimation and a retained FrameDriver wrapper. --- .github/workflows/ci.yml | 5 + Cargo.lock | 8 + Cargo.toml | 2 + frameclock_wayland/CHANGELOG.md | 23 ++ frameclock_wayland/Cargo.toml | 18 ++ frameclock_wayland/LICENSE-APACHE | 176 ++++++++++++++ frameclock_wayland/LICENSE-MIT | 19 ++ frameclock_wayland/README.md | 84 +++++++ frameclock_wayland/src/lib.rs | 71 ++++++ frameclock_wayland/src/presentation.rs | 243 ++++++++++++++++++++ frameclock_wayland/src/queue.rs | 92 ++++++++ frameclock_wayland/src/tick.rs | 304 +++++++++++++++++++++++++ frameclock_wayland/src/time.rs | 162 +++++++++++++ 13 files changed, 1207 insertions(+) create mode 100644 frameclock_wayland/CHANGELOG.md create mode 100644 frameclock_wayland/Cargo.toml create mode 100644 frameclock_wayland/LICENSE-APACHE create mode 100644 frameclock_wayland/LICENSE-MIT create mode 100644 frameclock_wayland/README.md create mode 100644 frameclock_wayland/src/lib.rs create mode 100644 frameclock_wayland/src/presentation.rs create mode 100644 frameclock_wayland/src/queue.rs create mode 100644 frameclock_wayland/src/tick.rs create mode 100644 frameclock_wayland/src/time.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d86655c..14c178e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ env: RUST_MIN_VER_PKGS: >- -p frameclock -p frameclock_apple + -p frameclock_wayland -p frameclock_web -p subduction_backend_android -p subduction_backend_apple @@ -28,6 +29,7 @@ env: RUST_STD_ONLY_PKGS: >- --exclude frameclock_apple --exclude frameclock_simulated + --exclude frameclock_wayland --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu @@ -56,6 +58,7 @@ env: RUST_NO_WASIP1_PKGS: >- --exclude frameclock_web --exclude frameclock_apple + --exclude frameclock_wayland --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu @@ -79,6 +82,7 @@ env: # List of packages that don't support wasm32. RUST_NO_WASM_PKGS: >- --exclude frameclock_apple + --exclude frameclock_wayland --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu @@ -106,6 +110,7 @@ env: --exclude windows_lotta_layers # Packages excluded on non-Linux runners (Linux-only). RUST_LINUX_EXCLUDE_PKGS: >- + --exclude frameclock_wayland --exclude subduction_backend_wayland --exclude wayland_example_common --exclude wayland_layers diff --git a/Cargo.lock b/Cargo.lock index c04deb1..d1d6c25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -474,6 +474,14 @@ dependencies = [ "frameclock", ] +[[package]] +name = "frameclock_wayland" +version = "0.0.1" +dependencies = [ + "frameclock", + "rustix 1.1.4", +] + [[package]] name = "frameclock_web" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index f27e8d1..52925ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "frameclock", "frameclock_web", "frameclock_apple", + "frameclock_wayland", "subduction_backend_apple", "subduction_backend_web", "subduction_backend_wgpu", @@ -64,6 +65,7 @@ repository = "https://github.com/forest-rs/subduction" [workspace.dependencies] frameclock = { version = "0.0.1", path = "frameclock" } frameclock_apple = { version = "0.0.1", path = "frameclock_apple", default-features = false } +frameclock_wayland = { version = "0.0.1", path = "frameclock_wayland" } frameclock_web = { version = "0.0.1", path = "frameclock_web" } subduction_core = { path = "subduction_core" } subduction_sync_harness = { path = "subduction_sync_harness" } diff --git a/frameclock_wayland/CHANGELOG.md b/frameclock_wayland/CHANGELOG.md new file mode 100644 index 0000000..6a4357b --- /dev/null +++ b/frameclock_wayland/CHANGELOG.md @@ -0,0 +1,23 @@ + + +# Changelog + +The latest published `frameclock_wayland` release is [0.0.1](#001-XXXX-XX-XX), which was released on +XXXX-XX-XX. You can find its changes [documented below](#001-XXXX-XX-XX). + +## [Unreleased] + +This release has an [MSRV][] of 1.92. + +This is the initial release. + +[Unreleased]: https://github.com/forest-rs/subduction/compare/frameclock_wayland-v0.0.1...HEAD +[0.0.1]: https://github.com/forest-rs/subduction/releases/tag/frameclock_wayland-v0.0.1 + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/frameclock_wayland/Cargo.toml b/frameclock_wayland/Cargo.toml new file mode 100644 index 0000000..4002ee7 --- /dev/null +++ b/frameclock_wayland/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "frameclock_wayland" +description = "Wayland timing adapters for frameclock" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["frame-pacing", "timing", "wayland", "presentation-time", "linux"] +categories = ["os::linux-apis", "rendering::engine"] + +[lints] +workspace = true + +[dependencies] +frameclock = { workspace = true } +rustix = { version = "1.1.3", default-features = false, features = ["time"] } diff --git a/frameclock_wayland/LICENSE-APACHE b/frameclock_wayland/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/frameclock_wayland/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/frameclock_wayland/LICENSE-MIT b/frameclock_wayland/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/frameclock_wayland/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frameclock_wayland/README.md b/frameclock_wayland/README.md new file mode 100644 index 0000000..1972239 --- /dev/null +++ b/frameclock_wayland/README.md @@ -0,0 +1,84 @@ +
+ +# Frameclock Wayland + +**Wayland timing adapters for `frameclock`.** + +
+ +`frameclock_wayland` connects Wayland frame timing to `frameclock`. It selects +and reads the compositor-aligned presentation clock as `HostTime`, converts +`wl_surface.frame` callback completions into `FrameTick` values, and carries +`wp_presentation` feedback facts as `PresentEvent` values for scheduler +feedback. + +The crate intentionally does not own `wl_surface` objects, event queues, +buffers, registries, or protocol dispatch. Protocol I/O belongs to hosts and +backend crates such as `subduction_backend_wayland`; this crate owns the +timing bookkeeping those hosts feed and poll. Present-hint computation and a +retained `FrameDriver` wrapper are left to future implementation here. + +## Core Flow + +```text +wl_surface.frame done -> TickerState -> FrameTick +wp_presentation_feedback events -> PresentEvent -> PresentEventQueue +wp_presentation.clock_id -> Clock -> HostTime reads +``` + +Use `TickerState` as the frame-callback bookkeeping for one surface: call +`mark_callback_requested` when a `wl_surface.frame` request is sent, call +`on_callback_done` when the matching `wl_callback.done` event arrives, and +drain resulting ticks with `poll_tick`. + +Use `presentation_time_to_host_time` to convert +`wp_presentation_feedback.presented` timestamps, store the most recent value +via `TickerState::set_last_observed_actual_present` so the next tick carries +`FrameTick::prev_actual_present`, and queue per-commit `PresentEvent`s in a +`PresentEventQueue` correlated by `SubmissionId`. + +Use `Clock::from_presentation_clock_id` to map the `wp_presentation.clock_id` +event to a `Clock`, and read all timing facts from that clock so feedback +timestamps and tick times stay in one time domain. + +## Timing Model + +`now`, `Clock::now`, and all converted timestamps are nanosecond ticks; +`timebase` returns the identity nanosecond `Timebase`. + +Wayland frame callbacks carry no predicted present time or refresh interval, +so emitted `FrameTick`s are pacing-only facts. Actual presentation evidence +arrives separately through `wp_presentation` feedback: the previous frame's +actual present time is surfaced as `FrameTick::prev_actual_present`, and the +full per-commit event stream is available as `PresentEvent` values for hosts +that resolve feedback by `SubmissionId`. + +When the compositor advertises `wp_presentation`, its `clock_id` event names +the clock domain of all feedback timestamps. Hosts should switch their reads +to that clock (`Clock::Presentation`) so `HostTime` comparisons remain valid. +`Clock::Monotonic` is the fallback when `wp_presentation` is missing or the +advertised clock is unknown. + +Compositors stop delivering frame callbacks while a surface is occluded or +minimised, so the tick stream can stall. Hosts should treat tick starvation +as normal Wayland behaviour: idle, apply a timeout, or fall back to a +timer-based tick source. + +## no_std + +This crate keeps its implementation `no_std` (with `alloc`), but reading +clocks requires an operating system. It is validated on Linux targets instead +of the workspace's generic `x86_64-unknown-none` no-std target. + +## Minimum Supported Rust Version (MSRV) + +This crate has been verified to compile with **Rust 1.92** and later. + +## License + +Licensed under either of + +- Apache License, Version 2.0, or +- MIT license, + +at your option. diff --git a/frameclock_wayland/src/lib.rs b/frameclock_wayland/src/lib.rs new file mode 100644 index 0000000..5ecee14 --- /dev/null +++ b/frameclock_wayland/src/lib.rs @@ -0,0 +1,71 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Wayland timing adapters for [`frameclock`]. +//! +//! This crate owns Wayland-specific timing adaptation. It selects and reads +//! the compositor-aligned presentation clock as [`HostTime`], converts +//! `wl_surface.frame` callback completions into [`FrameTick`] values via +//! [`TickerState`], and carries `wp_presentation` feedback facts as +//! [`PresentEvent`] values. +//! +//! It intentionally does not own `wl_surface` objects, event queues, buffers, +//! registries, or protocol dispatch. Protocol I/O belongs to hosts and backend +//! crates; this crate owns the timing bookkeeping those hosts feed and poll. +//! +//! # Core Flow +//! +//! ```text +//! wl_surface.frame done -> TickerState -> FrameTick +//! wp_presentation_feedback events -> PresentEvent -> PresentEventQueue +//! wp_presentation.clock_id -> Clock -> HostTime reads +//! ``` +//! +//! A host's frame-callback dispatch path has this shape: +//! +//! ```rust,ignore +//! use frameclock::OutputId; +//! use frameclock_wayland::{Clock, TickerState}; +//! +//! let mut ticker = TickerState::new(); +//! let clock = Clock::Monotonic; +//! +//! // When sending a wl_surface.frame request: +//! ticker.mark_callback_requested(); +//! +//! // When the matching wl_callback.done event arrives: +//! ticker.on_callback_done(clock, OutputId(0)); +//! +//! // After dispatch, drain the queued ticks: +//! while let Some(tick) = ticker.poll_tick() { +//! // Build a FrameOpportunity and plan the frame. +//! _ = tick; +//! } +//! ``` +//! +//! All `HostTime` values are nanosecond ticks. When the compositor advertises +//! `wp_presentation`, map its `clock_id` event to a [`Clock`] with +//! [`Clock::from_presentation_clock_id`] and read timing facts from that clock +//! so feedback timestamps and tick times stay in one time domain. +//! +//! This crate keeps its implementation `no_std` (with `alloc`), but reading +//! clocks requires an operating system. It is intended to be validated on +//! Linux targets, not on generic no-std targets such as `x86_64-unknown-none`. +//! +//! [`HostTime`]: frameclock::HostTime +//! [`FrameTick`]: frameclock::FrameTick + +#![no_std] + +extern crate alloc; + +mod presentation; +mod queue; +mod tick; +mod time; + +pub use presentation::{ + PresentEvent, PresentEventQueue, SubmissionId, presentation_time_to_host_time, +}; +pub use tick::TickerState; +pub use time::{Clock, now, timebase}; diff --git a/frameclock_wayland/src/presentation.rs b/frameclock_wayland/src/presentation.rs new file mode 100644 index 0000000..f376f02 --- /dev/null +++ b/frameclock_wayland/src/presentation.rs @@ -0,0 +1,243 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Presentation feedback contracts and queueing. + +use crate::queue::BoundedQueue; +use frameclock::{HostTime, OutputId}; + +/// Unique identity for one `wl_surface.commit` submission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubmissionId(pub u64); + +/// Converts a `wp_presentation_feedback.presented` timestamp to [`HostTime`]. +/// +/// `tv_nsec` is clamped to `≤999_999_999` to prevent overflow. Arithmetic is +/// saturating so edge-case compositor data never causes a panic. +#[must_use] +pub fn presentation_time_to_host_time(tv_sec_hi: u32, tv_sec_lo: u32, tv_nsec: u32) -> HostTime { + let seconds = u64::from(tv_sec_hi) << 32 | u64::from(tv_sec_lo); + let nanos = seconds + .saturating_mul(1_000_000_000) + .saturating_add(u64::from(tv_nsec.min(999_999_999))); + HostTime(nanos) +} + +/// Per-commit presentation feedback event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PresentEvent { + /// The submission was presented. + Presented { + /// Identity of the commit this event corresponds to. + id: SubmissionId, + /// Actual presentation timestamp in host-time ticks. + actual_present: HostTime, + /// Observed refresh interval in host ticks, if known. + refresh_interval: Option, + /// Output where the frame was shown, if known. + output: Option, + /// Raw protocol flags. + flags: u32, + }, + /// The compositor discarded the submission. + Discarded { + /// Identity of the commit this event corresponds to. + id: SubmissionId, + }, +} + +/// Bounded FIFO queue for [`PresentEvent`] values. +/// +/// Overflow policy is `drop_oldest`: when full, pushing a new event removes +/// the oldest queued event first. This keeps newest feedback available to the +/// host under backpressure. +#[derive(Debug, Clone)] +pub struct PresentEventQueue { + inner: BoundedQueue, +} + +impl PresentEventQueue { + /// Default queue capacity used by [`Default`]. + pub const DEFAULT_CAPACITY: usize = 64; + + /// Creates a queue with an explicit capacity. + /// + /// `capacity == 0` is promoted to `1`. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: BoundedQueue::with_capacity(capacity), + } + } + + /// Enqueues one presentation event. + pub fn push(&mut self, event: PresentEvent) { + self.inner.push(event); + } + + /// Pops the oldest queued event, if any. + pub fn pop(&mut self) -> Option { + self.inner.pop() + } + + /// Returns the current queue length. + #[must_use] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns `true` when no events are queued. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Number of events dropped due to queue overflow. + #[must_use] + pub fn dropped_count(&self) -> u64 { + self.inner.dropped_count() + } +} + +impl Default for PresentEventQueue { + fn default() -> Self { + Self::with_capacity(Self::DEFAULT_CAPACITY) + } +} + +#[cfg(test)] +mod tests { + use super::{PresentEvent, PresentEventQueue, SubmissionId, presentation_time_to_host_time}; + use frameclock::HostTime; + use frameclock::OutputId; + + #[test] + fn queue_overflow_drops_oldest_event() { + let mut queue = PresentEventQueue::with_capacity(2); + queue.push(PresentEvent::Discarded { + id: SubmissionId(1), + }); + queue.push(PresentEvent::Discarded { + id: SubmissionId(2), + }); + queue.push(PresentEvent::Discarded { + id: SubmissionId(3), + }); + + assert_eq!( + queue.pop(), + Some(PresentEvent::Discarded { + id: SubmissionId(2) + }) + ); + assert_eq!( + queue.pop(), + Some(PresentEvent::Discarded { + id: SubmissionId(3) + }) + ); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn presented_event_round_trips_payload() { + let event = PresentEvent::Presented { + id: SubmissionId(9), + actual_present: HostTime(123), + refresh_interval: Some(16_666_667), + output: Some(OutputId(4)), + flags: 7, + }; + + let mut queue = PresentEventQueue::with_capacity(4); + queue.push(event); + assert_eq!(queue.pop(), Some(event)); + } + + #[test] + fn zero_capacity_is_promoted_to_one() { + let mut queue = PresentEventQueue::with_capacity(0); + queue.push(PresentEvent::Discarded { + id: SubmissionId(1), + }); + queue.push(PresentEvent::Discarded { + id: SubmissionId(2), + }); + + assert_eq!(queue.len(), 1); + assert_eq!( + queue.pop(), + Some(PresentEvent::Discarded { + id: SubmissionId(2) + }) + ); + assert_eq!(queue.dropped_count(), 1); + } + + // --- presentation_time_to_host_time tests --- + + #[test] + fn timestamp_packing_normal_values() { + // 1 second + 500_000_000 ns + let t = presentation_time_to_host_time(0, 1, 500_000_000); + assert_eq!(t, HostTime(1_500_000_000)); + } + + #[test] + fn timestamp_packing_large_tv_sec_hi() { + // tv_sec_hi = 1 means seconds = 1 << 32 = 4_294_967_296 + let t = presentation_time_to_host_time(1, 0, 0); + assert_eq!(t, HostTime(4_294_967_296 * 1_000_000_000)); + } + + #[test] + fn timestamp_packing_zero() { + let t = presentation_time_to_host_time(0, 0, 0); + assert_eq!(t, HostTime(0)); + } + + #[test] + fn timestamp_packing_saturates_on_overflow() { + // u32::MAX across all fields should saturate rather than panic. + let t = presentation_time_to_host_time(u32::MAX, u32::MAX, u32::MAX); + assert_eq!(t, HostTime(u64::MAX)); + } + + #[test] + fn tv_nsec_clamped_above_max() { + // tv_nsec > 999_999_999 is clamped. + let clamped = presentation_time_to_host_time(0, 0, 1_500_000_000); + let expected = presentation_time_to_host_time(0, 0, 999_999_999); + assert_eq!(clamped, expected); + } + + #[test] + fn tv_nsec_exact_max_is_not_clamped() { + let t = presentation_time_to_host_time(0, 0, 999_999_999); + assert_eq!(t, HostTime(999_999_999)); + } + + #[test] + fn refresh_zero_produces_none() { + // Verify the conversion convention: refresh == 0 → None. + let refresh: u32 = 0; + let interval: Option = if refresh == 0 { + None + } else { + Some(u64::from(refresh)) + }; + assert_eq!(interval, None); + } + + #[test] + fn refresh_nonzero_produces_some() { + let refresh: u32 = 16_666_667; + let interval: Option = if refresh == 0 { + None + } else { + Some(u64::from(refresh)) + }; + assert_eq!(interval, Some(16_666_667)); + } +} diff --git a/frameclock_wayland/src/queue.rs b/frameclock_wayland/src/queue.rs new file mode 100644 index 0000000..158fd16 --- /dev/null +++ b/frameclock_wayland/src/queue.rs @@ -0,0 +1,92 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Internal bounded queue utilities. + +use alloc::collections::VecDeque; + +/// Bounded FIFO queue with a `drop_oldest` overflow policy. +/// +/// Once full, new pushes remove the oldest item before inserting the newest. +#[derive(Debug, Clone)] +pub(crate) struct BoundedQueue { + items: VecDeque, + capacity: usize, + dropped_count: u64, +} + +impl BoundedQueue { + pub(crate) fn with_capacity(capacity: usize) -> Self { + let capacity = capacity.max(1); + Self { + items: VecDeque::with_capacity(capacity), + capacity, + dropped_count: 0, + } + } + + pub(crate) fn push(&mut self, item: T) { + if self.items.len() == self.capacity { + let _ = self.items.pop_front(); + self.dropped_count += 1; + } + self.items.push_back(item); + } + + pub(crate) fn pop(&mut self) -> Option { + self.items.pop_front() + } + + pub(crate) fn len(&self) -> usize { + self.items.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub(crate) fn dropped_count(&self) -> u64 { + self.dropped_count + } +} + +#[cfg(test)] +mod tests { + use super::BoundedQueue; + + #[test] + fn zero_capacity_is_promoted_to_one() { + let mut queue = BoundedQueue::with_capacity(0); + queue.push(10_u32); + queue.push(11_u32); + + assert_eq!(queue.len(), 1); + assert_eq!(queue.pop(), Some(11_u32)); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn push_over_capacity_drops_oldest() { + let mut queue = BoundedQueue::with_capacity(2); + queue.push(1_u32); + queue.push(2_u32); + queue.push(3_u32); + + assert_eq!(queue.pop(), Some(2_u32)); + assert_eq!(queue.pop(), Some(3_u32)); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn empty_queue_reports_is_empty() { + let mut queue = BoundedQueue::with_capacity(2); + assert!(queue.is_empty()); + + queue.push(1_u32); + assert!(!queue.is_empty()); + + let _ = queue.pop(); + assert!(queue.is_empty()); + } +} diff --git a/frameclock_wayland/src/tick.rs b/frameclock_wayland/src/tick.rs new file mode 100644 index 0000000..ff739d0 --- /dev/null +++ b/frameclock_wayland/src/tick.rs @@ -0,0 +1,304 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Frame-tick queueing primitives and ticker state machine. + +use crate::queue::BoundedQueue; +use crate::time::Clock; +use frameclock::{FrameTick, HostTime, OutputId}; + +/// Internal bounded queue for frame ticks. +/// +/// Overflow policy is `drop_oldest` to retain the freshest pacing signal. +#[derive(Debug, Clone)] +pub(crate) struct TickQueue { + inner: BoundedQueue, +} + +impl TickQueue { + pub(crate) const DEFAULT_CAPACITY: usize = 8; + + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + inner: BoundedQueue::with_capacity(capacity), + } + } + + pub(crate) fn push(&mut self, tick: FrameTick) { + self.inner.push(tick); + } + + pub(crate) fn pop(&mut self) -> Option { + self.inner.pop() + } + + #[allow(dead_code, reason = "used by tests and future diagnostics")] + pub(crate) fn len(&self) -> usize { + self.inner.len() + } + + #[allow(dead_code, reason = "used by tests and future diagnostics")] + pub(crate) fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + #[allow(dead_code, reason = "used by tests and future diagnostics")] + pub(crate) fn dropped_count(&self) -> u64 { + self.inner.dropped_count() + } +} + +impl Default for TickQueue { + fn default() -> Self { + Self::with_capacity(Self::DEFAULT_CAPACITY) + } +} + +/// Pure-logic state machine for frame callback tick generation. +/// +/// Tracks the in-flight callback state, builds [`FrameTick`]s when callbacks +/// complete, and queues them for polling. Protocol I/O is handled externally; +/// this type contains only the bookkeeping. Hosts call +/// [`mark_callback_requested`](Self::mark_callback_requested) when sending a +/// `wl_surface.frame` request, [`on_callback_done`](Self::on_callback_done) +/// when the matching `wl_callback.done` event arrives, and drain ticks with +/// [`poll_tick`](Self::poll_tick). +#[derive(Debug)] +pub struct TickerState { + queue: TickQueue, + tick_index: u64, + callback_in_flight: bool, + last_observed_actual_present: Option, +} + +impl TickerState { + /// Creates an empty ticker with no callback in flight. + #[must_use] + pub fn new() -> Self { + Self { + queue: TickQueue::default(), + tick_index: 0, + callback_in_flight: false, + last_observed_actual_present: None, + } + } + + /// Records that a `wl_callback.done` event has arrived. + /// + /// If a callback is in flight, builds a pacing-only [`FrameTick`] for + /// `output` with the current time read from `clock`, enqueues it, + /// increments the tick index, and clears the in-flight flag. If no + /// callback is in flight, debug-asserts and returns. + pub fn on_callback_done(&mut self, clock: Clock, output: OutputId) { + debug_assert!( + self.callback_in_flight, + "on_callback_done called without an in-flight callback" + ); + if !self.callback_in_flight { + return; + } + + let now = clock.now(); + let prev_actual_present = self.last_observed_actual_present; + + let tick = FrameTick { + now, + predicted_present: None, + refresh_interval: None, + frame_index: self.tick_index, + output, + prev_actual_present, + }; + + self.queue.push(tick); + self.tick_index += 1; + self.callback_in_flight = false; + } + + /// Pops the next queued [`FrameTick`], if any. + pub fn poll_tick(&mut self) -> Option { + self.queue.pop() + } + + /// Returns whether a frame callback is currently in flight. + #[must_use] + pub fn is_callback_in_flight(&self) -> bool { + self.callback_in_flight + } + + /// Marks that a frame callback request has been sent. + pub fn mark_callback_requested(&mut self) { + debug_assert!( + !self.callback_in_flight, + "mark_callback_requested called while a callback is already in flight" + ); + self.callback_in_flight = true; + } + + /// Stores the most recent actual present time for propagation into the + /// next [`FrameTick::prev_actual_present`]. + pub fn set_last_observed_actual_present(&mut self, t: HostTime) { + self.last_observed_actual_present = Some(t); + } +} + +impl Default for TickerState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::{TickQueue, TickerState}; + use crate::time::Clock; + use frameclock::FrameTick; + use frameclock::HostTime; + use frameclock::OutputId; + + fn test_tick(frame_index: u64) -> FrameTick { + FrameTick { + now: HostTime(frame_index), + predicted_present: None, + refresh_interval: None, + frame_index, + output: OutputId(0), + prev_actual_present: None, + } + } + + // --- TickQueue tests --- + + #[test] + fn overflow_drops_oldest_tick() { + let mut queue = TickQueue::with_capacity(2); + queue.push(test_tick(1)); + queue.push(test_tick(2)); + queue.push(test_tick(3)); + + assert_eq!(queue.pop().map(|tick| tick.frame_index), Some(2)); + assert_eq!(queue.pop().map(|tick| tick.frame_index), Some(3)); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn empty_state_tracks_push_and_pop() { + let mut queue = TickQueue::default(); + assert!(queue.is_empty()); + assert_eq!(queue.len(), 0); + + queue.push(test_tick(7)); + assert!(!queue.is_empty()); + assert_eq!(queue.len(), 1); + + let _ = queue.pop(); + assert!(queue.is_empty()); + assert_eq!(queue.len(), 0); + } + + // --- TickerState tests --- + + #[test] + fn on_callback_done_enqueues_tick_with_correct_fields() { + let mut ticker = TickerState::new(); + + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + + let tick = ticker.poll_tick().expect("should have a tick"); + assert!(tick.now.ticks() > 0); + assert_eq!(tick.predicted_present, None); + assert_eq!(tick.refresh_interval, None); + assert_eq!(tick.frame_index, 0); + assert_eq!(tick.output, OutputId(0)); + assert_eq!(tick.prev_actual_present, None); + } + + #[test] + fn on_callback_done_uses_caller_output() { + let mut ticker = TickerState::new(); + + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(3)); + + let tick = ticker.poll_tick().expect("should have a tick"); + assert_eq!(tick.output, OutputId(3)); + } + + #[test] + fn poll_tick_returns_none_when_empty() { + let mut ticker = TickerState::new(); + assert!(ticker.poll_tick().is_none()); + } + + #[test] + fn tick_index_increments_monotonically() { + let mut ticker = TickerState::new(); + + for expected in 0..5 { + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + let tick = ticker.poll_tick().unwrap(); + assert_eq!(tick.frame_index, expected); + } + } + + #[test] + fn callback_in_flight_transitions() { + let mut ticker = TickerState::new(); + + assert!(!ticker.is_callback_in_flight()); + ticker.mark_callback_requested(); + assert!(ticker.is_callback_in_flight()); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + assert!(!ticker.is_callback_in_flight()); + } + + #[test] + fn last_observed_actual_present_propagates() { + let mut ticker = TickerState::new(); + + // First tick: no previous actual present. + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + let tick0 = ticker.poll_tick().unwrap(); + assert_eq!(tick0.prev_actual_present, None); + + // Record an actual present time. + ticker.set_last_observed_actual_present(HostTime(42_000)); + + // Second tick: should carry the observed time. + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + let tick1 = ticker.poll_tick().unwrap(); + assert_eq!(tick1.prev_actual_present, Some(HostTime(42_000))); + } + + #[test] + fn done_when_not_in_flight_is_ignored() { + let mut ticker = TickerState::new(); + + // Calling on_callback_done without mark_callback_requested is guarded + // by debug_assert, so in release builds it returns without enqueuing. + // We verify the initial state reflects no enqueue path. + assert!(!ticker.is_callback_in_flight()); + assert!(ticker.poll_tick().is_none()); + } + + #[test] + fn queue_overflow_drops_oldest_through_ticker() { + // TickQueue default capacity is 8; push 9 ticks and verify the first + // is dropped. + let mut ticker = TickerState::new(); + + for _ in 0..9 { + ticker.mark_callback_requested(); + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + } + + // First available tick should be index 1 (index 0 was dropped). + let tick = ticker.poll_tick().unwrap(); + assert_eq!(tick.frame_index, 1); + } +} diff --git a/frameclock_wayland/src/time.rs b/frameclock_wayland/src/time.rs new file mode 100644 index 0000000..86520c0 --- /dev/null +++ b/frameclock_wayland/src/time.rs @@ -0,0 +1,162 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Wayland host clock selection and reads. + +use frameclock::HostTime; +use frameclock::time::Timebase; +use rustix::time::{ClockId as PosixClockId, Timespec, clock_gettime}; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +/// Clock source used for Wayland timing facts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum Clock { + /// `CLOCK_MONOTONIC` fallback clock. + #[default] + Monotonic, + /// Clock selected from `wp_presentation.clock_id`. + Presentation(PosixClockId), +} + +impl Clock { + /// Attempts to map a `wp_presentation.clock_id` value to a [`Clock`]. + /// + /// Returns `Some(Clock::Presentation(...))` if the raw id maps to a POSIX + /// clock recognized by the platform. Returns `None` for unknown or + /// out-of-range values, in which case the caller should keep the current + /// clock and degrade gracefully. + #[must_use] + pub fn from_presentation_clock_id(clk_id: u32) -> Option { + // `PosixClockId` is `u32` on Apple platforms, `i32` elsewhere + #[cfg(not(target_vendor = "apple"))] + let posix_id = { + let raw = i32::try_from(clk_id).ok()?; + PosixClockId::try_from(raw).ok()? + }; + #[cfg(target_vendor = "apple")] + let posix_id = PosixClockId::try_from(clk_id).ok()?; + Some(Self::Presentation(posix_id)) + } + + /// Returns the current host time read from this clock in nanoseconds. + #[must_use] + pub fn now(self) -> HostTime { + let timespec = clock_gettime(self.posix_clock_id()); + timespec_to_host_time(timespec) + } + + #[must_use] + const fn posix_clock_id(self) -> PosixClockId { + match self { + Self::Monotonic => PosixClockId::Monotonic, + Self::Presentation(clock_id) => clock_id, + } + } +} + +/// Returns the Wayland [`Timebase`]: host ticks are nanoseconds. +#[must_use] +pub const fn timebase() -> Timebase { + Timebase::NANOS +} + +/// Returns the current monotonic host time in nanoseconds. +/// +/// Hosts tracking a `wp_presentation` clock domain should prefer +/// [`Clock::now`] on the selected clock so all timing facts stay comparable. +#[must_use] +pub fn now() -> HostTime { + Clock::Monotonic.now() +} + +fn timespec_to_host_time(timespec: Timespec) -> HostTime { + let seconds = u64::try_from(timespec.tv_sec).unwrap_or(0); + let nanos = u64::try_from(timespec.tv_nsec) + .unwrap_or(0) + .min(999_999_999); + + let ticks_u128 = u128::from(seconds) + .saturating_mul(NANOS_PER_SECOND) + .saturating_add(u128::from(nanos)); + let ticks = u64::try_from(ticks_u128).unwrap_or(u64::MAX); + HostTime(ticks) +} + +#[cfg(test)] +mod tests { + use super::{Clock, now, timebase, timespec_to_host_time}; + use frameclock::HostTime; + use frameclock::time::Timebase; + use rustix::time::{ClockId as PosixClockId, Timespec}; + + #[test] + fn timebase_is_nanos_identity() { + assert_eq!(timebase(), Timebase::NANOS); + } + + #[test] + fn now_is_monotonic_non_decreasing() { + let first = now(); + let second = now(); + assert!(second >= first, "monotonic clock should not go backwards"); + } + + #[test] + fn presentation_clock_variant_is_usable() { + let tick = Clock::Presentation(PosixClockId::Monotonic).now(); + assert!( + tick.ticks() > 0, + "clock_gettime(monotonic) should be positive" + ); + } + + #[test] + fn timespec_conversion_builds_nanosecond_ticks() { + let input = Timespec { + tv_sec: 12, + tv_nsec: 345_678_901, + }; + let expected = HostTime(12 * 1_000_000_000 + 345_678_901); + assert_eq!(timespec_to_host_time(input), expected); + } + + #[test] + fn timespec_conversion_saturates_on_large_values() { + let input = Timespec { + tv_sec: i64::MAX, + tv_nsec: 999_999_999, + }; + assert_eq!(timespec_to_host_time(input), HostTime(u64::MAX)); + } + + #[test] + fn clock_from_known_monotonic_id() { + let clk_id = PosixClockId::Monotonic as u32; + let clock = Clock::from_presentation_clock_id(clk_id).unwrap(); + assert_eq!(clock, Clock::Presentation(PosixClockId::Monotonic)); + // The returned clock must be readable. + assert!(clock.now().ticks() > 0); + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn clock_from_known_monotonic_raw_id() { + let clk_id = PosixClockId::MonotonicRaw as u32; + let clock = Clock::from_presentation_clock_id(clk_id).unwrap(); + assert_eq!(clock, Clock::Presentation(PosixClockId::MonotonicRaw)); + assert!(clock.now().ticks() > 0); + } + + #[test] + fn clock_from_unknown_in_range_id() { + // A value that fits in i32 but is not a recognized POSIX clock. + assert!(Clock::from_presentation_clock_id(12345).is_none()); + } + + #[test] + fn clock_from_overflow_id() { + // u32::MAX overflows i32, should be rejected. + assert!(Clock::from_presentation_clock_id(u32::MAX).is_none()); + } +} From 2d5880604fa92a08e93c8de173c4c18b33b270fc Mon Sep 17 00:00:00 2001 From: nomaterials Date: Mon, 15 Jun 2026 09:46:15 +0200 Subject: [PATCH 2/3] frameclock_wayland: document TickerState as one paced surface stream TickerState keeps a single most-recent actual-present timestamp and stamps it onto the next tick's prev_actual_present. Document that each instance models one paced surface/output stream: create one per wl_surface, pass a stable OutputId, and feed it only that surface's presentation feedback. Hosts multiplexing several surfaces on one queue should keep a TickerState per stream and correlate feedback by SubmissionId themselves. This makes the single-surface contract explicit for reuse beyond subduction_backend_wayland; SubmissionId-based correlation remains future work in the present-hint/driver wrapper layer. --- frameclock_wayland/README.md | 6 +++++- frameclock_wayland/src/tick.rs | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frameclock_wayland/README.md b/frameclock_wayland/README.md index 1972239..2c4134d 100644 --- a/frameclock_wayland/README.md +++ b/frameclock_wayland/README.md @@ -29,7 +29,11 @@ wp_presentation.clock_id -> Clock -> HostTime reads Use `TickerState` as the frame-callback bookkeeping for one surface: call `mark_callback_requested` when a `wl_surface.frame` request is sent, call `on_callback_done` when the matching `wl_callback.done` event arrives, and -drain resulting ticks with `poll_tick`. +drain resulting ticks with `poll_tick`. A `TickerState` models a single paced +surface/output stream — create one per `wl_surface`, pass it a stable +`OutputId`, and feed it only that surface's presentation feedback. Hosts that +multiplex several surfaces on one queue should keep a `TickerState` per stream +and correlate feedback to the right stream by `SubmissionId` themselves. Use `presentation_time_to_host_time` to convert `wp_presentation_feedback.presented` timestamps, store the most recent value diff --git a/frameclock_wayland/src/tick.rs b/frameclock_wayland/src/tick.rs index ff739d0..e803f92 100644 --- a/frameclock_wayland/src/tick.rs +++ b/frameclock_wayland/src/tick.rs @@ -63,6 +63,24 @@ impl Default for TickQueue { /// `wl_surface.frame` request, [`on_callback_done`](Self::on_callback_done) /// when the matching `wl_callback.done` event arrives, and drain ticks with /// [`poll_tick`](Self::poll_tick). +/// +/// # One stream per surface +/// +/// A `TickerState` models a single paced surface/output stream. Create one +/// instance per `wl_surface` you pace, drive it only with that surface's frame +/// callbacks, and pass a stable [`OutputId`] for the stream to +/// [`on_callback_done`](Self::on_callback_done). +/// +/// The ticker keeps a single most-recent actual-present timestamp (see +/// [`set_last_observed_actual_present`](Self::set_last_observed_actual_present)) +/// and stamps it onto the next tick's [`FrameTick::prev_actual_present`]. Feed +/// it only presentation feedback for the same surface/output stream: mixing in +/// feedback from an unrelated surface or output would attribute one surface's +/// presentation to another. Hosts that multiplex several surfaces on one event +/// queue should keep a `TickerState` per stream and correlate presentation +/// feedback to the right stream themselves — for example by the +/// [`SubmissionId`](crate::SubmissionId) carried on each +/// [`PresentEvent`](crate::PresentEvent). #[derive(Debug)] pub struct TickerState { queue: TickQueue, @@ -89,6 +107,10 @@ impl TickerState { /// `output` with the current time read from `clock`, enqueues it, /// increments the tick index, and clears the in-flight flag. If no /// callback is in flight, debug-asserts and returns. + /// + /// `output` should identify this stream's current target output and stay + /// stable for the stream's lifetime; refresh it only when the surface + /// actually moves between outputs. pub fn on_callback_done(&mut self, clock: Clock, output: OutputId) { debug_assert!( self.callback_in_flight, @@ -137,6 +159,9 @@ impl TickerState { /// Stores the most recent actual present time for propagation into the /// next [`FrameTick::prev_actual_present`]. + /// + /// Feed this only with presentation feedback for the same surface/output + /// stream this ticker paces (see the [type-level contract](Self#one-stream-per-surface)). pub fn set_last_observed_actual_present(&mut self, t: HostTime) { self.last_observed_actual_present = Some(t); } From 9323c0c42b9279adfaa8711db2f360c510507af8 Mon Sep 17 00:00:00 2001 From: nomaterials Date: Mon, 15 Jun 2026 09:50:43 +0200 Subject: [PATCH 3/3] frameclock_wayland: make TickerState double-request handling explicit mark_callback_requested now returns bool instead of only debug-asserting: true when the single in-flight callback slot was newly claimed, false when a callback is already in flight (leaving state unchanged). Marked #[must_use] so external hosts handle the rejected case instead of silently re-arming. Update the crate docs and example to claim the slot before sending the wl_surface.frame request, and add a test covering the rejected double request. subduction_backend_wayland keeps its own ticker and external guard until the backend is migrated to this crate. --- frameclock_wayland/README.md | 7 +++-- frameclock_wayland/src/lib.rs | 6 ++-- frameclock_wayland/src/tick.rs | 53 +++++++++++++++++++++++++--------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/frameclock_wayland/README.md b/frameclock_wayland/README.md index 2c4134d..9aff2a1 100644 --- a/frameclock_wayland/README.md +++ b/frameclock_wayland/README.md @@ -27,9 +27,10 @@ wp_presentation.clock_id -> Clock -> HostTime reads ``` Use `TickerState` as the frame-callback bookkeeping for one surface: call -`mark_callback_requested` when a `wl_surface.frame` request is sent, call -`on_callback_done` when the matching `wl_callback.done` event arrives, and -drain resulting ticks with `poll_tick`. A `TickerState` models a single paced +`mark_callback_requested` to claim the single in-flight slot before sending a +`wl_surface.frame` request (it returns `false` if a callback is already in +flight), call `on_callback_done` when the matching `wl_callback.done` event +arrives, and drain resulting ticks with `poll_tick`. A `TickerState` models a single paced surface/output stream — create one per `wl_surface`, pass it a stable `OutputId`, and feed it only that surface's presentation feedback. Hosts that multiplex several surfaces on one queue should keep a `TickerState` per stream diff --git a/frameclock_wayland/src/lib.rs b/frameclock_wayland/src/lib.rs index 5ecee14..4b09cfe 100644 --- a/frameclock_wayland/src/lib.rs +++ b/frameclock_wayland/src/lib.rs @@ -30,8 +30,10 @@ //! let mut ticker = TickerState::new(); //! let clock = Clock::Monotonic; //! -//! // When sending a wl_surface.frame request: -//! ticker.mark_callback_requested(); +//! // Claim the single in-flight slot before sending a wl_surface.frame request: +//! if ticker.mark_callback_requested() { +//! // send the wl_surface.frame request +//! } //! //! // When the matching wl_callback.done event arrives: //! ticker.on_callback_done(clock, OutputId(0)); diff --git a/frameclock_wayland/src/tick.rs b/frameclock_wayland/src/tick.rs index e803f92..a89b1fa 100644 --- a/frameclock_wayland/src/tick.rs +++ b/frameclock_wayland/src/tick.rs @@ -148,13 +148,22 @@ impl TickerState { self.callback_in_flight } - /// Marks that a frame callback request has been sent. - pub fn mark_callback_requested(&mut self) { - debug_assert!( - !self.callback_in_flight, - "mark_callback_requested called while a callback is already in flight" - ); + /// Claims the single in-flight callback slot before a `wl_surface.frame` + /// request is sent. + /// + /// Only one frame callback may be in flight at a time. Returns `true` when + /// the slot was newly claimed and the caller should send the + /// `wl_surface.frame` request. Returns `false` when a callback is already + /// in flight; in that case the ticker state is left unchanged and the + /// caller must not request another callback. The slot is released when the + /// matching [`on_callback_done`](Self::on_callback_done) runs. + #[must_use = "a false return means a callback is already in flight and no new frame request should be sent"] + pub fn mark_callback_requested(&mut self) -> bool { + if self.callback_in_flight { + return false; + } self.callback_in_flight = true; + true } /// Stores the most recent actual present time for propagation into the @@ -228,7 +237,7 @@ mod tests { fn on_callback_done_enqueues_tick_with_correct_fields() { let mut ticker = TickerState::new(); - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); let tick = ticker.poll_tick().expect("should have a tick"); @@ -244,7 +253,7 @@ mod tests { fn on_callback_done_uses_caller_output() { let mut ticker = TickerState::new(); - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(3)); let tick = ticker.poll_tick().expect("should have a tick"); @@ -262,7 +271,7 @@ mod tests { let mut ticker = TickerState::new(); for expected in 0..5 { - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); let tick = ticker.poll_tick().unwrap(); assert_eq!(tick.frame_index, expected); @@ -274,18 +283,36 @@ mod tests { let mut ticker = TickerState::new(); assert!(!ticker.is_callback_in_flight()); - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); assert!(ticker.is_callback_in_flight()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); assert!(!ticker.is_callback_in_flight()); } + #[test] + fn mark_callback_requested_rejects_double_request() { + let mut ticker = TickerState::new(); + + // First request claims the in-flight slot. + assert!(ticker.mark_callback_requested()); + assert!(ticker.is_callback_in_flight()); + + // A second request while one is in flight is rejected and leaves the + // state unchanged. + assert!(!ticker.mark_callback_requested()); + assert!(ticker.is_callback_in_flight()); + + // After the callback completes, the slot can be claimed again. + ticker.on_callback_done(Clock::Monotonic, OutputId(0)); + assert!(ticker.mark_callback_requested()); + } + #[test] fn last_observed_actual_present_propagates() { let mut ticker = TickerState::new(); // First tick: no previous actual present. - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); let tick0 = ticker.poll_tick().unwrap(); assert_eq!(tick0.prev_actual_present, None); @@ -294,7 +321,7 @@ mod tests { ticker.set_last_observed_actual_present(HostTime(42_000)); // Second tick: should carry the observed time. - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); let tick1 = ticker.poll_tick().unwrap(); assert_eq!(tick1.prev_actual_present, Some(HostTime(42_000))); @@ -318,7 +345,7 @@ mod tests { let mut ticker = TickerState::new(); for _ in 0..9 { - ticker.mark_callback_requested(); + assert!(ticker.mark_callback_requested()); ticker.on_callback_done(Clock::Monotonic, OutputId(0)); }